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 ## 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. - 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. - 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. - 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 = Arc::new(Mutex::new(Vec::new()));
let progress_sink = Arc::clone(&progress); 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( let report = engine::rebuild::rebuild_from_filesystem_with_progress(
db.conn(), db.conn(),
&data_dir, &data_dir,
@@ -369,12 +369,11 @@ fn rebuild(db: &Database, incremental: bool) -> Result<CommandOutput> {
if progress.last() != Some(&line) { if progress.last() != Some(&line) {
progress.push(line); progress.push(line);
} }
true
})), })),
)?; )?;
let thumbnails =
engine::media::regenerate_missing_thumbnails(db.conn(), &data_dir, &project.id)?;
emit_bulk(&project.id); emit_bulk(&project.id);
Ok((report, thumbnails)) Ok(report)
})?; })?;
let mut result = output( let mut result = output(
"Rebuild complete", "Rebuild complete",
@@ -389,8 +388,8 @@ fn rebuild(db: &Database, incremental: bool) -> Result<CommandOutput> {
"templates_updated": report.templates_updated, "templates_updated": report.templates_updated,
"scripts_created": report.scripts_created, "scripts_created": report.scripts_created,
"scripts_updated": report.scripts_updated, "scripts_updated": report.scripts_updated,
"thumbnails_generated": thumbnails.thumbnails_generated, "thumbnails_generated": report.thumbnails_generated,
"thumbnail_media_failed": thumbnails.media_failed, "thumbnail_media_failed": report.thumbnail_media_failed,
}), }),
); );
result.progress = progress result.progress = progress

View File

@@ -6,7 +6,7 @@ use std::path::Path;
use crate::db::DbConnection as Connection; use crate::db::DbConnection as Connection;
use crate::db::queries::post as post_q; use crate::db::queries::post as post_q;
use crate::engine::EngineResult; use crate::engine::{EngineError, EngineResult};
use crate::model::PostStatus; use crate::model::PostStatus;
use crate::util::{atomic_write, timestamp}; use crate::util::{atomic_write, timestamp};
@@ -18,6 +18,15 @@ pub fn regenerate_calendar(
conn: &Connection, conn: &Connection,
data_dir: &Path, data_dir: &Path,
project_id: &str, 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<()> { ) -> EngineResult<()> {
let posts = post_q::list_posts_by_project(conn, project_id)?; 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 months: BTreeMap<String, u32> = BTreeMap::new();
let mut days: 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 { if post.status != PostStatus::Published {
continue; continue;
} }
@@ -41,6 +54,10 @@ pub fn regenerate_calendar(
*days.entry(day_key).or_insert(0) += 1; *days.entry(day_key).or_insert(0) += 1;
} }
if !on_item(total, total, "calendar.json") {
return Err(EngineError::Cancelled);
}
let calendar = serde_json::json!({ let calendar = serde_json::json!({
"years": years, "years": years,
"months": months, "months": months,
@@ -82,4 +99,20 @@ mod tests {
serde_json::from_str(&std::fs::read_to_string(&cal_path).unwrap()).unwrap(); serde_json::from_str(&std::fs::read_to_string(&cal_path).unwrap()).unwrap();
assert!(data["years"].as_object().unwrap().is_empty()); 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), Conflict(String),
#[error("validation error: {0}")] #[error("validation error: {0}")]
Validation(String), Validation(String),
#[error("operation cancelled")]
Cancelled,
} }
impl From<crate::db::DatabaseError> for EngineError { impl From<crate::db::DatabaseError> for EngineError {

View File

@@ -63,6 +63,15 @@ pub struct PreparedSiteGeneration {
generated_hashes: Arc<HashMap<String, String>>, 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( pub fn prepare_site_generation(
conn: &Connection, conn: &Connection,
data_dir: &Path, 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)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum GenerationSection { pub enum GenerationSection {
Core, Core,

View File

@@ -53,9 +53,18 @@ pub fn rebuild_media_links(
conn: &Connection, conn: &Connection,
data_dir: &Path, data_dir: &Path,
project_id: &str, 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> { ) -> EngineResult<MediaLinkRebuildReport> {
conn.begin_savepoint()?; 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) => { Ok(report) => {
conn.release_savepoint()?; conn.release_savepoint()?;
Ok(report) Ok(report)
@@ -71,9 +80,16 @@ fn rebuild_media_links_inner(
conn: &Connection, conn: &Connection,
data_dir: &Path, data_dir: &Path,
project_id: &str, project_id: &str,
on_item: Option<&ItemProgressFn>,
) -> EngineResult<MediaLinkRebuildReport> { ) -> EngineResult<MediaLinkRebuildReport> {
let mut report = MediaLinkRebuildReport::default(); 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))?) let sidecar = read_sidecar(&fs::read_to_string(data_dir.join(&item.sidecar_path))?)
.map_err(EngineError::Parse)?; .map_err(EngineError::Parse)?;
for link in qpm::list_post_media_by_media(conn, &item.id)? { for link in qpm::list_post_media_by_media(conn, &item.id)? {
@@ -110,9 +126,22 @@ pub fn regenerate_missing_thumbnails(
conn: &Connection, conn: &Connection,
data_dir: &Path, data_dir: &Path,
project_id: &str, 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> { ) -> EngineResult<ThumbnailRepairReport> {
let mut report = ThumbnailRepairReport::default(); 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") { if !item.mime_type.starts_with("image/") || item.mime_type.contains("svg") {
continue; continue;
} }
@@ -653,7 +682,7 @@ pub fn rebuild_media_from_filesystem(
} }
/// Per-item progress callback: (current_item, total_items, item_description). /// 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. /// Like `rebuild_media_from_filesystem` but with optional per-item progress.
pub fn rebuild_media_from_filesystem_with_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() { for (i, path) in canonical_sidecars.iter().enumerate() {
if let Some(ref cb) = on_item { if let Some(ref cb) = on_item {
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("?"); 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) { match rebuild_canonical_media(conn, data_dir, project_id, path) {
Ok(created) => { Ok(created) => {
@@ -720,7 +751,9 @@ pub fn rebuild_media_from_filesystem_with_progress(
for (i, path) in translation_sidecars.iter().enumerate() { for (i, path) in translation_sidecars.iter().enumerate() {
if let Some(ref cb) = on_item { if let Some(ref cb) = on_item {
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("?"); 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) { match rebuild_translation_sidecar(conn, data_dir, project_id, path) {
Ok(created) => { Ok(created) => {

View File

@@ -70,8 +70,27 @@ pub fn compute_metadata_diff(
data_dir: &Path, data_dir: &Path,
project_id: &str, project_id: &str,
) -> EngineResult<DiffReport> { ) -> 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(); let mut report = DiffReport::default();
next_phase()?;
if let Ok(project) = qproject::get_project_by_id(conn, project_id) { if let Ok(project) = qproject::get_project_by_id(conn, project_id) {
match diff_project(conn, data_dir, &project) { match diff_project(conn, data_dir, &project) {
Ok(Some(diff)) => report.diffs.push(diff), Ok(Some(diff)) => report.diffs.push(diff),
@@ -94,6 +113,7 @@ pub fn compute_metadata_diff(
} }
// 1. Diff posts // 1. Diff posts
next_phase()?;
let posts = qp::list_posts_by_project(conn, project_id)?; let posts = qp::list_posts_by_project(conn, project_id)?;
for post in &posts { for post in &posts {
if post.file_path.is_empty() { if post.file_path.is_empty() {
@@ -107,6 +127,7 @@ pub fn compute_metadata_diff(
} }
// 2. Diff translations // 2. Diff translations
next_phase()?;
for post in &posts { for post in &posts {
let translations = qt::list_post_translations_by_post(conn, &post.id)?; let translations = qt::list_post_translations_by_post(conn, &post.id)?;
for t in &translations { for t in &translations {
@@ -122,6 +143,7 @@ pub fn compute_metadata_diff(
} }
// 3. Diff media // 3. Diff media
next_phase()?;
let media_items = qm::list_media_by_project(conn, project_id)?; let media_items = qm::list_media_by_project(conn, project_id)?;
for m in &media_items { for m in &media_items {
if m.sidecar_path.is_empty() { if m.sidecar_path.is_empty() {
@@ -146,6 +168,7 @@ pub fn compute_metadata_diff(
} }
// 4. Diff templates // 4. Diff templates
next_phase()?;
let templates = qtpl::list_templates_by_project(conn, project_id)?; let templates = qtpl::list_templates_by_project(conn, project_id)?;
for t in &templates { for t in &templates {
if t.file_path.is_empty() { if t.file_path.is_empty() {
@@ -159,6 +182,7 @@ pub fn compute_metadata_diff(
} }
// 5. Diff scripts // 5. Diff scripts
next_phase()?;
let scripts = qs::list_scripts_by_project(conn, project_id)?; let scripts = qs::list_scripts_by_project(conn, project_id)?;
for s in &scripts { for s in &scripts {
if s.file_path.is_empty() { if s.file_path.is_empty() {
@@ -172,6 +196,7 @@ pub fn compute_metadata_diff(
} }
// 6. Detect orphans // 6. Detect orphans
next_phase()?;
if crate::engine::meta::read_project_json(data_dir) if crate::engine::meta::read_project_json(data_dir)
.is_ok_and(|metadata| metadata.semantic_similarity_enabled) .is_ok_and(|metadata| metadata.semantic_similarity_enabled)
{ {
@@ -215,9 +240,14 @@ pub fn compute_metadata_diff(
} }
// 7. Detect orphans // 7. Detect orphans
next_phase()?;
let orphans = detect_orphan_files(conn, data_dir, project_id)?; let orphans = detect_orphan_files(conn, data_dir, project_id)?;
report.orphans = orphans; report.orphans = orphans;
if !on_progress(PHASES, PHASES) {
return Err(crate::engine::EngineError::Cancelled);
}
Ok(report) Ok(report)
} }

View File

@@ -716,7 +716,7 @@ pub fn rebuild_posts_from_filesystem(
} }
/// Per-item progress callback: (current_item, total_items, item_description). /// 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. /// Like `rebuild_posts_from_filesystem` but with optional per-item progress.
pub fn rebuild_posts_from_filesystem_with_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() { for (i, path) in canonical_files.iter().enumerate() {
if let Some(ref cb) = on_item { if let Some(ref cb) = on_item {
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("?"); 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) { match rebuild_canonical_post(conn, data_dir, project_id, path) {
Ok(created) => { Ok(created) => {
@@ -781,7 +783,9 @@ pub fn rebuild_posts_from_filesystem_with_progress(
for (i, path) in translation_files.iter().enumerate() { for (i, path) in translation_files.iter().enumerate() {
if let Some(ref cb) = on_item { if let Some(ref cb) = on_item {
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("?"); 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) { match rebuild_translation(conn, data_dir, project_id, path) {
Ok(created) => { Ok(created) => {
@@ -816,11 +820,25 @@ pub fn rebuild_all_links(
conn: &Connection, conn: &Connection,
data_dir: &Path, data_dir: &Path,
project_id: &str, 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> { ) -> EngineResult<usize> {
let posts = qp::list_posts_by_project(conn, project_id)?; let posts = qp::list_posts_by_project(conn, project_id)?;
let mut link_count = 0; 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 // Get post content: from DB or filesystem
let content = if let Some(ref content) = post.content { let content = if let Some(ref content) = post.content {
content.clone() content.clone()

View File

@@ -1,7 +1,10 @@
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use std::fs; use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf}; 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 serde::{Deserialize, Serialize};
use walkdir::WalkDir; use walkdir::WalkDir;
@@ -124,6 +127,26 @@ pub fn upload_site(
private_cache_dir: &Path, private_cache_dir: &Path,
preferences: &PublishingPreferences, preferences: &PublishingPreferences,
mut on_progress: impl FnMut(usize, usize, UploadTargetKind), 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> { ) -> EngineResult<PublishJob> {
if std::env::var_os("SSH_AUTH_SOCK").is_none() { if std::env::var_os("SSH_AUTH_SOCK").is_none() {
return Err(EngineError::Validation( return Err(EngineError::Validation(
@@ -134,7 +157,7 @@ pub fn upload_site(
data_dir, data_dir,
private_cache_dir, private_cache_dir,
preferences, preferences,
&mut |program, args| run_command(program, args), &mut |program, args| run_command_cancellable(program, args, cancel_flag),
&mut on_progress, &mut on_progress,
) )
} }
@@ -144,7 +167,7 @@ fn upload_site_with_runner(
private_cache_dir: &Path, private_cache_dir: &Path,
preferences: &PublishingPreferences, preferences: &PublishingPreferences,
runner: &mut CommandRunner<'_>, runner: &mut CommandRunner<'_>,
on_progress: &mut dyn FnMut(usize, usize, UploadTargetKind), on_progress: &mut dyn FnMut(usize, usize, UploadTargetKind) -> bool,
) -> EngineResult<PublishJob> { ) -> EngineResult<PublishJob> {
let credentials = Credentials::from_preferences(preferences)?; let credentials = Credentials::from_preferences(preferences)?;
let targets = build_upload_targets(data_dir, &credentials); 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 cache_path = private_cache_dir.join("publishing-scp-mtimes.json");
let mut cache = read_cache(&cache_path); let mut cache = read_cache(&cache_path);
for (index, target) in targets.iter().enumerate() { 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 { let result = match credentials.mode {
SshMode::Rsync => upload_rsync(target, &credentials, runner), SshMode::Rsync => upload_rsync(target, &credentials, runner),
SshMode::Scp => upload_scp(target, &credentials, &mut cache, runner), SshMode::Scp => upload_scp(target, &credentials, &mut cache, runner),
@@ -328,21 +353,49 @@ fn write_cache(path: &Path, cache: &ScpMtimeCache) -> EngineResult<()> {
Ok(()) Ok(())
} }
fn run_command(program: &str, args: &[String]) -> Result<(), String> { fn run_command_cancellable(
let output = Command::new(program) program: &str,
args: &[String],
cancel_flag: &AtomicBool,
) -> Result<(), String> {
let mut child = Command::new(program)
.args(args) .args(args)
.output() .stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|error| format!("failed to start {program}: {error}"))?; .map_err(|error| format!("failed to start {program}: {error}"))?;
if output.status.success() { let stderr = child.stderr.take();
Ok(()) let stderr_reader = std::thread::spawn(move || {
} else { let mut output = String::new();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); if let Some(mut stderr) = stderr {
Err(if stderr.is_empty() { let _ = stderr.read_to_string(&mut output);
format!("{program} exited with {}", output.status) }
} else { output
stderr });
}) 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)] #[cfg(test)]
@@ -376,7 +429,7 @@ mod tests {
commands.push((program.to_owned(), args.to_vec())); commands.push((program.to_owned(), args.to_vec()));
Ok(()) Ok(())
}, },
&mut |_, _, _| {}, &mut |_, _, _| true,
) )
.unwrap(); .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] #[test]
fn scp_excludes_sidecars_and_skips_unchanged_files() { fn scp_excludes_sidecars_and_skips_unchanged_files() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
@@ -415,7 +490,7 @@ mod tests {
first.push((program.to_owned(), args.to_vec())); first.push((program.to_owned(), args.to_vec()));
Ok(()) Ok(())
}, },
&mut |_, _, _| {}, &mut |_, _, _| true,
) )
.unwrap(); .unwrap();
assert_eq!( assert_eq!(
@@ -437,7 +512,7 @@ mod tests {
second.push(program.to_owned()); second.push(program.to_owned());
Ok(()) Ok(())
}, },
&mut |_, _, _| {}, &mut |_, _, _| true,
) )
.unwrap(); .unwrap();
assert!(second.is_empty()); assert!(second.is_empty());
@@ -454,7 +529,7 @@ mod tests {
cache.path(), cache.path(),
&preferences(SshMode::Rsync), &preferences(SshMode::Rsync),
&mut |_, _| Err("network down".into()), &mut |_, _| Err("network down".into()),
&mut |_, _, _| {}, &mut |_, _, _| true,
) )
.unwrap_err(); .unwrap_err();
assert!(error.to_string().contains("network down")); assert!(error.to_string().contains("network down"));

View File

@@ -27,6 +27,8 @@ pub struct FullRebuildReport {
pub templates_updated: usize, pub templates_updated: usize,
pub scripts_created: usize, pub scripts_created: usize,
pub scripts_updated: usize, pub scripts_updated: usize,
pub thumbnails_generated: usize,
pub thumbnail_media_failed: usize,
pub errors: Vec<String>, pub errors: Vec<String>,
} }
@@ -91,8 +93,19 @@ pub enum RebuildProgress {
name: String, name: String,
}, },
RebuildingTemplates, RebuildingTemplates,
TemplateItem {
current: usize,
total: usize,
name: String,
},
RebuildingScripts, RebuildingScripts,
ScriptItem {
current: usize,
total: usize,
name: String,
},
ImportingTags, ImportingTags,
RebuildingThumbnails,
RefreshingSemanticIndex, RefreshingSemanticIndex,
Complete, Complete,
} }
@@ -116,8 +129,19 @@ impl RebuildProgress {
name, name,
} => localize_item(locale, "engine.progress.mediaItem", *current, *total, name), } => localize_item(locale, "engine.progress.mediaItem", *current, *total, name),
Self::RebuildingTemplates => translate(locale, "engine.progress.rebuildingTemplates"), 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::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::ImportingTags => translate(locale, "engine.progress.importingTags"),
Self::RebuildingThumbnails => translate(locale, "engine.progress.rebuildingThumbnails"),
Self::RefreshingSemanticIndex => { Self::RefreshingSemanticIndex => {
translate(locale, "engine.progress.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). /// 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. /// Orchestrate a full rebuild from filesystem into the database.
/// ///
@@ -178,23 +202,26 @@ fn rebuild_from_filesystem_inner(
on_progress: Option<ProgressFn>, on_progress: Option<ProgressFn>,
) -> EngineResult<FullRebuildReport> { ) -> EngineResult<FullRebuildReport> {
let mut report = FullRebuildReport::default(); let mut report = FullRebuildReport::default();
let progress = |pct: f32, event: RebuildProgress| { let progress = |pct: f32, event: RebuildProgress| -> EngineResult<()> {
if let Some(ref f) = on_progress { if let Some(ref f) = on_progress
f(pct, &event); && !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 // 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. // 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)?; fts::ensure_fts_tables(conn)?;
crate::engine::meta::startup_sync(data_dir)?; crate::engine::meta::startup_sync(data_dir)?;
crate::engine::meta::sync_metadata_from_filesystem(conn, data_dir, project_id)?; crate::engine::meta::sync_metadata_from_filesystem(conn, data_dir, project_id)?;
clear_project_rows(conn, project_id)?; clear_project_rows(conn, project_id)?;
// 2. Rebuild posts (0.00 .. 0.35) // 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 post_item_cb: Option<post::ItemProgressFn> = on_progress.as_ref().map(|cb| {
let cb = Arc::clone(cb); let cb = Arc::clone(cb);
let f: post::ItemProgressFn = Box::new(move |current, total, name| { let f: post::ItemProgressFn = Box::new(move |current, total, name| {
@@ -211,7 +238,7 @@ fn rebuild_from_filesystem_inner(
total, total,
name: name.to_string(), name: name.to_string(),
}, },
); )
}); });
f f
}); });
@@ -228,7 +255,7 @@ fn rebuild_from_filesystem_inner(
report.errors.extend(post_report.errors); report.errors.extend(post_report.errors);
// 3. Rebuild media (0.35 .. 0.70) // 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 media_item_cb: Option<media::ItemProgressFn> = on_progress.as_ref().map(|cb| {
let cb = Arc::clone(cb); let cb = Arc::clone(cb);
let f: media::ItemProgressFn = Box::new(move |current, total, name| { let f: media::ItemProgressFn = Box::new(move |current, total, name| {
@@ -245,7 +272,7 @@ fn rebuild_from_filesystem_inner(
total, total,
name: name.to_string(), name: name.to_string(),
}, },
); )
}); });
f f
}); });
@@ -262,26 +289,87 @@ fn rebuild_from_filesystem_inner(
report.errors.extend(media_report.errors); report.errors.extend(media_report.errors);
// 4. Rebuild templates (0.70 .. 0.85) // 4. Rebuild templates (0.70 .. 0.85)
progress(0.70, RebuildProgress::RebuildingTemplates); progress(0.70, RebuildProgress::RebuildingTemplates)?;
let tpl_report = let template_progress = on_progress.as_ref().map(|callback| {
template_rebuild::rebuild_templates_from_filesystem(conn, data_dir, project_id)?; 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_created = tpl_report.created;
report.templates_updated = tpl_report.updated; report.templates_updated = tpl_report.updated;
report.errors.extend(tpl_report.errors); report.errors.extend(tpl_report.errors);
// 5. Rebuild scripts (0.85 .. 0.95) // 5. Rebuild scripts (0.85 .. 0.95)
progress(0.85, RebuildProgress::RebuildingScripts); progress(0.85, RebuildProgress::RebuildingScripts)?;
let script_report = let script_progress = on_progress.as_ref().map(|callback| {
script_rebuild::rebuild_scripts_from_filesystem(conn, data_dir, project_id)?; 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_created = script_report.created;
report.scripts_updated = script_report.updated; report.scripts_updated = script_report.updated;
report.errors.extend(script_report.errors); report.errors.extend(script_report.errors);
// 6. Restore relationships and tags (0.95 .. 1.0) // 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::import_tags_from_file(conn, data_dir, project_id)?;
super::tag::sync_tags_from_posts(conn, 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() { if !report.errors.is_empty() {
return Err(crate::engine::EngineError::Validation(format!( return Err(crate::engine::EngineError::Validation(format!(
@@ -290,11 +378,39 @@ fn rebuild_from_filesystem_inner(
))); )));
} }
progress(0.98, RebuildProgress::RefreshingSemanticIndex); progress(0.97, RebuildProgress::RebuildingThumbnails)?;
crate::engine::embedding::EmbeddingService::production(conn, data_dir) let thumbnail_report = media::regenerate_missing_thumbnails_with_progress(
.index_unindexed(project_id)?; 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) Ok(report)
} }
@@ -445,6 +561,36 @@ mod tests {
assert!(report.errors.is_empty()); 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] #[test]
fn rebuild_creates_posts_and_media() { fn rebuild_creates_posts_and_media() {
let (db, dir) = setup(); let (db, dir) = setup();

View File

@@ -17,6 +17,8 @@ pub struct ScriptRebuildReport {
pub errors: Vec<String>, pub errors: Vec<String>,
} }
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) -> bool + Send>;
/// Rebuild scripts from the filesystem into the database. /// Rebuild scripts from the filesystem into the database.
/// ///
/// Walks the `scripts/` directory for `*.lua` files, parses each /// Walks the `scripts/` directory for `*.lua` files, parses each
@@ -26,6 +28,15 @@ pub fn rebuild_scripts_from_filesystem(
conn: &Connection, conn: &Connection,
data_dir: &Path, data_dir: &Path,
project_id: &str, 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> { ) -> EngineResult<ScriptRebuildReport> {
let mut report = ScriptRebuildReport::default(); let mut report = ScriptRebuildReport::default();
let scripts_dir = data_dir.join("scripts"); let scripts_dir = data_dir.join("scripts");
@@ -34,17 +45,23 @@ pub fn rebuild_scripts_from_filesystem(
return Ok(report); return Ok(report);
} }
for entry in WalkDir::new(&scripts_dir) let files = WalkDir::new(&scripts_dir)
.into_iter() .into_iter()
.filter_map(|e| e.ok()) .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(); let path = entry.path();
if !path.is_file() { let name = path
continue; .file_stem()
} .and_then(|stem| stem.to_str())
let ext = path.extension().and_then(|e| e.to_str()); .unwrap_or("?");
if ext != Some("lua") { if let Some(ref callback) = on_item
continue; && !callback(index + 1, files.len(), name)
{
return Err(EngineError::Cancelled);
} }
match rebuild_single_script(conn, data_dir, project_id, path) { 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). /// 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 /// Repair a missing or previously deployed FTS schema and report whether its
/// derived content still needs to be rebuilt. /// 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)? { for post in post_q::list_posts_by_project(conn, project_id)? {
*current += 1; *current += 1;
if let Some(callback) = on_item { if let Some(callback) = on_item
callback(*current, total, &post.title); && !callback(*current, total, &post.title)
{
return Err(EngineError::Cancelled);
} }
let translations = post_translation::list_post_translations_by_post(conn, &post.id)?; let translations = post_translation::list_post_translations_by_post(conn, &post.id)?;
let translation_data = translations let translation_data = translations
@@ -255,8 +257,10 @@ fn index_project(
for media in media_q::list_media_by_project(conn, project_id)? { for media in media_q::list_media_by_project(conn, project_id)? {
*current += 1; *current += 1;
if let Some(callback) = on_item { if let Some(callback) = on_item
callback(*current, total, &media.original_name); && !callback(*current, total, &media.original_name)
{
return Err(EngineError::Cancelled);
} }
let translations = media_translation::list_media_translations_by_media(conn, &media.id)?; let translations = media_translation::list_media_translations_by_media(conn, &media.id)?;
let translation_data = translations let translation_data = translations
@@ -374,6 +378,29 @@ mod tests {
assert_eq!(results.len(), 1); 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] #[test]
fn rebuild_indexes_published_post_and_translation_bodies_from_files() { fn rebuild_indexes_published_post_and_translation_bodies_from_files() {
let db = Database::open_in_memory().unwrap(); 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::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Condvar, Mutex}; use std::sync::{Arc, Condvar, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use tokio::sync::Notify;
/// Unique task identifier. /// Unique task identifier.
pub type TaskId = u64; pub type TaskId = u64;
@@ -25,6 +28,7 @@ pub struct TaskSnapshot {
pub status: TaskStatus, pub status: TaskStatus,
pub progress: Option<f32>, pub progress: Option<f32>,
pub message: Option<String>, pub message: Option<String>,
pub cancellation_requested: bool,
pub created_at: Instant, pub created_at: Instant,
} }
@@ -42,228 +46,340 @@ struct TaskEntry {
created_at: Instant, created_at: Instant,
finished_at: Option<Instant>, finished_at: Option<Instant>,
last_progress_report: Option<Instant>, last_progress_report: Option<Instant>,
worker_active: bool,
worker_started: bool,
} }
/// Manages concurrent tasks with a max concurrency limit and FIFO queue. /// Manages concurrent tasks with a max concurrency limit and FIFO queue.
pub struct TaskManager { pub struct TaskManager {
max_concurrent: usize, max_concurrent: usize,
next_id: Mutex<TaskId>, state: Mutex<TaskState>,
tasks: Mutex<Vec<TaskEntry>>,
state_changed: Condvar, 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 { impl TaskManager {
/// Create a new task manager with the given concurrency limit. /// Create a new task manager with the given concurrency limit.
pub fn new(max_concurrent: usize) -> Self { pub fn new(max_concurrent: usize) -> Self {
Self { Self {
max_concurrent, max_concurrent: max_concurrent.max(1),
next_id: Mutex::new(1), state: Mutex::new(TaskState {
tasks: Mutex::new(Vec::new()), next_id: 1,
tasks: HashMap::new(),
order: VecDeque::new(),
pending: VecDeque::new(),
worker_count: 0,
}),
state_changed: Condvar::new(), state_changed: Condvar::new(),
async_changed: Notify::new(),
} }
} }
/// Submit a new task. Returns its unique identifier. /// Submit a new task. Returns its unique identifier.
pub fn submit(&self, label: &str) -> TaskId { pub fn submit(&self, label: &str) -> TaskId {
let mut next = self.next_id.lock().unwrap(); self.submit_with_group(label, None, None)
let id = *next; }
*next += 1;
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, id,
label: label.to_owned(), TaskEntry {
group_id: None, id,
group_name: None, label: label.to_owned(),
status: TaskStatus::Pending, group_id: group_id.map(str::to_owned),
cancel_flag: Arc::new(AtomicBool::new(false)), group_name: group_name.map(str::to_owned),
progress: None, status: TaskStatus::Pending,
message: None, cancel_flag: Arc::new(AtomicBool::new(false)),
created_at: Instant::now(), progress: None,
finished_at: None, message: None,
last_progress_report: None, created_at: Instant::now(),
}; finished_at: None,
last_progress_report: None,
let mut tasks = self.tasks.lock().unwrap(); worker_active: false,
tasks.push(entry); worker_started: false,
// Auto-start if under capacity },
let running = tasks );
.iter() state.order.push_back(id);
.filter(|t| t.status == TaskStatus::Running) state.pending.push_back(id);
.count(); Self::promote_next(&mut state, self.max_concurrent);
if running < self.max_concurrent drop(state);
&& let Some(t) = tasks self.notify_changed();
.iter_mut()
.find(|t| t.id == id && t.status == TaskStatus::Pending)
{
t.status = TaskStatus::Running;
}
id id
} }
/// Submit a new task within a group. Returns its unique identifier. /// 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 { pub fn submit_grouped(&self, label: &str, group_id: &str, group_name: &str) -> TaskId {
let id = self.submit(label); self.submit_with_group(label, Some(group_id), Some(group_name))
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
} }
/// 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 { 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 { loop {
match tasks match state.tasks.get(&task_id).map(|task| &task.status) {
.iter() Some(TaskStatus::Running) => {
.find(|task| task.id == task_id) if let Some(task) = state.tasks.get_mut(&task_id) {
.map(|task| &task.status) task.worker_started = true;
{ }
Some(TaskStatus::Running) => return true, return true;
Some(TaskStatus::Pending) => tasks = self.state_changed.wait(tasks).unwrap(), }
Some(TaskStatus::Pending) => state = self.state_changed.wait(state).unwrap(),
_ => return false, _ => 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. /// Mark a task as completed.
pub fn complete(&self, task_id: TaskId) { pub fn complete(&self, task_id: TaskId) {
let mut tasks = self.tasks.lock().unwrap(); let mut state = self.state.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) let released = if let Some(entry) = state.tasks.get_mut(&task_id) {
&& matches!(entry.status, TaskStatus::Running) if matches!(entry.status, TaskStatus::Running) {
{ entry.status = TaskStatus::Completed;
entry.status = TaskStatus::Completed; entry.progress = Some(1.0);
entry.progress = Some(1.0); entry.finished_at = Some(Instant::now());
entry.finished_at = Some(Instant::now()); }
} let released = entry.worker_active;
Self::promote_next(&mut tasks, self.max_concurrent); entry.worker_active = false;
self.state_changed.notify_all(); 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. /// Mark a task as failed with an error message.
pub fn fail(&self, task_id: TaskId, error: String) { pub fn fail(&self, task_id: TaskId, error: String) {
let mut tasks = self.tasks.lock().unwrap(); let mut state = self.state.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) let released = if let Some(entry) = state.tasks.get_mut(&task_id) {
&& matches!(entry.status, TaskStatus::Running) if matches!(entry.status, TaskStatus::Running) {
{ if entry.cancel_flag.load(Ordering::Acquire) {
entry.message = Some(error.clone()); entry.status = TaskStatus::Cancelled;
entry.status = TaskStatus::Failed(error); } else {
entry.finished_at = Some(Instant::now()); entry.message = Some(error.clone());
} entry.status = TaskStatus::Failed(error);
Self::promote_next(&mut tasks, self.max_concurrent); }
self.state_changed.notify_all(); 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. /// Cancel queued work immediately, or request a cooperative stop from a worker.
pub fn cancel(&self, task_id: TaskId) { pub fn cancel(&self, task_id: TaskId) -> bool {
let mut tasks = self.tasks.lock().unwrap(); let mut state = self.state.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) 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) && matches!(entry.status, TaskStatus::Running | TaskStatus::Pending)
{ {
entry.cancel_flag.store(true, Ordering::Release); entry.cancel_flag.store(true, Ordering::Release);
entry.status = TaskStatus::Cancelled; if !entry.worker_started {
entry.finished_at = Some(Instant::now()); 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); state.worker_count = state.worker_count.saturating_sub(usize::from(released));
self.state_changed.notify_all(); 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. /// Cancel every active task in a group and release their workers.
pub fn cancel_group(&self, group_id: &str) { 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(); let now = Instant::now();
for entry in tasks.iter_mut().filter(|task| { let group_ids = state
task.group_id.as_deref() == Some(group_id) .tasks
&& matches!(task.status, TaskStatus::Running | TaskStatus::Pending) .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.cancel_flag.store(true, Ordering::Release);
entry.status = TaskStatus::Cancelled; if !entry.worker_started {
entry.finished_at = Some(now); 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); state.worker_count = state.worker_count.saturating_sub(released);
self.state_changed.notify_all(); Self::promote_next(&mut state, self.max_concurrent);
drop(state);
self.notify_changed();
} }
/// Return the group containing a task, if any. /// Return the group containing a task, if any.
pub fn group_id(&self, task_id: TaskId) -> Option<String> { pub fn group_id(&self, task_id: TaskId) -> Option<String> {
self.tasks let mut state = self.state.lock().unwrap();
.lock() Self::prune_expired(&mut state);
.unwrap() state
.iter() .tasks
.find(|task| task.id == task_id) .get(&task_id)
.and_then(|task| task.group_id.clone()) .and_then(|task| task.group_id.clone())
} }
/// Check whether a task has been cancelled. /// Check whether a task has been cancelled.
pub fn is_cancelled(&self, task_id: TaskId) -> bool { pub fn is_cancelled(&self, task_id: TaskId) -> bool {
let tasks = self.tasks.lock().unwrap(); let mut state = self.state.lock().unwrap();
tasks Self::prune_expired(&mut state);
.iter() state
.find(|t| t.id == task_id) .tasks
.get(&task_id)
.map(|t| t.cancel_flag.load(Ordering::Acquire)) .map(|t| t.cancel_flag.load(Ordering::Acquire))
.unwrap_or(false) .unwrap_or(false)
} }
/// Shared cancellation flag for a worker owned by this task. /// Shared cancellation flag for a worker owned by this task.
pub fn cancellation_flag(&self, task_id: TaskId) -> Option<Arc<AtomicBool>> { pub fn cancellation_flag(&self, task_id: TaskId) -> Option<Arc<AtomicBool>> {
self.tasks let mut state = self.state.lock().unwrap();
.lock() Self::prune_expired(&mut state);
.unwrap() state
.iter() .tasks
.find(|task| task.id == task_id) .get(&task_id)
.map(|task| Arc::clone(&task.cancel_flag)) .map(|task| Arc::clone(&task.cancel_flag))
} }
/// Return the current status of a task. /// Return the current status of a task.
pub fn status(&self, task_id: TaskId) -> Option<TaskStatus> { pub fn status(&self, task_id: TaskId) -> Option<TaskStatus> {
let tasks = self.tasks.lock().unwrap(); self.get(task_id).map(|task| task.status)
tasks
.iter()
.find(|t| t.id == task_id)
.map(|t| t.status.clone())
} }
/// Count tasks that are still queued. /// Count tasks that are still queued.
pub fn pending_count(&self) -> usize { pub fn pending_count(&self) -> usize {
let tasks = self.tasks.lock().unwrap(); let mut state = self.state.lock().unwrap();
tasks Self::prune_expired(&mut state);
.iter() state
.tasks
.values()
.filter(|t| t.status == TaskStatus::Pending) .filter(|t| t.status == TaskStatus::Pending)
.count() .count()
} }
/// Count tasks that are currently running. /// Count tasks that are currently running.
pub fn running_count(&self) -> usize { pub fn running_count(&self) -> usize {
let tasks = self.tasks.lock().unwrap(); let mut state = self.state.lock().unwrap();
tasks Self::prune_expired(&mut state);
.iter() state
.tasks
.values()
.filter(|t| t.status == TaskStatus::Running) .filter(|t| t.status == TaskStatus::Running)
.count() .count()
} }
/// Remove finished tasks older than the configured retention period. /// Remove finished tasks older than the configured retention period.
pub fn evict_expired(&self) { pub fn evict_expired(&self) {
let cutoff = Instant::now() - FINISHED_TASK_TTL; let mut state = self.state.lock().unwrap();
let mut tasks = self.tasks.lock().unwrap(); Self::prune_expired(&mut state);
tasks.retain(|task| {
task.finished_at
.is_none_or(|finished_at| finished_at > cutoff)
});
} }
/// Remove every finished task while preserving running and queued work. /// Remove every finished task while preserving running and queued work.
pub fn clear_completed(&self) { pub fn clear_completed(&self) {
self.tasks let mut state = self.state.lock().unwrap();
.lock() Self::remove_where(&mut state, |task| task.status == TaskStatus::Completed);
.unwrap() }
.retain(|task| matches!(task.status, TaskStatus::Pending | TaskStatus::Running));
/// 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. /// 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>) { pub fn report_progress(&self, task_id: TaskId, progress: Option<f32>, message: Option<String>) {
let mut tasks = self.tasks.lock().unwrap(); let mut state = self.state.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) if let Some(entry) = state.tasks.get_mut(&task_id)
&& entry.status == TaskStatus::Running && entry.status == TaskStatus::Running
{ {
let now = Instant::now(); let now = Instant::now();
@@ -271,7 +387,7 @@ impl TaskManager {
Some(prev) => now.duration_since(prev).as_millis() >= PROGRESS_THROTTLE_MS as u128, Some(prev) => now.duration_since(prev).as_millis() >= PROGRESS_THROTTLE_MS as u128,
None => true, None => true,
}; };
if should_report { if should_report || progress.is_some_and(|value| value >= 1.0) {
entry.progress = progress; entry.progress = progress;
entry.message = message; entry.message = message;
entry.last_progress_report = Some(now); entry.last_progress_report = Some(now);
@@ -281,58 +397,157 @@ impl TaskManager {
/// Return the current progress of a task. /// Return the current progress of a task.
pub fn progress(&self, task_id: TaskId) -> Option<f32> { pub fn progress(&self, task_id: TaskId) -> Option<f32> {
let tasks = self.tasks.lock().unwrap(); self.get(task_id).and_then(|task| task.progress)
tasks
.iter()
.find(|t| t.id == task_id)
.and_then(|t| t.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> { pub fn snapshots(&self) -> Vec<TaskSnapshot> {
let tasks = self.tasks.lock().unwrap(); let mut state = self.state.lock().unwrap();
let mut snapshots = tasks Self::prune_expired(&mut state);
.iter() let mut active = state
.tasks
.values()
.filter(|task| task.finished_at.is_none()) .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<_>>(); .collect::<Vec<_>>();
snapshots.sort_by_key(|snapshot| snapshot.created_at); active.sort_by_key(|task| (task.status != TaskStatus::Running, task.created_at));
snapshots 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. /// Return one retained task by id.
fn promote_next(tasks: &mut [TaskEntry], max_concurrent: usize) { pub fn get(&self, task_id: TaskId) -> Option<TaskSnapshot> {
while tasks 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() .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) .filter(|task| task.status == TaskStatus::Running)
.count() .map(Self::snapshot)
< max_concurrent .collect()
{ }
let Some(task) = tasks
.iter_mut() fn worker_exited(&self, task_id: TaskId) {
.find(|task| task.status == TaskStatus::Pending) let mut state = self.state.lock().unwrap();
else { 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; break;
}; };
let Some(task) = state.tasks.get_mut(&id) else {
continue;
};
if task.status != TaskStatus::Pending {
continue;
}
task.status = TaskStatus::Running; 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 { impl Default for TaskManager {
@@ -399,7 +614,7 @@ mod tests {
} }
#[test] #[test]
fn cancel_sets_flag() { fn cancelling_unstarted_task_settles_immediately() {
let mgr = TaskManager::default(); let mgr = TaskManager::default();
let id = mgr.submit("upload"); let id = mgr.submit("upload");
// Task is auto-started (Running) // Task is auto-started (Running)
@@ -409,6 +624,35 @@ mod tests {
assert_eq!(mgr.status(id), Some(TaskStatus::Cancelled)); 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] #[test]
fn complete_and_fail() { fn complete_and_fail() {
let mgr = TaskManager::default(); let mgr = TaskManager::default();
@@ -444,8 +688,12 @@ mod tests {
// After b fails: d promoted to running // After b fails: d promoted to running
{ {
let mut tasks = mgr.tasks.lock().unwrap(); let mut state = mgr.state.lock().unwrap();
for task in tasks.iter_mut().filter(|task| task.finished_at.is_some()) { for task in state
.tasks
.values_mut()
.filter(|task| task.finished_at.is_some())
{
task.finished_at = task.finished_at =
Some(Instant::now() - FINISHED_TASK_TTL - Duration::from_secs(1)); Some(Instant::now() - FINISHED_TASK_TTL - Duration::from_secs(1));
} }
@@ -469,6 +717,34 @@ mod tests {
assert_eq!(mgr.snapshots().len(), 10); 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] #[test]
fn clear_completed_preserves_active_tasks() { fn clear_completed_preserves_active_tasks() {
let mgr = TaskManager::new(2); let mgr = TaskManager::new(2);
@@ -555,4 +831,70 @@ mod tests {
assert_eq!(mgr.progress(id), Some(0.5)); assert_eq!(mgr.progress(id), Some(0.5));
assert_eq!(mgr.snapshots()[0].message.as_deref(), Some("halfway")); 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 errors: Vec<String>,
} }
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) -> bool + Send>;
/// Rebuild templates from the filesystem into the database. /// Rebuild templates from the filesystem into the database.
/// ///
/// Walks the `templates/` directory for `*.liquid` files, parses each via /// Walks the `templates/` directory for `*.liquid` files, parses each via
@@ -26,6 +28,15 @@ pub fn rebuild_templates_from_filesystem(
conn: &Connection, conn: &Connection,
data_dir: &Path, data_dir: &Path,
project_id: &str, 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> { ) -> EngineResult<TemplateRebuildReport> {
let mut report = TemplateRebuildReport::default(); let mut report = TemplateRebuildReport::default();
let templates_dir = data_dir.join("templates"); let templates_dir = data_dir.join("templates");
@@ -34,17 +45,23 @@ pub fn rebuild_templates_from_filesystem(
return Ok(report); return Ok(report);
} }
for entry in WalkDir::new(&templates_dir) let files = WalkDir::new(&templates_dir)
.into_iter() .into_iter()
.filter_map(|e| e.ok()) .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(); let path = entry.path();
if !path.is_file() { let name = path
continue; .file_stem()
} .and_then(|stem| stem.to_str())
let ext = path.extension().and_then(|e| e.to_str()); .unwrap_or("?");
if ext != Some("liquid") { if let Some(ref callback) = on_item
continue; && !callback(index + 1, files.len(), name)
{
return Err(EngineError::Cancelled);
} }
match rebuild_single_template(conn, data_dir, project_id, path) { 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 walkdir::WalkDir;
use crate::db::queries; use crate::db::queries;
use crate::engine::EngineResult;
use crate::engine::generation::has_published_snapshot; use crate::engine::generation::has_published_snapshot;
use crate::engine::{EngineError, EngineResult};
use crate::model::Post; use crate::model::Post;
use crate::render::{build_canonical_post_path, build_site_route_manifest}; use crate::render::{build_canonical_post_path, build_site_route_manifest};
@@ -25,11 +25,27 @@ pub fn validate_site(
data_dir: &Path, data_dir: &Path,
project_id: &str, project_id: &str,
) -> EngineResult<SiteValidationReport> { ) -> 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 metadata = crate::engine::meta::read_project_json(data_dir)?;
let output_dir = generated_output_dir(data_dir); let output_dir = generated_output_dir(data_dir);
let published_posts = load_published_posts(conn, project_id)?; let published_posts = load_published_posts(conn, project_id)?;
let route_manifest = build_site_route_manifest(data_dir, &metadata, &published_posts) let route_manifest = build_site_route_manifest(data_dir, &metadata, &published_posts)
.map_err(|error| crate::engine::EngineError::Parse(error.to_string()))?; .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( crate::engine::generation::refresh_validation_sitemap(
conn, conn,
&output_dir, &output_dir,
@@ -43,6 +59,9 @@ pub fn validate_site(
.into_iter() .into_iter()
.map(|page| page.relative_path) .map(|page| page.relative_path)
.collect::<HashSet<_>>(); .collect::<HashSet<_>>();
if !on_progress(2, PHASES) {
return Err(EngineError::Cancelled);
}
let mut actual = HashSet::new(); let mut actual = HashSet::new();
let mut zero_byte = HashSet::new(); let mut zero_byte = HashSet::new();
@@ -67,6 +86,9 @@ pub fn validate_site(
.into_iter() .into_iter()
.map(|file| (file.relative_path, file.updated_at)) .map(|file| (file.relative_path, file.updated_at))
.collect::<HashMap<_, _>>(); .collect::<HashMap<_, _>>();
if !on_progress(3, PHASES) {
return Err(EngineError::Cancelled);
}
let mut stale_pages = stale_post_paths( let mut stale_pages = stale_post_paths(
data_dir, data_dir,
&output_dir, &output_dir,
@@ -83,6 +105,10 @@ pub fn validate_site(
stale_pages.sort(); stale_pages.sort();
stale_pages.dedup(); stale_pages.dedup();
if !on_progress(PHASES, PHASES) {
return Err(EngineError::Cancelled);
}
Ok(SiteValidationReport { Ok(SiteValidationReport {
missing_pages, missing_pages,
extra_pages, extra_pages,

View File

@@ -5,7 +5,7 @@ use std::path::Path;
use crate::db::DbConnection as Connection; use crate::db::DbConnection as Connection;
use crate::db::queries::{post as post_q, post_translation}; use crate::db::queries::{post as post_q, post_translation};
use crate::engine::EngineResult; use crate::engine::{EngineError, EngineResult};
use crate::model::PostStatus; use crate::model::PostStatus;
/// Normalize a language code for comparison (lowercase, strip region). /// 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). /// 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. /// Validate all translations with optional per-item progress.
pub fn validate_translations_with_progress( pub fn validate_translations_with_progress(
@@ -75,8 +75,10 @@ pub fn validate_translations_with_progress(
let post_count = posts.len(); let post_count = posts.len();
for (i, post) in posts.iter().enumerate() { for (i, post) in posts.iter().enumerate() {
if let Some(ref cb) = on_item { if let Some(ref cb) = on_item
cb(i + 1, post_count, &post.title); && !cb(i + 1, post_count, &post.title)
{
return Err(EngineError::Cancelled);
} }
let translations = post_translation::list_post_translations_by_post(conn, &post.id)?; 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 path = entry.path();
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
if let Some(ref cb) = on_item { if let Some(ref cb) = on_item
cb(i + 1, fs_total, stem); && !cb(i + 1, fs_total, stem)
{
return Err(EngineError::Cancelled);
} }
checked_fs_files += 1; 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_list_page_with_media_map, render_starter_single_post_page,
render_starter_single_post_page_with_media_map, render_starter_single_post_page_with_media_map,
}; };
pub(crate) use site::count_site_render_pages_from_context;
pub use site::{ pub use site::{
PagefindDocument, PreviewRenderResult, SitePage, SiteRenderArtifacts, SiteRenderContext, PagefindDocument, PreviewRenderResult, SitePage, SiteRenderArtifacts, SiteRenderContext,
build_preview_response, build_site_render_artifacts, build_site_render_artifacts_from_context, 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 = let linked_media_by_post_id =
build_linked_media_by_post_id(&posts, &linked_media_by_source_post); 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 post_data_json_by_id = build_post_data_json_by_id(&posts, &linked_media_by_post_id);
let menu_items = build_menu_items( let menu_items = build_menu_items(data_dir, &language, &main_language, &category_settings)?;
data_dir,
&language,
&main_language,
&category_settings,
)?;
let canonical_post_path_by_slug = let canonical_post_path_by_slug =
canonical_post_path_by_slug(&posts, &language, &main_language); canonical_post_path_by_slug(&posts, &language, &main_language);
let taxonomy = build_taxonomy_context(&posts, &tags); let taxonomy = build_taxonomy_context(&posts, &tags);
@@ -590,6 +585,39 @@ pub fn build_site_render_artifacts_from_context(
Ok(artifacts) 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( pub fn build_preview_response(
conn: &Connection, conn: &Connection,
data_dir: &Path, data_dir: &Path,

View File

@@ -1,5 +1,6 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use base64::Engine as _; use base64::Engine as _;
use chrono::{SecondsFormat, TimeZone, Utc}; use chrono::{SecondsFormat, TimeZone, Utc};
@@ -1215,33 +1216,39 @@ impl CoreHost {
"get" => { "get" => {
let id = task_id_arg(args, 0)?; let id = task_id_arg(args, 0)?;
manager manager
.snapshots() .get(id)
.into_iter()
.find(|task| task.id == id)
.map(public_task) .map(public_task)
.transpose() .transpose()
.map(|value| value.unwrap_or(Value::Null)) .map(|value| value.unwrap_or(Value::Null))
} }
"get_all" => public_tasks(manager.snapshots()), "get_all" => public_tasks(manager.all()),
"get_running" => public_tasks( "get_running" => public_tasks(manager.running()),
manager
.snapshots()
.into_iter()
.filter(|task| task.status == TaskStatus::Running)
.collect(),
),
"status_snapshot" => { "status_snapshot" => {
let tasks = manager.snapshots(); 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!({ 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_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)?, "tasks": public_tasks(tasks)?,
})) }))
} }
"cancel" => { "cancel" => Ok(Value::Bool(manager.cancel(task_id_arg(args, 0)?))),
manager.cancel(task_id_arg(args, 0)?);
Ok(Value::Bool(true))
}
"clear_completed" => { "clear_completed" => {
manager.clear_completed(); manager.clear_completed();
Ok(Value::Bool(true)) Ok(Value::Bool(true))
@@ -1355,10 +1362,17 @@ impl CoreHost {
} }
let manager = self.task_manager.clone(); let manager = self.task_manager.clone();
let task_id = self.task_id; 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.data_dir,
&self.private_cache_dir, &self.private_cache_dir,
&preferences, &preferences,
cancel_flag.as_ref(),
move |current, total, _| { move |current, total, _| {
if let (Some(manager), Some(task_id)) = (&manager, task_id) { if let (Some(manager), Some(task_id)) = (&manager, task_id) {
manager.report_progress( manager.report_progress(
@@ -1367,6 +1381,7 @@ impl CoreHost {
Some("uploading site".into()), Some("uploading site".into()),
); );
} }
!progress_cancel_flag.load(Ordering::Acquire)
}, },
)?; )?;
match (&self.task_manager, self.task_id) { 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> { 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 { let (status, error) = match value.status {
TaskStatus::Pending => ("pending", None), TaskStatus::Pending => ("pending", None),
TaskStatus::Running => ("running", None), TaskStatus::Running => ("running", None),
@@ -1668,6 +1685,9 @@ fn public_task(value: TaskSnapshot) -> HostResult<Value> {
}; };
Ok( Ok(
json!({"id":value.id.to_string(),"name":value.label,"status":status, 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)}), "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.", "updated" => "Whether the Git network command completed successfully.",
"progress" => "Completion value reported by the task.", "progress" => "Completion value reported by the task.",
"message" => "Latest user-facing task message.", "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.", "created_at" => "Creation timestamp.",
"updated_at" => "Last-update timestamp.", "updated_at" => "Last-update timestamp.",
"language" | "main_language" => "BCP 47 language code.", "language" | "main_language" => "BCP 47 language code.",

View File

@@ -408,6 +408,8 @@ impl ProtocolError {
} }
fn remote_task(snapshot: TaskSnapshot) -> RemoteTask { fn remote_task(snapshot: TaskSnapshot) -> RemoteTask {
let cancellable = matches!(snapshot.status, TaskStatus::Pending | TaskStatus::Running)
&& !snapshot.cancellation_requested;
let (status, failure) = match snapshot.status { let (status, failure) = match snapshot.status {
TaskStatus::Pending => ("pending", None), TaskStatus::Pending => ("pending", None),
TaskStatus::Running => ("running", None), TaskStatus::Running => ("running", None),
@@ -418,6 +420,10 @@ fn remote_task(snapshot: TaskSnapshot) -> RemoteTask {
RemoteTask { RemoteTask {
id: snapshot.id, id: snapshot.id,
label: snapshot.label, label: snapshot.label,
group_id: snapshot.group_id,
group_name: snapshot.group_name,
cancellation_requested: snapshot.cancellation_requested,
cancellable,
status: status.to_owned(), status: status.to_owned(),
progress: snapshot.progress, progress: snapshot.progress,
message: snapshot.message.or(failure), message: snapshot.message.or(failure),
@@ -610,7 +616,11 @@ mod tests {
let fixture = Fixture::new(); let fixture = Fixture::new();
let mut session = fixture.session(); let mut session = fixture.session();
let _ = session.pending(); 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 fixture
.host .host
.tasks() .tasks()
@@ -618,7 +628,11 @@ mod tests {
let update = session.pending(); let update = session.pending();
assert!(matches!( assert!(matches!(
update.as_slice(), 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!( assert!(
session session

View File

@@ -57,7 +57,12 @@ pub enum ServerMessage {
pub struct RemoteTask { pub struct RemoteTask {
pub id: u64, pub id: u64,
pub label: String, pub label: String,
pub group_id: Option<String>,
pub group_name: Option<String>,
pub status: String, pub status: String,
pub progress: Option<f32>, pub progress: Option<f32>,
pub message: Option<String>, 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::fs;
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::mpsc; use std::sync::mpsc;
use std::time::Duration; use std::time::Duration;
use anyhow::{Result, anyhow, bail}; use anyhow::{Result, anyhow, bail};
use bds_core::db::Database; use bds_core::db::Database;
use bds_core::engine::ai::{AiEndpointConfig, AiEndpointKind}; use bds_core::engine::ai::{AiEndpointConfig, AiEndpointKind};
use bds_core::engine::task::{TaskId, TaskManager};
use bds_core::engine::{self, domain_events}; use bds_core::engine::{self, domain_events};
use bds_core::i18n::{UiLocale, normalize_language}; use bds_core::i18n::{UiLocale, normalize_language};
use bds_core::model::metadata::ProjectMetadata; use bds_core::model::metadata::ProjectMetadata;
@@ -600,7 +602,7 @@ impl TuiApp {
fn queue_task( fn queue_task(
&mut self, &mut self,
label: &str, 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 tasks = self.host.tasks();
let task_id = tasks.submit(label); let task_id = tasks.submit(label);
@@ -608,22 +610,32 @@ impl TuiApp {
self.started_task_ids.insert(task_id); self.started_task_ids.insert(task_id);
self.status = self.tr_with("tui.taskRunning", &[("label", label)]); self.status = self.tr_with("tui.taskRunning", &[("label", label)]);
std::thread::spawn(move || { std::thread::spawn(move || {
if !tasks.wait_until_runnable(task_id) { let Some(worker) = tasks.admit_blocking(task_id) else {
return; 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) => { Ok(result) => {
tasks.complete(task_id); tasks.complete(task_id);
let _ = sender.send(result); let _ = sender.send(result);
} }
Err(error) => { Err(error) => {
let cancelled = tasks.is_cancelled(task_id);
let message = error.to_string(); let message = error.to_string();
tasks.fail(task_id, message.clone()); tasks.fail(task_id, message.clone());
let _ = sender.send(BackgroundResult { if !cancelled {
status: message, let _ = sender.send(BackgroundResult {
panel: None, status: message,
reload: false, panel: None,
}); reload: false,
});
}
} }
} }
}); });
@@ -636,15 +648,19 @@ impl TuiApp {
} }
match task.status { match task.status {
engine::task::TaskStatus::Pending | engine::task::TaskStatus::Running => { engine::task::TaskStatus::Pending | engine::task::TaskStatus::Running => {
self.status = match task.progress { self.status = if task.cancellation_requested {
Some(progress) => self.tr_with( self.tr_with("tui.taskCancelling", &[("label", &task.label)])
"tui.taskProgress", } else {
&[ match task.progress {
("label", &task.label), Some(progress) => self.tr_with(
("percent", &format!("{:.0}", progress * 100.0)), "tui.taskProgress",
], &[
), ("label", &task.label),
None => task.label, ("percent", &format!("{:.0}", progress * 100.0)),
],
),
None => task.label,
}
}; };
} }
engine::task::TaskStatus::Completed if self.completed_task_ids.insert(task.id) => { engine::task::TaskStatus::Completed if self.completed_task_ids.insert(task.id) => {
@@ -1550,7 +1566,10 @@ impl TuiApp {
let message = prompt.value; let message = prompt.value;
let locale = self.locale; let locale = self.locale;
let label = self.tr("tui.taskGitCommit"); 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) let output = engine::git::GitEngine::new(&data_dir)
.commit_all(&message)? .commit_all(&message)?
.output; .output;
@@ -2730,10 +2749,12 @@ impl TuiApp {
let data_dir = self.data_dir()?.to_owned(); let data_dir = self.data_dir()?.to_owned();
let locale = self.locale; let locale = self.locale;
let label = self.tr("tui.taskGitPull"); 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 mut output = Vec::new();
let result = engine::git::GitEngine::new(&data_dir) let result = engine::git::GitEngine::new(&data_dir).pull(
.pull(|| false, |line| output.push(line.text))?; || tasks.is_cancelled(task_id),
|line| output.push(line.text),
)?;
output.push(result.output); output.push(result.output);
Ok(BackgroundResult { Ok(BackgroundResult {
status: bds_core::i18n::translate_with( status: bds_core::i18n::translate_with(
@@ -2759,10 +2780,12 @@ impl TuiApp {
let data_dir = self.data_dir()?.to_owned(); let data_dir = self.data_dir()?.to_owned();
let locale = self.locale; let locale = self.locale;
let label = self.tr("tui.taskGitPush"); 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 mut output = Vec::new();
let result = engine::git::GitEngine::new(&data_dir) let result = engine::git::GitEngine::new(&data_dir).push(
.push(|| false, |line| output.push(line.text))?; || tasks.is_cancelled(task_id),
|line| output.push(line.text),
)?;
output.push(result.output); output.push(result.output);
Ok(BackgroundResult { Ok(BackgroundResult {
status: bds_core::i18n::translate_with( status: bds_core::i18n::translate_with(
@@ -2807,13 +2830,26 @@ impl TuiApp {
"metadata-diff" => { "metadata-diff" => {
let locale = self.locale; let locale = self.locale;
let label = self.tr("tui.commandMetadataDiff"); 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 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 { Ok(BackgroundResult {
status: bds_core::i18n::translate(locale, "tui.metadataDiffComplete"), status: bds_core::i18n::translate(locale, "tui.metadataDiffComplete"),
panel: Some(Panel::Report { panel: Some(Panel::Report {
title: bds_core::i18n::translate(locale, "menu.item.metadataDiff"), 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, action: ReportAction::MetadataDiff,
}), }),
reload: false, reload: false,
@@ -2823,13 +2859,26 @@ impl TuiApp {
"validate-site" => { "validate-site" => {
let locale = self.locale; let locale = self.locale;
let label = self.tr("tui.commandValidateSite"); 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 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 { Ok(BackgroundResult {
status: bds_core::i18n::translate(locale, "tui.siteValidationComplete"), status: bds_core::i18n::translate(locale, "tui.siteValidationComplete"),
panel: Some(Panel::Report { panel: Some(Panel::Report {
title: bds_core::i18n::translate(locale, "menu.item.validateSite"), 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, action: ReportAction::SiteValidation,
}), }),
reload: false, reload: false,
@@ -2839,18 +2888,86 @@ impl TuiApp {
"force-render" => { "force-render" => {
let locale = self.locale; let locale = self.locale;
let label = self.tr("tui.commandForceRender"); 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 db = Database::open(&database_path)?;
let metadata = engine::meta::read_project_json(&data_dir)?; let metadata = engine::meta::read_project_json(&data_dir)?;
let posts = published_sources(db.conn(), &data_dir, &project_id)?; let posts =
let report = engine::generation::generate_starter_site_forced( 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(), db.conn(),
&data_dir.join("html"), &data_dir,
&project_id, &project_id,
&metadata, &metadata,
&posts, &sources,
metadata.main_language.as_deref().unwrap_or("en"),
)?; )?;
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 { Ok(BackgroundResult {
status: bds_core::i18n::translate_with( status: bds_core::i18n::translate_with(
locale, locale,
@@ -2865,12 +2982,21 @@ impl TuiApp {
"rebuild-database" => { "rebuild-database" => {
let locale = self.locale; let locale = self.locale;
let label = self.tr("tui.commandRebuildDatabase"); 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 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(), db.conn(),
&data_dir, &data_dir,
&project_id, &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 let count = report.posts_created
+ report.posts_updated + report.posts_updated
@@ -2894,9 +3020,21 @@ impl TuiApp {
"reindex-search" => { "reindex-search" => {
let locale = self.locale; let locale = self.locale;
let label = self.tr("tui.commandReindexSearch"); 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 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 { Ok(BackgroundResult {
status: bds_core::i18n::translate_with( status: bds_core::i18n::translate_with(
locale, locale,
@@ -3027,6 +3165,13 @@ fn metadata_report_body(
locale: UiLocale, locale: UiLocale,
) -> Result<String> { ) -> Result<String> {
let report = engine::metadata_diff::compute_metadata_diff(conn, data_dir, project_id)?; 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 let mut lines = report
.diffs .diffs
.iter() .iter()
@@ -3058,7 +3203,7 @@ fn metadata_report_body(
"tui.noMetadataDifferences", "tui.noMetadataDifferences",
)); ));
} }
Ok(lines.join("\n")) lines.join("\n")
} }
fn site_validation_body( fn site_validation_body(
@@ -3068,7 +3213,14 @@ fn site_validation_body(
locale: UiLocale, locale: UiLocale,
) -> Result<String> { ) -> Result<String> {
let report = engine::validate_site::validate_site(conn, data_dir, project_id)?; 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, locale,
"tui.siteValidationReport", "tui.siteValidationReport",
&[ &[
@@ -3076,7 +3228,7 @@ fn site_validation_body(
("extra", &report.extra_pages.join("\n")), ("extra", &report.extra_pages.join("\n")),
("stale", &report.stale_pages.join("\n")), ("stale", &report.stale_pages.join("\n")),
], ],
)) )
} }
impl TuiApp { 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 = let on_progress: engine::rebuild::ProgressFn =
Arc::new(move |pct, event| { Arc::new(move |pct, event| {
tm.report_progress(tid, Some(pct), Some(event.localized(locale))); tm.report_progress(tid, Some(pct), Some(event.localized(locale)));
!tm.is_cancelled(tid)
}); });
let report = engine::rebuild::rebuild_from_filesystem_with_progress( let report = engine::rebuild::rebuild_from_filesystem_with_progress(
db.conn(), db.conn(),
@@ -42,14 +43,28 @@ impl BdsApp {
"engine.calendarStarted", "engine.calendarStarted",
move |db_path, project_id, data_dir, tm, tid| { move |db_path, project_id, data_dir, tm, tid| {
let db = Database::open(&db_path).map_err(|e| e.to_string())?; let 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_with_progress(
engine::calendar::regenerate_calendar(db.conn(), &data_dir, &project_id) db.conn(),
.map_err(|e| e.to_string())?; &data_dir,
tm.report_progress( &project_id,
tid, |current, total, name| {
Some(0.90), tm.report_progress(
Some(t(locale, "engine.writingCalendar")), 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()) Ok("done".to_string())
}, },
) )
@@ -59,24 +74,39 @@ impl BdsApp {
TabType::TranslationValidation, TabType::TranslationValidation,
"tabBar.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(); return Task::none();
}; }
self.translation_validation_state.is_running = true; self.translation_validation_state.is_running = true;
self.translation_validation_state.error_message = None; self.translation_validation_state.error_message = None;
let db_path = self.db_path.clone(); let locale = self.ui_locale;
let project_id = project.id.clone(); self.spawn_result_task(
let data_dir = data_dir.clone(); "menu.item.validateTranslations",
Task::perform( move |db_path, project_id, data_dir, tm, tid| {
async move { let db = Database::open(&db_path).map_err(|e| e.to_string())?;
tokio::task::spawn_blocking(move || { let meta = engine::meta::read_project_json(&data_dir)
let db = Database::open(&db_path).map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let meta = engine::meta::read_project_json(&data_dir) let main_lang = meta.main_language.as_deref().unwrap_or("en");
.map_err(|e| e.to_string())?; let blog_langs = meta.blog_languages.clone();
let main_lang = meta.main_language.as_deref().unwrap_or("en"); let progress_tm = Arc::clone(&tm);
let blog_langs = meta.blog_languages.clone(); let on_item: engine::validate_translations::ItemProgressFn =
let on_item: engine::validate_translations::ItemProgressFn = Box::new(move |current, total, name| {
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( engine::validate_translations::validate_translations_with_progress(
db.conn(), db.conn(),
&data_dir, &data_dir,
@@ -85,16 +115,31 @@ impl BdsApp {
main_lang, main_lang,
Some(on_item), Some(on_item),
) )
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())?;
}) tm.report_progress(
.await tid,
.unwrap_or_else(|error| Err(format!("task panicked: {error}"))) 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; self.translation_validation_state.is_running = false;
if cancelled {
return Task::none();
}
match result { match result {
Ok(report) => { Ok(report) => {
self.translation_validation_state.report = Some(report); self.translation_validation_state.report = Some(report);
@@ -149,8 +194,13 @@ impl BdsApp {
self.open_singleton_tab(TabType::MetadataDiff, "tabBar.metadataDiff"); self.open_singleton_tab(TabType::MetadataDiff, "tabBar.metadataDiff");
self.start_metadata_diff() 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; self.metadata_diff_state.is_running = false;
if cancelled {
return Task::none();
}
match result { match result {
Ok(report) => { Ok(report) => {
self.metadata_diff_state.report = Some(report); self.metadata_diff_state.report = Some(report);
@@ -170,36 +220,38 @@ impl BdsApp {
else { else {
return Task::none(); return Task::none();
}; };
let (Some(project), Some(data_dir)) = if self.active_project.is_none() || self.data_dir.is_none() {
(self.active_project.as_ref(), self.data_dir.as_ref())
else {
return Task::none(); return Task::none();
}; }
self.metadata_diff_state.is_repairing = true; self.metadata_diff_state.is_repairing = true;
let db_path = self.db_path.clone(); let locale = self.ui_locale;
let project_id = project.id.clone(); self.spawn_result_task(
let data_dir = data_dir.clone(); "metadataDiff.repair",
Task::perform( move |db_path, project_id, data_dir, tm, tid| {
async move { tm.report_progress(tid, Some(0.0), Some(t(locale, "metadataDiff.repair")));
tokio::task::spawn_blocking(move || { if tm.is_cancelled(tid) {
let db = Database::open(&db_path).map_err(|error| error.to_string())?; return Err("operation cancelled".into());
engine::metadata_diff::repair_metadata_diff_item( }
db.conn(), let db = Database::open(&db_path).map_err(|error| error.to_string())?;
&data_dir, engine::metadata_diff::repair_metadata_diff_item(
&project_id, db.conn(),
direction, &data_dir,
&item, &project_id,
) direction,
.map_err(|error| error.to_string()) &item,
}) )
.await .map_err(|error| error.to_string())
.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; self.metadata_diff_state.is_repairing = false;
if cancelled {
return Task::none();
}
match result { match result {
Ok(()) => { Ok(()) => {
self.notify( 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::RunSiteValidation => self.start_site_validation(),
Message::ApplySiteValidation => self.apply_site_validation(), Message::ApplySiteValidation => self.apply_site_validation(),
Message::EngineTaskDone { Message::EngineTaskDone {
@@ -223,15 +331,14 @@ impl BdsApp {
result, result,
} => { } => {
let search_rebuild_finished = self.search_index_rebuild_task_id == Some(task_id); 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 cancellation_requested = self.task_manager.is_cancelled(task_id);
let refresh_semantic_tags = !cancelled let refresh_semantic_tags = !cancellation_requested
&& result.is_ok() && result.is_ok()
&& matches!( && matches!(
operation, operation,
"embeddings.indexing" | "menu.item.rebuildEmbeddingIndex" "embeddings.indexing" | "menu.item.rebuildEmbeddingIndex"
); );
match &result { match &result {
_ if cancelled => {}
Ok(detail) => { Ok(detail) => {
self.task_manager.complete(task_id); self.task_manager.complete(task_id);
if operation == "engine.rebuildStarted" { if operation == "engine.rebuildStarted" {
@@ -258,12 +365,14 @@ impl BdsApp {
} }
Err(err) => { Err(err) => {
self.task_manager.fail(task_id, err.clone()); self.task_manager.fail(task_id, err.clone());
let message = tw( if !cancellation_requested {
self.ui_locale, let message = tw(
"common.operationFailed", self.ui_locale,
&[("operation", &label), ("error", err)], "common.operationFailed",
); &[("operation", &label), ("error", err)],
self.notify(ToastLevel::Error, &message); );
self.notify(ToastLevel::Error, &message);
}
} }
} }
if search_rebuild_finished { if search_rebuild_finished {
@@ -293,12 +402,41 @@ impl BdsApp {
self.refresh_task_snapshots(); self.refresh_task_snapshots();
Task::batch([sidebar_task, semantic_task]) 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 { Message::SiteGenerationSectionDone {
group_id, group_id,
task_id, task_id,
result, 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(); self.refresh_task_snapshots();
return Task::none(); return Task::none();
} }
@@ -335,21 +473,54 @@ impl BdsApp {
} }
} }
let should_index = let next_phase =
self.site_generation_workflows self.site_generation_workflows
.get(&group_id) .get(&group_id)
.is_some_and(|workflow| { .and_then(|workflow| {
workflow.index_task_id.is_none() (workflow.calendar_task_id.is_none()
&& workflow.index_task_id.is_none()
&& workflow.render_task_ids.iter().all(|task_id| { && workflow.render_task_ids.iter().all(|task_id| {
self.task_manager.status(*task_id) self.task_manager.status(*task_id)
== Some(TaskStatus::Completed) == Some(TaskStatus::Completed)
}) }))
.then_some(workflow.calendar_needed)
}); });
self.refresh_task_snapshots(); self.refresh_task_snapshots();
if should_index { match next_phase {
self.queue_site_search_index(&group_id) Some(true) => self.queue_site_calendar(&group_id),
} else { Some(false) => self.queue_site_search_index(&group_id),
Task::none() 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 { Message::SiteGenerationIndexDone {
@@ -357,7 +528,8 @@ impl BdsApp {
task_id, task_id,
result, 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(); self.refresh_task_snapshots();
return Task::none(); 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; self.site_validation_state.is_running = false;
if cancelled {
return Task::none();
}
self.site_validation_state.has_run = true; self.site_validation_state.has_run = true;
match result { match result {
Ok(report) => { Ok(report) => {

View File

@@ -259,10 +259,11 @@ impl BdsApp {
Task::perform( Task::perform(
async move { async move {
let Some(worker) = task_manager.admit(task_id).await else {
return Err("cancelled".to_string());
};
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
if !task_manager.wait_until_runnable(task_id) { let _worker = worker;
return Err("cancelled".to_string());
}
let engine = GitEngine::new(&data_dir); let engine = GitEngine::new(&data_dir);
let old_head = (operation == GitOperation::Pull) let old_head = (operation == GitOperation::Pull)
.then(|| engine.head()) .then(|| engine.head())

View File

@@ -14,35 +14,40 @@ fn shorten_task_url(url: &str) -> String {
} }
impl BdsApp { 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) { pub(super) fn refresh_task_snapshots(&mut self) {
self.task_snapshots = self self.task_snapshots = self
.task_manager .task_manager
.snapshots() .snapshots()
.into_iter() .into_iter()
.map(|snapshot| { .map(|snapshot| TaskSnapshot {
let status_str = match &snapshot.status { id: snapshot.id,
TaskStatus::Pending => t(self.ui_locale, "tasks.statusPending"), source: crate::state::navigation::TaskSource::Local,
TaskStatus::Running => t(self.ui_locale, "tasks.statusRunning"), label: snapshot.label,
TaskStatus::Completed => t(self.ui_locale, "tasks.statusCompleted"), group_id: snapshot.group_id,
TaskStatus::Failed(error) => { group_name: snapshot.group_name,
tw(self.ui_locale, "tasks.statusFailed", &[("error", error)]) status: snapshot.status.clone(),
} progress: snapshot.progress,
TaskStatus::Cancelled => t(self.ui_locale, "tasks.statusCancelled"), message: snapshot.message,
}; cancellation_requested: snapshot.cancellation_requested,
TaskSnapshot { is_cancellable: matches!(
id: snapshot.id, snapshot.status,
label: snapshot.label, TaskStatus::Pending | TaskStatus::Running
group_id: snapshot.group_id, ) && !snapshot.cancellation_requested,
group_name: snapshot.group_name,
status: status_str,
progress: snapshot.progress,
message: snapshot.message,
is_cancellable: matches!(
snapshot.status,
TaskStatus::Pending | TaskStatus::Running
),
}
}) })
.chain(self.remote_task_snapshots.iter().cloned())
.collect(); .collect();
} }
@@ -69,7 +74,7 @@ impl BdsApp {
} else { } else {
SiteGenerationKind::Full 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 { if kind == SiteGenerationKind::Validation {
self.site_validation_state.is_applying = false; self.site_validation_state.is_applying = false;
} }
@@ -79,6 +84,16 @@ impl BdsApp {
); );
return Task::none(); 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) { let metadata = match engine::meta::read_project_json(data_dir) {
Ok(metadata) => metadata, Ok(metadata) => metadata,
Err(error) => { Err(error) => {
@@ -107,37 +122,122 @@ impl BdsApp {
return Task::none(); return Task::none();
} }
let sections = validation.as_ref().map_or_else( let task_validation = validation.clone();
|| engine::generation::GenerationSection::ALL.to_vec(), let locale = self.ui_locale;
|validation| engine::generation::sections_from_validation_report(validation, &metadata), self.spawn_result_task(
); "engine.prepareGeneration",
if sections.is_empty() { move |db_path, project_id, data_dir, tm, tid| {
return Task::none(); 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 project_id = project.id.clone();
let db_path = self.db_path.clone(); let db_path = self.db_path.clone();
let data_dir = data_dir.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_id = format!("site-generation:{}", Uuid::new_v4());
let group_name = t( let group_name = t(
self.ui_locale, self.ui_locale,
@@ -150,7 +250,10 @@ impl BdsApp {
let mut render_task_ids = Vec::new(); let mut render_task_ids = Vec::new();
let mut tasks = 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)); let label = t(self.ui_locale, generation_section_label_key(section));
self.add_output(&label); self.add_output(&label);
let page_work = page_estimates[&section]; let page_work = page_estimates[&section];
@@ -173,14 +276,20 @@ impl BdsApp {
let task_data_dir = data_dir.clone(); let task_data_dir = data_dir.clone();
let task_group_id = group_id.clone(); let task_group_id = group_id.clone();
let task_validation = validation.clone(); let task_validation = validation.clone();
let task_prepared = Arc::clone(&prepared);
let locale = self.ui_locale; let locale = self.ui_locale;
tasks.push(Task::perform( tasks.push(Task::perform(
async move { async move {
let Some(worker) = task_manager.admit(task_id).await else {
return Err("cancelled".to_string());
};
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
let _worker = worker;
run_site_generation_section( run_site_generation_section(
task_db_path, task_db_path,
task_project_id, task_project_id,
task_data_dir, task_data_dir,
task_prepared,
task_manager, task_manager,
task_id, task_id,
section, section,
@@ -210,6 +319,8 @@ impl BdsApp {
data_dir, data_dir,
group_name, group_name,
render_task_ids, render_task_ids,
calendar_needed,
calendar_task_id: None,
index_task_id: None, index_task_id: None,
report: engine::generation::GenerationReport::default(), report: engine::generation::GenerationReport::default(),
}, },
@@ -218,6 +329,75 @@ impl BdsApp {
Task::batch(tasks) 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> { 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 { let Some(workflow) = self.site_generation_workflows.get(group_id).cloned() else {
return Task::none(); return Task::none();
@@ -237,10 +417,11 @@ impl BdsApp {
let task_group_id = group_id.to_string(); let task_group_id = group_id.to_string();
Task::perform( Task::perform(
async move { async move {
let Some(worker) = task_manager.admit(task_id).await else {
return Err("cancelled".to_string());
};
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
if !task_manager.wait_until_runnable(task_id) { let _worker = worker;
return Err("cancelled".to_string());
}
let db = let db =
Database::open(&workflow.db_path).map_err(|error| error.to_string())?; Database::open(&workflow.db_path).map_err(|error| error.to_string())?;
let metadata = engine::meta::read_project_json(&workflow.data_dir) let metadata = engine::meta::read_project_json(&workflow.data_dir)
@@ -312,7 +493,7 @@ impl BdsApp {
if workflow.kind == SiteGenerationKind::Validation { if workflow.kind == SiteGenerationKind::Validation {
self.site_validation_state.is_applying = false; 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(); self.refresh_task_snapshots();
true true
} }
@@ -337,7 +518,10 @@ impl BdsApp {
let task_id = self.task_manager.submit(&label); let task_id = self.task_manager.submit(&label);
self.search_index_rebuild_running = true; self.search_index_rebuild_running = true;
self.search_index_rebuild_task_id = Some(task_id); 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.refresh_task_snapshots();
self.sync_menu_state(); self.sync_menu_state();
@@ -346,10 +530,11 @@ impl BdsApp {
let task_manager = Arc::clone(&self.task_manager); let task_manager = Arc::clone(&self.task_manager);
Task::perform( Task::perform(
async move { async move {
let Some(worker) = task_manager.admit(task_id).await else {
return Err("cancelled".to_string());
};
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
if !task_manager.wait_until_runnable(task_id) { let _worker = worker;
return Err("cancelled".to_string());
}
let db = Database::open(&db_path).map_err(|error| error.to_string())?; let db = Database::open(&db_path).map_err(|error| error.to_string())?;
let progress_manager = Arc::clone(&task_manager); let progress_manager = Arc::clone(&task_manager);
let on_item: engine::search::ItemProgressFn = let on_item: engine::search::ItemProgressFn =
@@ -373,6 +558,7 @@ impl BdsApp {
Some(progress), Some(progress),
Some(message), Some(message),
); );
!progress_manager.is_cancelled(task_id)
}); });
let report = engine::search::rebuild_search_index(db.conn(), Some(on_item)) let report = engine::search::rebuild_search_index(db.conn(), Some(on_item))
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
@@ -403,6 +589,49 @@ impl BdsApp {
self.spawn_engine_task_in_group(label_key, None, work) 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>( pub(super) fn spawn_grouped_engine_task<F>(
&mut self, &mut self,
label_key: &'static str, label_key: &'static str,
@@ -453,10 +682,11 @@ impl BdsApp {
Task::perform( Task::perform(
async move { async move {
let Some(worker) = tm.admit(task_id).await else {
return Err("cancelled".to_string());
};
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
if !tm.wait_until_runnable(task_id) { let _worker = worker;
return Err("cancelled".to_string());
}
work(db_path, project_id, data_dir, tm, task_id) work(db_path, project_id, data_dir, tm, task_id)
}) })
.await .await
@@ -490,6 +720,7 @@ fn run_site_generation_section(
db_path: PathBuf, db_path: PathBuf,
project_id: String, project_id: String,
data_dir: PathBuf, data_dir: PathBuf,
prepared: Arc<engine::generation::PreparedSiteGeneration>,
task_manager: Arc<TaskManager>, task_manager: Arc<TaskManager>,
task_id: TaskId, task_id: TaskId,
section: engine::generation::GenerationSection, section: engine::generation::GenerationSection,
@@ -498,9 +729,6 @@ fn run_site_generation_section(
locale: UiLocale, locale: UiLocale,
expected_pages: usize, expected_pages: usize,
) -> Result<engine::generation::GenerationReport, String> { ) -> Result<engine::generation::GenerationReport, String> {
if !task_manager.wait_until_runnable(task_id) {
return Err("cancelled".to_string());
}
task_manager.report_progress( task_manager.report_progress(
task_id, task_id,
Some(if expected_pages == 0 { 1.0 } else { 0.0 }), 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 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"); let output_dir = data_dir.join("html");
std::fs::create_dir_all(&output_dir).map_err(|error| error.to_string())?; std::fs::create_dir_all(&output_dir).map_err(|error| error.to_string())?;
let render_manager = Arc::clone(&task_manager); let render_manager = Arc::clone(&task_manager);
@@ -565,7 +773,7 @@ fn run_site_generation_section(
db.conn(), db.conn(),
&output_dir, &output_dir,
&project_id, &project_id,
&prepared, prepared.as_ref(),
&validation, &validation,
section, section,
on_page, on_page,
@@ -576,7 +784,7 @@ fn run_site_generation_section(
db.conn(), db.conn(),
&output_dir, &output_dir,
&project_id, &project_id,
&prepared, prepared.as_ref(),
section, section,
force, force,
&on_rendered, &on_rendered,

View File

@@ -107,15 +107,23 @@ impl PanelTab {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct TaskSnapshot { pub struct TaskSnapshot {
pub id: u64, pub id: u64,
pub source: TaskSource,
pub label: String, pub label: String,
pub group_id: Option<String>, pub group_id: Option<String>,
pub group_name: Option<String>, pub group_name: Option<String>,
pub status: String, pub status: bds_core::engine::task::TaskStatus,
pub progress: Option<f32>, pub progress: Option<f32>,
pub message: Option<String>, pub message: Option<String>,
pub cancellation_requested: bool,
pub is_cancellable: 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. /// A single line of output shown in the panel.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct OutputEntry { pub struct OutputEntry {

View File

@@ -386,7 +386,10 @@ fn network_output(run: &GitNetworkRunState, locale: UiLocale) -> Element<'static
) )
.padding(6), .padding(6),
button(text(t(locale, "common.cancel")).size(11)) 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]) .padding([4, 7])
.style(inputs::secondary_button), .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() { if !report.orphans.is_empty() {
let rows = report let rows = report.orphans.iter().enumerate().fold(
.orphans column!().spacing(8),
.iter() |column, (index, orphan)| {
.fold(column!().spacing(4), |column, orphan| { let mut item = row![
column.push(
text(tw( text(tw(
locale, locale,
"metadataDiff.orphan", "metadataDiff.orphan",
&[("reason", &orphan.reason), ("path", &orphan.file_path)], &[("reason", &orphan.reason), ("path", &orphan.file_path)],
)) ))
.size(12), .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( content = content.push(inputs::card(
column![text(t(locale, "metadataDiff.orphans")).size(16), rows].spacing(8), column![text(t(locale, "metadataDiff.orphans")).size(16), rows].spacing(8),
)); ));

View File

@@ -64,7 +64,10 @@ pub enum ModalState {
on_confirm: ConfirmAction, on_confirm: ConfirmAction,
}, },
SearchIndexRepair, SearchIndexRepair,
SearchIndexRebuilding, SearchIndexRebuilding {
task_id: bds_core::engine::task::TaskId,
cancellation_requested: bool,
},
FindReplace { FindReplace {
query: String, query: String,
replacement: String, replacement: String,
@@ -552,8 +555,11 @@ pub fn view(
.into() .into()
} }
ModalState::SearchIndexRebuilding => container( ModalState::SearchIndexRebuilding {
column![ task_id,
cancellation_requested,
} => {
let mut content = column![
text(t(locale, "searchIndexRepair.rebuildingTitle")) text(t(locale, "searchIndexRepair.rebuildingTitle"))
.size(16) .size(16)
.shaping(Shaping::Advanced) .shaping(Shaping::Advanced)
@@ -563,12 +569,31 @@ pub fn view(
.size(13) .size(13)
.shaping(Shaping::Advanced) .shaping(Shaping::Advanced)
.color(Color::from_rgb(0.80, 0.80, 0.85)), .color(Color::from_rgb(0.80, 0.80, 0.85)),
] ];
.padding(20), content = if cancellation_requested {
) content.push(Space::with_height(16.0)).push(
.width(Length::Fixed(420.0)) text(t(locale, "tasks.status.cancelling"))
.style(modal_box_style) .size(13)
.into(), .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 { ModalState::PostInsertLink {
post_id: _post_id, 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 iced::{Alignment, Background, Border, Color, Element, Length, Theme};
use bds_core::engine::git::GitCommit; use bds_core::engine::git::GitCommit;
use bds_core::engine::task::TaskStatus;
use bds_core::i18n::UiLocale; use bds_core::i18n::UiLocale;
use crate::app::Message; use crate::app::Message;
use crate::components::inputs; use crate::components::inputs;
use crate::i18n::t; use crate::i18n::{t, tw};
use crate::state::navigation::{OutputEntry, PanelTab, TaskSnapshot}; use crate::state::navigation::{OutputEntry, PanelTab, TaskSnapshot};
use crate::state::tabs::{Tab, TabType}; use crate::state::tabs::{Tab, TabType};
use crate::views::post_editor::ResolvedPostLink; use crate::views::post_editor::ResolvedPostLink;
use std::collections::HashSet; use std::collections::HashSet;
fn task_row(snapshot: &TaskSnapshot, locale: UiLocale) -> Element<'static, Message> { 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 let progress = snapshot
.progress .progress
.map(|value| format!(" ({:.0}%)", value * 100.0)) .map(|value| format!(" ({:.0}%)", value * 100.0))
@@ -25,7 +36,7 @@ fn task_row(snapshot: &TaskSnapshot, locale: UiLocale) -> Element<'static, Messa
.unwrap_or_default(); .unwrap_or_default();
let label = text(format!( let label = text(format!(
"{}{}{}{}", "{}{}{}{}",
snapshot.label, snapshot.status, progress, phase snapshot.label, status, progress, phase
)) ))
.size(11) .size(11)
.shaping(Shaping::Advanced) .shaping(Shaping::Advanced)
@@ -34,7 +45,7 @@ fn task_row(snapshot: &TaskSnapshot, locale: UiLocale) -> Element<'static, Messa
if snapshot.is_cancellable { if snapshot.is_cancellable {
content = content.push(Space::with_width(Length::Fill)).push( content = content.push(Space::with_width(Length::Fill)).push(
button(text(t(locale, "tasks.cancelTask")).size(11)) 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]) .padding([3, 8])
.style(inputs::secondary_button), .style(inputs::secondary_button),
); );
@@ -46,12 +57,9 @@ fn group_progress(members: &[&TaskSnapshot]) -> Option<f32> {
(!members.is_empty()).then(|| { (!members.is_empty()).then(|| {
members members
.iter() .iter()
.map(|member| { .map(|member| match member.status {
if member.is_cancellable { TaskStatus::Completed | TaskStatus::Failed(_) | TaskStatus::Cancelled => 1.0,
member.progress.unwrap_or(0.0) TaskStatus::Pending | TaskStatus::Running => member.progress.unwrap_or(0.0),
} else {
1.0
}
}) })
.sum::<f32>() .sum::<f32>()
/ members.len() as f32 / members.len() as f32
@@ -219,7 +227,7 @@ pub fn view(
.padding(8) .padding(8)
.into() .into()
} else { } 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 rendered_groups = HashSet::new();
let mut items: Vec<Element<'static, Message>> = Vec::new(); let mut items: Vec<Element<'static, Message>> = Vec::new();
for snapshot in &visible { for snapshot in &visible {
@@ -441,16 +449,19 @@ mod progress_tests {
fn group_progress_includes_pending_tasks_as_zero_like_bds2() { fn group_progress_includes_pending_tasks_as_zero_like_bds2() {
let complete = TaskSnapshot { let complete = TaskSnapshot {
id: 1, id: 1,
source: crate::state::navigation::TaskSource::Local,
label: String::new(), label: String::new(),
group_id: None, group_id: None,
group_name: None, group_name: None,
status: String::new(), status: TaskStatus::Completed,
progress: Some(1.0), progress: Some(1.0),
message: None, message: None,
cancellation_requested: false,
is_cancellable: false, is_cancellable: false,
}; };
let pending = TaskSnapshot { let pending = TaskSnapshot {
id: 2, id: 2,
status: TaskStatus::Pending,
progress: None, progress: None,
is_cancellable: true, is_cancellable: true,
..complete.clone() ..complete.clone()

View File

@@ -2,10 +2,11 @@ use iced::widget::text::Shaping;
use iced::widget::{Space, button, container, row, text}; use iced::widget::{Space, button, container, row, text};
use iced::{Alignment, Background, Border, Color, Element, Length, Theme}; use iced::{Alignment, Background, Border, Color, Element, Length, Theme};
use bds_core::engine::task::TaskStatus;
use bds_core::i18n::UiLocale; use bds_core::i18n::UiLocale;
use crate::app::Message; use crate::app::Message;
use crate::i18n::tw; use crate::i18n::{t, tw};
use crate::state::navigation::TaskSnapshot; use crate::state::navigation::TaskSnapshot;
use crate::views::project_selector; use crate::views::project_selector;
@@ -131,23 +132,38 @@ pub fn view(
let project_name = active_project_name.unwrap_or("\u{2014}"); let project_name = active_project_name.unwrap_or("\u{2014}");
let project_trigger = project_selector::trigger_button(project_name); let project_trigger = project_selector::trigger_button(project_name);
let running: Vec<&TaskSnapshot> = task_snapshots let active: Vec<&TaskSnapshot> = task_snapshots
.iter() .iter()
.filter(|t| t.status == "running") .filter(|task| matches!(task.status, TaskStatus::Pending | TaskStatus::Running))
.collect(); .collect();
let task_indicator: Element<'static, Message> = if !running.is_empty() { let first = active
let first = &running[0]; .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 let progress_str = first
.progress .progress
.map(|p| format!(" {:.0}%", p * 100.0)) .map(|p| format!(" {:.0}%", p * 100.0))
.unwrap_or_default(); .unwrap_or_default();
let phase_str = first.message.as_deref().unwrap_or(""); let phase_str = first.message.as_deref().unwrap_or("");
let extra = if running.len() > 1 { let extra = if active.len() > 1 {
format!(" (+{})", running.len() - 1) format!(" (+{})", active.len() - 1)
} else { } else {
String::new() 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) format!("{}{progress_str}{extra}", first.label)
} else { } else {
format!("{phase_str}{progress_str}{extra}") format!("{phase_str}{progress_str}{extra}")

View File

@@ -3687,6 +3687,10 @@ local result = bds.publish.upload_site({ host = "example.com", username = "autho
```lua ```lua
{ {
cancellable = true,
cancellation_requested = true,
group_id = "example-id",
group_name = "example",
id = "example-id", id = "example-id",
message = "Working", message = "Working",
name = "Example", name = "Example",
@@ -4705,6 +4709,10 @@ local result = bds.tasks.get("example-id")
```lua ```lua
{ {
cancellable = true,
cancellation_requested = true,
group_id = "example-id",
group_name = "example",
id = "example-id", id = "example-id",
message = "Working", message = "Working",
name = "Example", name = "Example",
@@ -4746,6 +4754,10 @@ local result = bds.tasks.status_snapshot()
running_count = 1, running_count = 1,
tasks = { tasks = {
{ {
cancellable = true,
cancellation_requested = true,
group_id = "example-id",
group_name = "example",
id = "example-id", id = "example-id",
message = "Working", message = "Working",
name = "Example", name = "Example",
@@ -4817,6 +4829,10 @@ local result = bds.tasks.get_all()
```lua ```lua
{ {
{ {
cancellable = true,
cancellation_requested = true,
group_id = "example-id",
group_name = "example",
id = "example-id", id = "example-id",
message = "Working", message = "Working",
name = "Example", name = "Example",
@@ -4855,6 +4871,10 @@ local result = bds.tasks.get_running()
```lua ```lua
{ {
{ {
cancellable = true,
cancellation_requested = true,
group_id = "example-id",
group_name = "example",
id = "example-id", id = "example-id",
message = "Working", message = "Working",
name = "Example", name = "Example",

View File

@@ -269,6 +269,10 @@ Public task snapshot returned by the task manager.
```lua ```lua
{ {
cancellable = true,
cancellation_requested = true,
group_id = "example-id",
group_name = "example",
id = "example-id", id = "example-id",
message = "Working", message = "Working",
name = "Example", name = "Example",
@@ -279,6 +283,10 @@ Public task snapshot returned by the task manager.
| Field | Type | Required | Meaning | | 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. | | `id` | `string` | Yes | Stable record identifier. |
| `message` | `string \| nil` | No | Latest user-facing task message. | | `message` | `string \| nil` | No | Latest user-facing task message. |
| `name` | `string` | Yes | Human-readable name. | | `name` | `string` | Yes | Human-readable name. |
@@ -298,6 +306,10 @@ Aggregate task status snapshot.
running_count = 1, running_count = 1,
tasks = { tasks = {
{ {
cancellable = true,
cancellation_requested = true,
group_id = "example-id",
group_name = "example",
id = "example-id", id = "example-id",
message = "Working", message = "Working",
name = "Example", name = "Example",

View File

@@ -1833,6 +1833,10 @@
{ {
"description": "Public task snapshot returned by the task manager.", "description": "Public task snapshot returned by the task manager.",
"fields": { "fields": {
"cancellable": "boolean",
"cancellation_requested": "boolean",
"group_id": "string | nil",
"group_name": "string | nil",
"id": "string", "id": "string",
"message": "string | nil", "message": "string | nil",
"name": "string", "name": "string",

View File

@@ -33,6 +33,7 @@ editor-matchingTags = Passende Tags
editor-createTag = Tag erstellen: { $name } editor-createTag = Tag erstellen: { $name }
duplicates-title = Doppelte Beiträge finden duplicates-title = Doppelte Beiträge finden
duplicates-searching = Duplikate werden gesucht… duplicates-searching = Duplikate werden gesucht…
duplicates-complete = Duplikatsuche abgeschlossen
duplicates-count = { $count } mögliche Paare duplicates-count = { $count } mögliche Paare
duplicates-disabled = Semantische Ähnlichkeit ist für dieses Projekt nicht aktiviert. duplicates-disabled = Semantische Ähnlichkeit ist für dieses Projekt nicht aktiviert.
duplicates-empty = Keine möglichen doppelten Beiträge gefunden. duplicates-empty = Keine möglichen doppelten Beiträge gefunden.
@@ -187,6 +188,7 @@ tasks-noActive = Keine aktiven Aufgaben
tasks-cancelTask = Aufgabe abbrechen tasks-cancelTask = Aufgabe abbrechen
tasks-statusPending = Ausstehend tasks-statusPending = Ausstehend
tasks-statusRunning = Laufend tasks-statusRunning = Laufend
tasks-statusCancelling = Wird abgebrochen…
tasks-statusCompleted = Abgeschlossen tasks-statusCompleted = Abgeschlossen
tasks-statusFailed = Fehlgeschlagen: { $error } tasks-statusFailed = Fehlgeschlagen: { $error }
tasks-statusCancelled = Abgebrochen tasks-statusCancelled = Abgebrochen
@@ -238,6 +240,7 @@ siteValidation-apply = Validierung anwenden
siteValidation-applying = Wird angewendet... siteValidation-applying = Wird angewendet...
siteValidation-idle = Starte die Website-Validierung, um generierte Dateien mit dem erwarteten Output zu vergleichen. siteValidation-idle = Starte die Website-Validierung, um generierte Dateien mit dem erwarteten Output zu vergleichen.
siteValidation-running = Validierung läuft... siteValidation-running = Validierung läuft...
siteValidation-complete = Validierung abgeschlossen
siteValidation-clean = Es wurden keine fehlenden, zusätzlichen oder veralteten generierten Dateien gefunden. siteValidation-clean = Es wurden keine fehlenden, zusätzlichen oder veralteten generierten Dateien gefunden.
siteValidation-error = Validierung fehlgeschlagen siteValidation-error = Validierung fehlgeschlagen
siteValidation-missing = Fehlende Dateien siteValidation-missing = Fehlende Dateien
@@ -569,6 +572,8 @@ settings-rebuildMedia = Medien neu aufbauen
settings-rebuildScripts = Skripte neu aufbauen settings-rebuildScripts = Skripte neu aufbauen
settings-rebuildTemplates = Vorlagen neu aufbauen settings-rebuildTemplates = Vorlagen neu aufbauen
settings-rebuildLinks = Links neu aufbauen settings-rebuildLinks = Links neu aufbauen
settings-rebuildPostLinks = Beitragslinks werden neu aufgebaut…
settings-rebuildMediaLinks = Medienlinks werden neu aufgebaut…
settings-regenerateThumbnails = Vorschaubilder regenerieren settings-regenerateThumbnails = Vorschaubilder regenerieren
settings-openDataFolder = Datenordner öffnen settings-openDataFolder = Datenordner öffnen
settings-installCli = CLI installieren settings-installCli = CLI installieren
@@ -641,6 +646,10 @@ metadataDiff-dbToFile = Datenbank verwenden
metadataDiff-orphans = Verwaiste Dateien metadataDiff-orphans = Verwaiste Dateien
metadataDiff-errors = Fehler metadataDiff-errors = Fehler
metadataDiff-repaired = Metadatenabweichung repariert 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-field = { $field }: DB = { $database } · Datei = { $file }
metadataDiff-entity = { $entity } · { $path } metadataDiff-entity = { $entity } · { $path }
metadataDiff-orphan = { $reason } · { $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-rebuildingTemplates = Vorlagen werden neu aufgebaut…
engine-progress-rebuildingScripts = Skripte werden neu aufgebaut… engine-progress-rebuildingScripts = Skripte werden neu aufgebaut…
engine-progress-importingTags = Schlagwörter werden importiert… engine-progress-importingTags = Schlagwörter werden importiert…
engine-progress-rebuildingThumbnails = Fehlende Vorschaubilder werden neu aufgebaut…
engine-progress-refreshingSemanticIndex = Semantischer Index wird aktualisiert… engine-progress-refreshingSemanticIndex = Semantischer Index wird aktualisiert…
engine-progress-rebuildComplete = Neuaufbau abgeschlossen engine-progress-rebuildComplete = Neuaufbau abgeschlossen
translationValidation-run = Prüfen translationValidation-run = Prüfen
@@ -683,6 +693,9 @@ engine-renderingPages = Seiten werden gerendert ({ $current }/{ $total })
engine-generatedPage = Erstellt { $url } ({ $current }/{ $total }) engine-generatedPage = Erstellt { $url } ({ $current }/{ $total })
engine-rewrotePage = Neu geschrieben { $url } ({ $current }/{ $total }) engine-rewrotePage = Neu geschrieben { $url } ({ $current }/{ $total })
engine-renderSiteGroup = Website rendern 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-forceRenderSiteGroup = Website vollständig neu generieren
engine-applyValidationGroup = Website-Validierung anwenden engine-applyValidationGroup = Website-Validierung anwenden
engine-renderSiteCore = Website-Kern rendern engine-renderSiteCore = Website-Kern rendern
@@ -692,7 +705,7 @@ engine-renderTagArchives = Schlagwortarchive rendern
engine-renderDateArchives = Datumsarchive rendern engine-renderDateArchives = Datumsarchive rendern
engine-buildSearchIndex = Suchindex erstellen engine-buildSearchIndex = Suchindex erstellen
engine-builtSearchFile = { $path } erstellt ({ $current }/{ $total }) 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-generationSummary = Website-Erstellung abgeschlossen: geschrieben={ $written }, übersprungen={ $skipped }, gelöscht={ $deleted }, Ausgabe={ $output }
engine-publicUrlRequired = Vor der Website-Erstellung ist eine öffentliche URL erforderlich engine-publicUrlRequired = Vor der Website-Erstellung ist eine öffentliche URL erforderlich
settings-imageImportConcurrency = Gleichzeitige Bildimporte (18) 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-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-openItemDeleted = Der geöffnete Eintrag wurde von einem anderen Client gelöscht.
tui-taskRunning = { $label } — läuft tui-taskRunning = { $label } — läuft
tui-taskCancelling = { $label } — wird abgebrochen…
tui-taskProgress = { $label } — { $percent } % tui-taskProgress = { $label } — { $percent } %
tui-taskComplete = { $label } — abgeschlossen tui-taskComplete = { $label } — abgeschlossen
tui-taskFailed = { $label } — { $error } tui-taskFailed = { $label } — { $error }

View File

@@ -33,6 +33,7 @@ editor-matchingTags = Matching tags
editor-createTag = Create tag: { $name } editor-createTag = Create tag: { $name }
duplicates-title = Find Duplicate Posts duplicates-title = Find Duplicate Posts
duplicates-searching = Searching for duplicates… duplicates-searching = Searching for duplicates…
duplicates-complete = Duplicate search complete
duplicates-count = { $count } potential pairs duplicates-count = { $count } potential pairs
duplicates-disabled = Semantic similarity is not enabled for this project. duplicates-disabled = Semantic similarity is not enabled for this project.
duplicates-empty = No potential duplicate posts found. duplicates-empty = No potential duplicate posts found.
@@ -187,6 +188,7 @@ tasks-noActive = No active tasks
tasks-cancelTask = Cancel task tasks-cancelTask = Cancel task
tasks-statusPending = Pending tasks-statusPending = Pending
tasks-statusRunning = Running tasks-statusRunning = Running
tasks-statusCancelling = Cancelling…
tasks-statusCompleted = Completed tasks-statusCompleted = Completed
tasks-statusFailed = Failed: { $error } tasks-statusFailed = Failed: { $error }
tasks-statusCancelled = Cancelled tasks-statusCancelled = Cancelled
@@ -238,6 +240,7 @@ siteValidation-apply = Apply Validation
siteValidation-applying = Applying... siteValidation-applying = Applying...
siteValidation-idle = Run site validation to compare generated files against expected output. siteValidation-idle = Run site validation to compare generated files against expected output.
siteValidation-running = Validation in progress... siteValidation-running = Validation in progress...
siteValidation-complete = Validation complete
siteValidation-clean = No missing, extra, or stale generated files were found. siteValidation-clean = No missing, extra, or stale generated files were found.
siteValidation-error = Validation failed siteValidation-error = Validation failed
siteValidation-missing = Missing Files siteValidation-missing = Missing Files
@@ -569,6 +572,8 @@ settings-rebuildMedia = Rebuild Media
settings-rebuildScripts = Rebuild Scripts settings-rebuildScripts = Rebuild Scripts
settings-rebuildTemplates = Rebuild Templates settings-rebuildTemplates = Rebuild Templates
settings-rebuildLinks = Rebuild Links settings-rebuildLinks = Rebuild Links
settings-rebuildPostLinks = Rebuilding post links…
settings-rebuildMediaLinks = Rebuilding media links…
settings-regenerateThumbnails = Regenerate Thumbnails settings-regenerateThumbnails = Regenerate Thumbnails
settings-openDataFolder = Open Data Folder settings-openDataFolder = Open Data Folder
settings-installCli = Install CLI settings-installCli = Install CLI
@@ -641,6 +646,10 @@ metadataDiff-dbToFile = Use Database
metadataDiff-orphans = Orphan files metadataDiff-orphans = Orphan files
metadataDiff-errors = Errors metadataDiff-errors = Errors
metadataDiff-repaired = Metadata difference repaired 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-field = { $field }: DB = { $database } · File = { $file }
metadataDiff-entity = { $entity } · { $path } metadataDiff-entity = { $entity } · { $path }
metadataDiff-orphan = { $reason } · { $path } metadataDiff-orphan = { $reason } · { $path }
@@ -659,6 +668,7 @@ engine-progress-mediaItem = Media: { $current }/{ $total } — { $name }
engine-progress-rebuildingTemplates = Rebuilding templates… engine-progress-rebuildingTemplates = Rebuilding templates…
engine-progress-rebuildingScripts = Rebuilding scripts… engine-progress-rebuildingScripts = Rebuilding scripts…
engine-progress-importingTags = Importing tags… engine-progress-importingTags = Importing tags…
engine-progress-rebuildingThumbnails = Rebuilding missing thumbnails…
engine-progress-refreshingSemanticIndex = Refreshing semantic index… engine-progress-refreshingSemanticIndex = Refreshing semantic index…
engine-progress-rebuildComplete = Rebuild complete engine-progress-rebuildComplete = Rebuild complete
translationValidation-run = Validate translationValidation-run = Validate
@@ -683,6 +693,9 @@ engine-renderingPages = Rendering pages ({ $current }/{ $total })
engine-generatedPage = Generated { $url } ({ $current }/{ $total }) engine-generatedPage = Generated { $url } ({ $current }/{ $total })
engine-rewrotePage = Rewrote { $url } ({ $current }/{ $total }) engine-rewrotePage = Rewrote { $url } ({ $current }/{ $total })
engine-renderSiteGroup = Render Site 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-forceRenderSiteGroup = Force Render Site
engine-applyValidationGroup = Apply Site Validation engine-applyValidationGroup = Apply Site Validation
engine-renderSiteCore = Render Site Core engine-renderSiteCore = Render Site Core
@@ -692,7 +705,7 @@ engine-renderTagArchives = Render Tag Archives
engine-renderDateArchives = Render Date Archives engine-renderDateArchives = Render Date Archives
engine-buildSearchIndex = Build Search Index engine-buildSearchIndex = Build Search Index
engine-builtSearchFile = Built { $path } ({ $current }/{ $total }) 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-generationSummary = Site generation complete: written={ $written }, skipped={ $skipped }, deleted={ $deleted }, output={ $output }
engine-publicUrlRequired = Public URL is required before generating the site engine-publicUrlRequired = Public URL is required before generating the site
settings-imageImportConcurrency = Image Import Concurrency (18) 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-categoryEditingGuiOnly = Category add, edit, and removal remain in the desktop application
tui-openItemDeleted = The open item was deleted by another client. tui-openItemDeleted = The open item was deleted by another client.
tui-taskRunning = { $label } — running tui-taskRunning = { $label } — running
tui-taskCancelling = { $label } — cancelling…
tui-taskProgress = { $label } — { $percent }% tui-taskProgress = { $label } — { $percent }%
tui-taskComplete = { $label } — complete tui-taskComplete = { $label } — complete
tui-taskFailed = { $label } — { $error } tui-taskFailed = { $label } — { $error }

View File

@@ -33,6 +33,7 @@ editor-matchingTags = Etiquetas coincidentes
editor-createTag = Crear etiqueta: { $name } editor-createTag = Crear etiqueta: { $name }
duplicates-title = Buscar publicaciones duplicadas duplicates-title = Buscar publicaciones duplicadas
duplicates-searching = Buscando duplicados… duplicates-searching = Buscando duplicados…
duplicates-complete = Búsqueda de duplicados completada
duplicates-count = { $count } pares potenciales duplicates-count = { $count } pares potenciales
duplicates-disabled = La similitud semántica no está activada para este proyecto. duplicates-disabled = La similitud semántica no está activada para este proyecto.
duplicates-empty = No se encontraron publicaciones posiblemente duplicadas. duplicates-empty = No se encontraron publicaciones posiblemente duplicadas.
@@ -187,6 +188,7 @@ tasks-noActive = No hay tareas activas
tasks-cancelTask = Cancelar tarea tasks-cancelTask = Cancelar tarea
tasks-statusPending = Pendiente tasks-statusPending = Pendiente
tasks-statusRunning = En ejecución tasks-statusRunning = En ejecución
tasks-statusCancelling = Cancelando…
tasks-statusCompleted = Completada tasks-statusCompleted = Completada
tasks-statusFailed = Error: { $error } tasks-statusFailed = Error: { $error }
tasks-statusCancelled = Cancelada tasks-statusCancelled = Cancelada
@@ -238,6 +240,7 @@ siteValidation-apply = Aplicar validación
siteValidation-applying = Aplicando... siteValidation-applying = Aplicando...
siteValidation-idle = Ejecuta la validación del sitio para comparar los archivos generados con la salida esperada. siteValidation-idle = Ejecuta la validación del sitio para comparar los archivos generados con la salida esperada.
siteValidation-running = Validación en curso... siteValidation-running = Validación en curso...
siteValidation-complete = Validación completada
siteValidation-clean = No se encontraron archivos generados faltantes, extra o desactualizados. siteValidation-clean = No se encontraron archivos generados faltantes, extra o desactualizados.
siteValidation-error = La validación falló siteValidation-error = La validación falló
siteValidation-missing = Archivos faltantes siteValidation-missing = Archivos faltantes
@@ -569,6 +572,8 @@ settings-rebuildMedia = Reconstruir medios
settings-rebuildScripts = Reconstruir scripts settings-rebuildScripts = Reconstruir scripts
settings-rebuildTemplates = Reconstruir plantillas settings-rebuildTemplates = Reconstruir plantillas
settings-rebuildLinks = Reconstruir enlaces settings-rebuildLinks = Reconstruir enlaces
settings-rebuildPostLinks = Reconstruyendo enlaces de entradas…
settings-rebuildMediaLinks = Reconstruyendo enlaces de medios…
settings-regenerateThumbnails = Regenerar miniaturas settings-regenerateThumbnails = Regenerar miniaturas
settings-openDataFolder = Abrir carpeta de datos settings-openDataFolder = Abrir carpeta de datos
settings-installCli = Instalar CLI settings-installCli = Instalar CLI
@@ -641,6 +646,10 @@ metadataDiff-dbToFile = Usar base de datos
metadataDiff-orphans = Archivos huérfanos metadataDiff-orphans = Archivos huérfanos
metadataDiff-errors = Errores metadataDiff-errors = Errores
metadataDiff-repaired = Diferencia de metadatos reparada 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-field = { $field }: BD = { $database } · Archivo = { $file }
metadataDiff-entity = { $entity } · { $path } metadataDiff-entity = { $entity } · { $path }
metadataDiff-orphan = { $reason } · { $path } metadataDiff-orphan = { $reason } · { $path }
@@ -659,6 +668,7 @@ engine-progress-mediaItem = Medios: { $current }/{ $total } — { $name }
engine-progress-rebuildingTemplates = Reconstruyendo las plantillas… engine-progress-rebuildingTemplates = Reconstruyendo las plantillas…
engine-progress-rebuildingScripts = Reconstruyendo los scripts… engine-progress-rebuildingScripts = Reconstruyendo los scripts…
engine-progress-importingTags = Importando las etiquetas… engine-progress-importingTags = Importando las etiquetas…
engine-progress-rebuildingThumbnails = Reconstruyendo miniaturas faltantes…
engine-progress-refreshingSemanticIndex = Actualizando el índice semántico… engine-progress-refreshingSemanticIndex = Actualizando el índice semántico…
engine-progress-rebuildComplete = Reconstrucción completada engine-progress-rebuildComplete = Reconstrucción completada
translationValidation-run = Validar translationValidation-run = Validar
@@ -683,6 +693,9 @@ engine-renderingPages = Renderizando páginas ({ $current }/{ $total })
engine-generatedPage = Generado { $url } ({ $current }/{ $total }) engine-generatedPage = Generado { $url } ({ $current }/{ $total })
engine-rewrotePage = Reescrito { $url } ({ $current }/{ $total }) engine-rewrotePage = Reescrito { $url } ({ $current }/{ $total })
engine-renderSiteGroup = Renderizar sitio 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-forceRenderSiteGroup = Forzar la regeneración del sitio
engine-applyValidationGroup = Aplicar validación del sitio engine-applyValidationGroup = Aplicar validación del sitio
engine-renderSiteCore = Renderizar núcleo 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-renderDateArchives = Renderizar archivos por fecha
engine-buildSearchIndex = Crear índice de búsqueda engine-buildSearchIndex = Crear índice de búsqueda
engine-builtSearchFile = Creado { $path } ({ $current }/{ $total }) 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-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 engine-publicUrlRequired = Se requiere una URL pública antes de generar el sitio
settings-imageImportConcurrency = Importaciones de imágenes simultáneas (18) 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-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-openItemDeleted = Otro cliente eliminó el elemento abierto.
tui-taskRunning = { $label } — en curso tui-taskRunning = { $label } — en curso
tui-taskCancelling = { $label } — cancelando…
tui-taskProgress = { $label } — { $percent } % tui-taskProgress = { $label } — { $percent } %
tui-taskComplete = { $label } — completado tui-taskComplete = { $label } — completado
tui-taskFailed = { $label } — { $error } tui-taskFailed = { $label } — { $error }

View File

@@ -33,6 +33,7 @@ editor-matchingTags = Étiquettes correspondantes
editor-createTag = Créer létiquette : { $name } editor-createTag = Créer létiquette : { $name }
duplicates-title = Rechercher les articles en double duplicates-title = Rechercher les articles en double
duplicates-searching = Recherche des doublons… duplicates-searching = Recherche des doublons…
duplicates-complete = Recherche des doublons terminée
duplicates-count = { $count } paires potentielles duplicates-count = { $count } paires potentielles
duplicates-disabled = La similarité sémantique nest pas activée pour ce projet. duplicates-disabled = La similarité sémantique nest pas activée pour ce projet.
duplicates-empty = Aucun doublon potentiel trouvé. duplicates-empty = Aucun doublon potentiel trouvé.
@@ -187,6 +188,7 @@ tasks-noActive = Aucune tâche active
tasks-cancelTask = Annuler la tâche tasks-cancelTask = Annuler la tâche
tasks-statusPending = En attente tasks-statusPending = En attente
tasks-statusRunning = En cours tasks-statusRunning = En cours
tasks-statusCancelling = Annulation…
tasks-statusCompleted = Terminée tasks-statusCompleted = Terminée
tasks-statusFailed = Échec : { $error } tasks-statusFailed = Échec : { $error }
tasks-statusCancelled = Annulée tasks-statusCancelled = Annulée
@@ -238,6 +240,7 @@ siteValidation-apply = Appliquer la validation
siteValidation-applying = Application en cours... siteValidation-applying = Application en cours...
siteValidation-idle = Lancez la validation du site pour comparer les fichiers générés à la sortie attendue. siteValidation-idle = Lancez la validation du site pour comparer les fichiers générés à la sortie attendue.
siteValidation-running = Validation en cours... 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-clean = Aucun fichier généré manquant, supplémentaire ou obsolète n'a été trouvé.
siteValidation-error = La validation a échoué siteValidation-error = La validation a échoué
siteValidation-missing = Fichiers manquants siteValidation-missing = Fichiers manquants
@@ -569,6 +572,8 @@ settings-rebuildMedia = Reconstruire les médias
settings-rebuildScripts = Reconstruire les scripts settings-rebuildScripts = Reconstruire les scripts
settings-rebuildTemplates = Reconstruire les modèles settings-rebuildTemplates = Reconstruire les modèles
settings-rebuildLinks = Reconstruire les liens 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-regenerateThumbnails = Régénérer les miniatures
settings-openDataFolder = Ouvrir le dossier de données settings-openDataFolder = Ouvrir le dossier de données
settings-installCli = Installer la CLI settings-installCli = Installer la CLI
@@ -641,6 +646,10 @@ metadataDiff-dbToFile = Utiliser la base
metadataDiff-orphans = Fichiers orphelins metadataDiff-orphans = Fichiers orphelins
metadataDiff-errors = Erreurs metadataDiff-errors = Erreurs
metadataDiff-repaired = Différence de métadonnées réparée 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-field = { $field } : base = { $database } · Fichier = { $file }
metadataDiff-entity = { $entity } · { $path } metadataDiff-entity = { $entity } · { $path }
metadataDiff-orphan = { $reason } · { $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-rebuildingTemplates = Reconstruction des modèles…
engine-progress-rebuildingScripts = Reconstruction des scripts… engine-progress-rebuildingScripts = Reconstruction des scripts…
engine-progress-importingTags = Importation des étiquettes… engine-progress-importingTags = Importation des étiquettes…
engine-progress-rebuildingThumbnails = Reconstruction des miniatures manquantes…
engine-progress-refreshingSemanticIndex = Actualisation de lindex sémantique… engine-progress-refreshingSemanticIndex = Actualisation de lindex sémantique…
engine-progress-rebuildComplete = Reconstruction terminée engine-progress-rebuildComplete = Reconstruction terminée
translationValidation-run = Valider translationValidation-run = Valider
@@ -683,6 +693,9 @@ engine-renderingPages = Rendu des pages ({ $current }/{ $total })
engine-generatedPage = Généré { $url } ({ $current }/{ $total }) engine-generatedPage = Généré { $url } ({ $current }/{ $total })
engine-rewrotePage = Réécrit { $url } ({ $current }/{ $total }) engine-rewrotePage = Réécrit { $url } ({ $current }/{ $total })
engine-renderSiteGroup = Générer le site 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-forceRenderSiteGroup = Forcer la régénération du site
engine-applyValidationGroup = Appliquer la validation du site engine-applyValidationGroup = Appliquer la validation du site
engine-renderSiteCore = Générer le cœur 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-renderDateArchives = Générer les archives chronologiques
engine-buildSearchIndex = Construire lindex de recherche engine-buildSearchIndex = Construire lindex de recherche
engine-builtSearchFile = Construit { $path } ({ $current }/{ $total }) 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-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 engine-publicUrlRequired = Une URL publique est requise avant de générer le site
settings-imageImportConcurrency = Imports dimages simultanés (18) 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-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-openItemDeleted = Lélément ouvert a été supprimé par un autre client.
tui-taskRunning = { $label } — en cours tui-taskRunning = { $label } — en cours
tui-taskCancelling = { $label } — annulation…
tui-taskProgress = { $label } — { $percent } % tui-taskProgress = { $label } — { $percent } %
tui-taskComplete = { $label } — terminé tui-taskComplete = { $label } — terminé
tui-taskFailed = { $label } — { $error } tui-taskFailed = { $label } — { $error }

View File

@@ -33,6 +33,7 @@ editor-matchingTags = Tag corrispondenti
editor-createTag = Crea tag: { $name } editor-createTag = Crea tag: { $name }
duplicates-title = Trova post duplicati duplicates-title = Trova post duplicati
duplicates-searching = Ricerca duplicati… duplicates-searching = Ricerca duplicati…
duplicates-complete = Ricerca duplicati completata
duplicates-count = { $count } coppie potenziali duplicates-count = { $count } coppie potenziali
duplicates-disabled = La somiglianza semantica non è abilitata per questo progetto. duplicates-disabled = La somiglianza semantica non è abilitata per questo progetto.
duplicates-empty = Nessun possibile post duplicato trovato. duplicates-empty = Nessun possibile post duplicato trovato.
@@ -187,6 +188,7 @@ tasks-noActive = Nessuna attività attiva
tasks-cancelTask = Annulla attività tasks-cancelTask = Annulla attività
tasks-statusPending = In attesa tasks-statusPending = In attesa
tasks-statusRunning = In esecuzione tasks-statusRunning = In esecuzione
tasks-statusCancelling = Annullamento…
tasks-statusCompleted = Completata tasks-statusCompleted = Completata
tasks-statusFailed = Non riuscita: { $error } tasks-statusFailed = Non riuscita: { $error }
tasks-statusCancelled = Annullata tasks-statusCancelled = Annullata
@@ -238,6 +240,7 @@ siteValidation-apply = Applica validazione
siteValidation-applying = Applicazione in corso... siteValidation-applying = Applicazione in corso...
siteValidation-idle = Esegui la validazione del sito per confrontare i file generati con l'output previsto. siteValidation-idle = Esegui la validazione del sito per confrontare i file generati con l'output previsto.
siteValidation-running = Validazione in corso... siteValidation-running = Validazione in corso...
siteValidation-complete = Validazione completata
siteValidation-clean = Non sono stati trovati file generati mancanti, aggiuntivi o obsoleti. siteValidation-clean = Non sono stati trovati file generati mancanti, aggiuntivi o obsoleti.
siteValidation-error = Validazione non riuscita siteValidation-error = Validazione non riuscita
siteValidation-missing = File mancanti siteValidation-missing = File mancanti
@@ -569,6 +572,8 @@ settings-rebuildMedia = Ricostruisci media
settings-rebuildScripts = Ricostruisci script settings-rebuildScripts = Ricostruisci script
settings-rebuildTemplates = Ricostruisci modelli settings-rebuildTemplates = Ricostruisci modelli
settings-rebuildLinks = Ricostruisci link settings-rebuildLinks = Ricostruisci link
settings-rebuildPostLinks = Ricostruzione dei link degli articoli…
settings-rebuildMediaLinks = Ricostruzione dei link multimediali…
settings-regenerateThumbnails = Rigenera miniature settings-regenerateThumbnails = Rigenera miniature
settings-openDataFolder = Apri cartella dati settings-openDataFolder = Apri cartella dati
settings-installCli = Installa CLI settings-installCli = Installa CLI
@@ -641,6 +646,10 @@ metadataDiff-dbToFile = Usa database
metadataDiff-orphans = File orfani metadataDiff-orphans = File orfani
metadataDiff-errors = Errori metadataDiff-errors = Errori
metadataDiff-repaired = Differenza nei metadati riparata 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-field = { $field }: DB = { $database } · File = { $file }
metadataDiff-entity = { $entity } · { $path } metadataDiff-entity = { $entity } · { $path }
metadataDiff-orphan = { $reason } · { $path } metadataDiff-orphan = { $reason } · { $path }
@@ -659,6 +668,7 @@ engine-progress-mediaItem = Media: { $current }/{ $total } — { $name }
engine-progress-rebuildingTemplates = Ricostruzione dei modelli… engine-progress-rebuildingTemplates = Ricostruzione dei modelli…
engine-progress-rebuildingScripts = Ricostruzione degli script… engine-progress-rebuildingScripts = Ricostruzione degli script…
engine-progress-importingTags = Importazione dei tag… engine-progress-importingTags = Importazione dei tag…
engine-progress-rebuildingThumbnails = Ricostruzione delle miniature mancanti…
engine-progress-refreshingSemanticIndex = Aggiornamento dellindice semantico… engine-progress-refreshingSemanticIndex = Aggiornamento dellindice semantico…
engine-progress-rebuildComplete = Ricostruzione completata engine-progress-rebuildComplete = Ricostruzione completata
translationValidation-run = Verifica translationValidation-run = Verifica
@@ -683,6 +693,9 @@ engine-renderingPages = Rendering delle pagine ({ $current }/{ $total })
engine-generatedPage = Generato { $url } ({ $current }/{ $total }) engine-generatedPage = Generato { $url } ({ $current }/{ $total })
engine-rewrotePage = Riscritto { $url } ({ $current }/{ $total }) engine-rewrotePage = Riscritto { $url } ({ $current }/{ $total })
engine-renderSiteGroup = Genera sito 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-forceRenderSiteGroup = Forza la rigenerazione del sito
engine-applyValidationGroup = Applica convalida del sito engine-applyValidationGroup = Applica convalida del sito
engine-renderSiteCore = Genera nucleo 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-renderDateArchives = Genera archivi per data
engine-buildSearchIndex = Crea indice di ricerca engine-buildSearchIndex = Crea indice di ricerca
engine-builtSearchFile = Creato { $path } ({ $current }/{ $total }) 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-generationSummary = Generazione completata: scritti={ $written }, ignorati={ $skipped }, eliminati={ $deleted }, output={ $output }
engine-publicUrlRequired = È richiesto un URL pubblico prima di generare il sito engine-publicUrlRequired = È richiesto un URL pubblico prima di generare il sito
settings-imageImportConcurrency = Importazioni immagini simultanee (18) 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-categoryEditingGuiOnly = Laggiunta, la modifica e la rimozione delle categorie restano nellapplicazione desktop
tui-openItemDeleted = Lelemento aperto è stato eliminato da un altro client. tui-openItemDeleted = Lelemento aperto è stato eliminato da un altro client.
tui-taskRunning = { $label } — in esecuzione tui-taskRunning = { $label } — in esecuzione
tui-taskCancelling = { $label } — annullamento…
tui-taskProgress = { $label } — { $percent }% tui-taskProgress = { $label } — { $percent }%
tui-taskComplete = { $label } — completato tui-taskComplete = { $label } — completato
tui-taskFailed = { $label } — { $error } tui-taskFailed = { $label } — { $error }

View File

@@ -44,9 +44,10 @@ surface GenerationRuntimeSurface {
-- "Generated" for a full render and "Rewrote" for a validation apply — -- "Generated" for a full render and "Rewrote" for a validation apply —
-- and advances the bar by the number of URLs written. -- and advances the bar by the number of URLs written.
-- --
-- Before section work begins, generation counts its planned URLs. The -- Route preparation and counting happen in background work before
-- task reports 0/total immediately and advances once per rendered URL, -- section rendering. Validation totals count only prepared URLs
-- matching bDS2's route-based generation progress. -- 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 -- Full generation and validation apply share the same structuring: a
-- task group containing one task per section (Render Site Core, Render -- 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 -- unchanged post route refreshes its tracked generation time so the
-- automatic follow-up validation does not report it stale again. -- automatic follow-up validation does not report it stale again.
ensures: GenerateSiteRequested(plan_generation(project_id, sections)) 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 -- Day-block grouping for archives

View File

@@ -262,7 +262,9 @@ invariant PanelTabFallback {
-- git_log unavailable when neither post nor media tab is active -- 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 -- Tasks with shared group_id are collapsible groups showing bDS2-style
-- average child progress; pending children contribute zero. -- average child progress; pending children contribute zero.
-- Output tab: log entries with copy-all button. -- Output tab: log entries with copy-all button.
@@ -287,6 +289,12 @@ value StatusBarLeft {
running_task_overflow: Integer? -- "+N more" count when multiple running 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 { value StatusBarRight {
-- In display order (left to right): -- In display order (left to right):
post_status: String? -- draft|published|archived dot, when post tab active post_status: String? -- draft|published|archived dot, when post tab active

View File

@@ -135,6 +135,11 @@ surface ScriptRuntimeSurface {
-- sync_from_posts. -- sync_from_posts.
-- bds.tasks: get, status_snapshot, cancel, get_all, get_running, -- bds.tasks: get, status_snapshot, cancel, get_all, get_running,
-- clear_completed. -- 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, -- bds.sync: check_availability, get_repo_state, get_status,
-- get_history, get_remote_state, fetch, pull, push, commit_all. -- get_history, get_remote_state, fetch, pull, push, commit_all.
-- bds.publish: upload_site. -- bds.publish: upload_site.

View File

@@ -6,6 +6,9 @@
entity Task { entity Task {
name: String name: String
status: pending | running | completed | failed | cancelled status: pending | running | completed | failed | cancelled
source: local | remote
cancellable: Boolean
cancellation_requested: Boolean
progress: Decimal? -- 0.0..1.0 progress: Decimal? -- 0.0..1.0
message: String? message: String?
group_id: String? -- Optional task grouping group_id: String? -- Optional task grouping
@@ -28,6 +31,7 @@ surface TaskControlSurface {
SubmitTaskRequested(name, work) SubmitTaskRequested(name, work)
CancelTaskRequested(task) CancelTaskRequested(task)
RegisterExternalTaskRequested(name) RegisterExternalTaskRequested(name)
TaskStateRequested()
} }
surface TaskRuntimeSurface { surface TaskRuntimeSurface {
@@ -38,6 +42,8 @@ surface TaskRuntimeSurface {
TaskWorkFailed(task, error_message) TaskWorkFailed(task, error_message)
ProgressReported(task, value, message) ProgressReported(task, value, message)
FinishedTaskEvictionDue() FinishedTaskEvictionDue()
WorkerCapacityAvailable()
TaskWorkStopped(task)
} }
surface TaskSurface { surface TaskSurface {
@@ -46,6 +52,7 @@ surface TaskSurface {
exposes: exposes:
task.name task.name
task.status task.status
task.source
task.progress when task.progress != null task.progress when task.progress != null
task.message when task.message != null task.message when task.message != null
task.group_id when task.group_id != null task.group_id when task.group_id != null
@@ -74,13 +81,23 @@ invariant FifoQueue {
rule SubmitTask { rule SubmitTask {
when: SubmitTaskRequested(name, work) when: SubmitTaskRequested(name, work)
let running_tasks = Tasks where status = running
ensures: 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 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 { rule CompleteTask {
@@ -99,8 +116,28 @@ rule FailTask {
rule CancelTask { rule CancelTask {
when: CancelTaskRequested(task) when: CancelTaskRequested(task)
requires: task.status = running or task.status = pending requires: task.status = pending
-- Cancellation uses a runtime-specific cancellation mechanism 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: task.status = cancelled
ensures: NextQueuedTaskStarted() ensures: NextQueuedTaskStarted()
} }
@@ -110,6 +147,9 @@ rule ReportProgress {
-- Progress events throttled to 250ms -- Progress events throttled to 250ms
ensures: task.progress = value ensures: task.progress = value
ensures: task.message = message 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 { invariant ProgressThrottled {
@@ -139,11 +179,24 @@ rule EvictFinishedTasks {
ensures: not exists task 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) -- External tasks: lifecycle controlled by caller (e.g., renderer-side scripts)
rule RegisterExternalTask { rule RegisterExternalTask {
when: RegisterExternalTaskRequested(name) when: RegisterExternalTaskRequested(name)
ensures: 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 task.status = running
@guidance @guidance
-- External tasks are not managed by the queue -- External tasks are not managed by the queue