fix: more work on background tasks
This commit is contained in:
@@ -355,7 +355,7 @@ fn rebuild(db: &Database, incremental: bool) -> Result<CommandOutput> {
|
||||
|
||||
let progress = Arc::new(Mutex::new(Vec::new()));
|
||||
let progress_sink = Arc::clone(&progress);
|
||||
let (report, thumbnails) = cli_sync::run_cli_mutation(db.conn(), || {
|
||||
let report = cli_sync::run_cli_mutation(db.conn(), || {
|
||||
let report = engine::rebuild::rebuild_from_filesystem_with_progress(
|
||||
db.conn(),
|
||||
&data_dir,
|
||||
@@ -369,12 +369,11 @@ fn rebuild(db: &Database, incremental: bool) -> Result<CommandOutput> {
|
||||
if progress.last() != Some(&line) {
|
||||
progress.push(line);
|
||||
}
|
||||
true
|
||||
})),
|
||||
)?;
|
||||
let thumbnails =
|
||||
engine::media::regenerate_missing_thumbnails(db.conn(), &data_dir, &project.id)?;
|
||||
emit_bulk(&project.id);
|
||||
Ok((report, thumbnails))
|
||||
Ok(report)
|
||||
})?;
|
||||
let mut result = output(
|
||||
"Rebuild complete",
|
||||
@@ -389,8 +388,8 @@ fn rebuild(db: &Database, incremental: bool) -> Result<CommandOutput> {
|
||||
"templates_updated": report.templates_updated,
|
||||
"scripts_created": report.scripts_created,
|
||||
"scripts_updated": report.scripts_updated,
|
||||
"thumbnails_generated": thumbnails.thumbnails_generated,
|
||||
"thumbnail_media_failed": thumbnails.media_failed,
|
||||
"thumbnails_generated": report.thumbnails_generated,
|
||||
"thumbnail_media_failed": report.thumbnail_media_failed,
|
||||
}),
|
||||
);
|
||||
result.progress = progress
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::path::Path;
|
||||
use crate::db::DbConnection as Connection;
|
||||
|
||||
use crate::db::queries::post as post_q;
|
||||
use crate::engine::EngineResult;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::PostStatus;
|
||||
use crate::util::{atomic_write, timestamp};
|
||||
|
||||
@@ -18,6 +18,15 @@ pub fn regenerate_calendar(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
) -> EngineResult<()> {
|
||||
regenerate_calendar_with_progress(conn, data_dir, project_id, |_, _, _| true)
|
||||
}
|
||||
|
||||
pub fn regenerate_calendar_with_progress(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
mut on_item: impl FnMut(usize, usize, &str) -> bool,
|
||||
) -> EngineResult<()> {
|
||||
let posts = post_q::list_posts_by_project(conn, project_id)?;
|
||||
|
||||
@@ -25,7 +34,11 @@ pub fn regenerate_calendar(
|
||||
let mut months: BTreeMap<String, u32> = BTreeMap::new();
|
||||
let mut days: BTreeMap<String, u32> = BTreeMap::new();
|
||||
|
||||
for post in &posts {
|
||||
let total = posts.len() + 1;
|
||||
for (index, post) in posts.iter().enumerate() {
|
||||
if !on_item(index + 1, total, &post.title) {
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
if post.status != PostStatus::Published {
|
||||
continue;
|
||||
}
|
||||
@@ -41,6 +54,10 @@ pub fn regenerate_calendar(
|
||||
*days.entry(day_key).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
if !on_item(total, total, "calendar.json") {
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
|
||||
let calendar = serde_json::json!({
|
||||
"years": years,
|
||||
"months": months,
|
||||
@@ -82,4 +99,20 @@ mod tests {
|
||||
serde_json::from_str(&std::fs::read_to_string(&cal_path).unwrap()).unwrap();
|
||||
assert!(data["years"].as_object().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_before_write_leaves_no_calendar() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
db.migrate().unwrap();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let project =
|
||||
engine::project::create_project(db.conn(), "Test", Some(dir.path().to_str().unwrap()))
|
||||
.unwrap();
|
||||
|
||||
let result =
|
||||
regenerate_calendar_with_progress(db.conn(), dir.path(), &project.id, |_, _, _| false);
|
||||
|
||||
assert!(matches!(result, Err(EngineError::Cancelled)));
|
||||
assert!(!dir.path().join("html/calendar.json").exists());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ pub enum EngineError {
|
||||
Conflict(String),
|
||||
#[error("validation error: {0}")]
|
||||
Validation(String),
|
||||
#[error("operation cancelled")]
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl From<crate::db::DatabaseError> for EngineError {
|
||||
|
||||
@@ -63,6 +63,15 @@ pub struct PreparedSiteGeneration {
|
||||
generated_hashes: Arc<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PreparedSiteGeneration {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("PreparedSiteGeneration")
|
||||
.field("source_count", &self.sources.len())
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prepare_site_generation(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
@@ -91,6 +100,34 @@ pub fn prepare_site_generation(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn prepared_section_page_count(
|
||||
prepared: &PreparedSiteGeneration,
|
||||
validation: Option<&SiteValidationReport>,
|
||||
section: GenerationSection,
|
||||
) -> usize {
|
||||
let requested = validation.map(|validation| {
|
||||
validation
|
||||
.missing_pages
|
||||
.iter()
|
||||
.chain(validation.stale_pages.iter())
|
||||
.cloned()
|
||||
.collect::<HashSet<_>>()
|
||||
});
|
||||
let fallback = validation.is_some_and(|validation| {
|
||||
validation
|
||||
.missing_pages
|
||||
.iter()
|
||||
.chain(validation.extra_pages.iter())
|
||||
.chain(validation.stale_pages.iter())
|
||||
.any(|path| classify_generated_path(path, &prepared.metadata).is_none())
|
||||
});
|
||||
crate::render::count_site_render_pages_from_context(
|
||||
&prepared.render,
|
||||
section,
|
||||
(!fallback).then_some(requested.as_ref()).flatten(),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum GenerationSection {
|
||||
Core,
|
||||
|
||||
@@ -53,9 +53,18 @@ pub fn rebuild_media_links(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
) -> EngineResult<MediaLinkRebuildReport> {
|
||||
rebuild_media_links_with_progress(conn, data_dir, project_id, None)
|
||||
}
|
||||
|
||||
pub fn rebuild_media_links_with_progress(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
on_item: Option<ItemProgressFn>,
|
||||
) -> EngineResult<MediaLinkRebuildReport> {
|
||||
conn.begin_savepoint()?;
|
||||
match rebuild_media_links_inner(conn, data_dir, project_id) {
|
||||
match rebuild_media_links_inner(conn, data_dir, project_id, on_item.as_ref()) {
|
||||
Ok(report) => {
|
||||
conn.release_savepoint()?;
|
||||
Ok(report)
|
||||
@@ -71,9 +80,16 @@ fn rebuild_media_links_inner(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
on_item: Option<&ItemProgressFn>,
|
||||
) -> EngineResult<MediaLinkRebuildReport> {
|
||||
let mut report = MediaLinkRebuildReport::default();
|
||||
for item in qm::list_media_by_project(conn, project_id)? {
|
||||
let items = qm::list_media_by_project(conn, project_id)?;
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
if let Some(callback) = on_item
|
||||
&& !callback(index + 1, items.len(), &item.original_name)
|
||||
{
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
let sidecar = read_sidecar(&fs::read_to_string(data_dir.join(&item.sidecar_path))?)
|
||||
.map_err(EngineError::Parse)?;
|
||||
for link in qpm::list_post_media_by_media(conn, &item.id)? {
|
||||
@@ -110,9 +126,22 @@ pub fn regenerate_missing_thumbnails(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
) -> EngineResult<ThumbnailRepairReport> {
|
||||
regenerate_missing_thumbnails_with_progress(conn, data_dir, project_id, |_, _, _| true)
|
||||
}
|
||||
|
||||
pub fn regenerate_missing_thumbnails_with_progress(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
mut on_item: impl FnMut(usize, usize, &str) -> bool,
|
||||
) -> EngineResult<ThumbnailRepairReport> {
|
||||
let mut report = ThumbnailRepairReport::default();
|
||||
for item in qm::list_media_by_project(conn, project_id)? {
|
||||
let items = qm::list_media_by_project(conn, project_id)?;
|
||||
for (index, item) in items.iter().enumerate() {
|
||||
if !on_item(index + 1, items.len(), &item.original_name) {
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
if !item.mime_type.starts_with("image/") || item.mime_type.contains("svg") {
|
||||
continue;
|
||||
}
|
||||
@@ -653,7 +682,7 @@ pub fn rebuild_media_from_filesystem(
|
||||
}
|
||||
|
||||
/// Per-item progress callback: (current_item, total_items, item_description).
|
||||
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) + Send>;
|
||||
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) -> bool + Send>;
|
||||
|
||||
/// Like `rebuild_media_from_filesystem` but with optional per-item progress.
|
||||
pub fn rebuild_media_from_filesystem_with_progress(
|
||||
@@ -699,7 +728,9 @@ pub fn rebuild_media_from_filesystem_with_progress(
|
||||
for (i, path) in canonical_sidecars.iter().enumerate() {
|
||||
if let Some(ref cb) = on_item {
|
||||
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("?");
|
||||
cb(i + 1, total, name);
|
||||
if !cb(i + 1, total, name) {
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
}
|
||||
match rebuild_canonical_media(conn, data_dir, project_id, path) {
|
||||
Ok(created) => {
|
||||
@@ -720,7 +751,9 @@ pub fn rebuild_media_from_filesystem_with_progress(
|
||||
for (i, path) in translation_sidecars.iter().enumerate() {
|
||||
if let Some(ref cb) = on_item {
|
||||
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("?");
|
||||
cb(offset + i + 1, total, name);
|
||||
if !cb(offset + i + 1, total, name) {
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
}
|
||||
match rebuild_translation_sidecar(conn, data_dir, project_id, path) {
|
||||
Ok(created) => {
|
||||
|
||||
@@ -70,8 +70,27 @@ pub fn compute_metadata_diff(
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
) -> EngineResult<DiffReport> {
|
||||
compute_metadata_diff_with_progress(conn, data_dir, project_id, |_, _| true)
|
||||
}
|
||||
|
||||
pub fn compute_metadata_diff_with_progress(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
mut on_progress: impl FnMut(usize, usize) -> bool,
|
||||
) -> EngineResult<DiffReport> {
|
||||
const PHASES: usize = 8;
|
||||
let mut phase = 0;
|
||||
let mut next_phase = || -> EngineResult<()> {
|
||||
if !on_progress(phase, PHASES) {
|
||||
return Err(crate::engine::EngineError::Cancelled);
|
||||
}
|
||||
phase += 1;
|
||||
Ok(())
|
||||
};
|
||||
let mut report = DiffReport::default();
|
||||
|
||||
next_phase()?;
|
||||
if let Ok(project) = qproject::get_project_by_id(conn, project_id) {
|
||||
match diff_project(conn, data_dir, &project) {
|
||||
Ok(Some(diff)) => report.diffs.push(diff),
|
||||
@@ -94,6 +113,7 @@ pub fn compute_metadata_diff(
|
||||
}
|
||||
|
||||
// 1. Diff posts
|
||||
next_phase()?;
|
||||
let posts = qp::list_posts_by_project(conn, project_id)?;
|
||||
for post in &posts {
|
||||
if post.file_path.is_empty() {
|
||||
@@ -107,6 +127,7 @@ pub fn compute_metadata_diff(
|
||||
}
|
||||
|
||||
// 2. Diff translations
|
||||
next_phase()?;
|
||||
for post in &posts {
|
||||
let translations = qt::list_post_translations_by_post(conn, &post.id)?;
|
||||
for t in &translations {
|
||||
@@ -122,6 +143,7 @@ pub fn compute_metadata_diff(
|
||||
}
|
||||
|
||||
// 3. Diff media
|
||||
next_phase()?;
|
||||
let media_items = qm::list_media_by_project(conn, project_id)?;
|
||||
for m in &media_items {
|
||||
if m.sidecar_path.is_empty() {
|
||||
@@ -146,6 +168,7 @@ pub fn compute_metadata_diff(
|
||||
}
|
||||
|
||||
// 4. Diff templates
|
||||
next_phase()?;
|
||||
let templates = qtpl::list_templates_by_project(conn, project_id)?;
|
||||
for t in &templates {
|
||||
if t.file_path.is_empty() {
|
||||
@@ -159,6 +182,7 @@ pub fn compute_metadata_diff(
|
||||
}
|
||||
|
||||
// 5. Diff scripts
|
||||
next_phase()?;
|
||||
let scripts = qs::list_scripts_by_project(conn, project_id)?;
|
||||
for s in &scripts {
|
||||
if s.file_path.is_empty() {
|
||||
@@ -172,6 +196,7 @@ pub fn compute_metadata_diff(
|
||||
}
|
||||
|
||||
// 6. Detect orphans
|
||||
next_phase()?;
|
||||
if crate::engine::meta::read_project_json(data_dir)
|
||||
.is_ok_and(|metadata| metadata.semantic_similarity_enabled)
|
||||
{
|
||||
@@ -215,9 +240,14 @@ pub fn compute_metadata_diff(
|
||||
}
|
||||
|
||||
// 7. Detect orphans
|
||||
next_phase()?;
|
||||
let orphans = detect_orphan_files(conn, data_dir, project_id)?;
|
||||
report.orphans = orphans;
|
||||
|
||||
if !on_progress(PHASES, PHASES) {
|
||||
return Err(crate::engine::EngineError::Cancelled);
|
||||
}
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
|
||||
@@ -716,7 +716,7 @@ pub fn rebuild_posts_from_filesystem(
|
||||
}
|
||||
|
||||
/// Per-item progress callback: (current_item, total_items, item_description).
|
||||
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) + Send>;
|
||||
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) -> bool + Send>;
|
||||
|
||||
/// Like `rebuild_posts_from_filesystem` but with optional per-item progress.
|
||||
pub fn rebuild_posts_from_filesystem_with_progress(
|
||||
@@ -760,7 +760,9 @@ pub fn rebuild_posts_from_filesystem_with_progress(
|
||||
for (i, path) in canonical_files.iter().enumerate() {
|
||||
if let Some(ref cb) = on_item {
|
||||
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("?");
|
||||
cb(i + 1, total, name);
|
||||
if !cb(i + 1, total, name) {
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
}
|
||||
match rebuild_canonical_post(conn, data_dir, project_id, path) {
|
||||
Ok(created) => {
|
||||
@@ -781,7 +783,9 @@ pub fn rebuild_posts_from_filesystem_with_progress(
|
||||
for (i, path) in translation_files.iter().enumerate() {
|
||||
if let Some(ref cb) = on_item {
|
||||
let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("?");
|
||||
cb(offset + i + 1, total, name);
|
||||
if !cb(offset + i + 1, total, name) {
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
}
|
||||
match rebuild_translation(conn, data_dir, project_id, path) {
|
||||
Ok(created) => {
|
||||
@@ -816,11 +820,25 @@ pub fn rebuild_all_links(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
) -> EngineResult<usize> {
|
||||
rebuild_all_links_with_progress(conn, data_dir, project_id, None)
|
||||
}
|
||||
|
||||
pub fn rebuild_all_links_with_progress(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
on_item: Option<ItemProgressFn>,
|
||||
) -> EngineResult<usize> {
|
||||
let posts = qp::list_posts_by_project(conn, project_id)?;
|
||||
let mut link_count = 0;
|
||||
|
||||
for post in &posts {
|
||||
for (index, post) in posts.iter().enumerate() {
|
||||
if let Some(ref callback) = on_item
|
||||
&& !callback(index + 1, posts.len(), &post.title)
|
||||
{
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
// Get post content: from DB or filesystem
|
||||
let content = if let Some(ref content) = post.content {
|
||||
content.clone()
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use walkdir::WalkDir;
|
||||
@@ -124,6 +127,26 @@ pub fn upload_site(
|
||||
private_cache_dir: &Path,
|
||||
preferences: &PublishingPreferences,
|
||||
mut on_progress: impl FnMut(usize, usize, UploadTargetKind),
|
||||
) -> EngineResult<PublishJob> {
|
||||
let cancelled = AtomicBool::new(false);
|
||||
upload_site_cancellable(
|
||||
data_dir,
|
||||
private_cache_dir,
|
||||
preferences,
|
||||
&cancelled,
|
||||
move |current, total, kind| {
|
||||
on_progress(current, total, kind);
|
||||
true
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn upload_site_cancellable(
|
||||
data_dir: &Path,
|
||||
private_cache_dir: &Path,
|
||||
preferences: &PublishingPreferences,
|
||||
cancel_flag: &AtomicBool,
|
||||
mut on_progress: impl FnMut(usize, usize, UploadTargetKind) -> bool,
|
||||
) -> EngineResult<PublishJob> {
|
||||
if std::env::var_os("SSH_AUTH_SOCK").is_none() {
|
||||
return Err(EngineError::Validation(
|
||||
@@ -134,7 +157,7 @@ pub fn upload_site(
|
||||
data_dir,
|
||||
private_cache_dir,
|
||||
preferences,
|
||||
&mut |program, args| run_command(program, args),
|
||||
&mut |program, args| run_command_cancellable(program, args, cancel_flag),
|
||||
&mut on_progress,
|
||||
)
|
||||
}
|
||||
@@ -144,7 +167,7 @@ fn upload_site_with_runner(
|
||||
private_cache_dir: &Path,
|
||||
preferences: &PublishingPreferences,
|
||||
runner: &mut CommandRunner<'_>,
|
||||
on_progress: &mut dyn FnMut(usize, usize, UploadTargetKind),
|
||||
on_progress: &mut dyn FnMut(usize, usize, UploadTargetKind) -> bool,
|
||||
) -> EngineResult<PublishJob> {
|
||||
let credentials = Credentials::from_preferences(preferences)?;
|
||||
let targets = build_upload_targets(data_dir, &credentials);
|
||||
@@ -162,7 +185,9 @@ fn upload_site_with_runner(
|
||||
let cache_path = private_cache_dir.join("publishing-scp-mtimes.json");
|
||||
let mut cache = read_cache(&cache_path);
|
||||
for (index, target) in targets.iter().enumerate() {
|
||||
on_progress(index + 1, targets.len(), target.kind);
|
||||
if !on_progress(index + 1, targets.len(), target.kind) {
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
let result = match credentials.mode {
|
||||
SshMode::Rsync => upload_rsync(target, &credentials, runner),
|
||||
SshMode::Scp => upload_scp(target, &credentials, &mut cache, runner),
|
||||
@@ -328,21 +353,49 @@ fn write_cache(path: &Path, cache: &ScpMtimeCache) -> EngineResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_command(program: &str, args: &[String]) -> Result<(), String> {
|
||||
let output = Command::new(program)
|
||||
fn run_command_cancellable(
|
||||
program: &str,
|
||||
args: &[String],
|
||||
cancel_flag: &AtomicBool,
|
||||
) -> Result<(), String> {
|
||||
let mut child = Command::new(program)
|
||||
.args(args)
|
||||
.output()
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|error| format!("failed to start {program}: {error}"))?;
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
Err(if stderr.is_empty() {
|
||||
format!("{program} exited with {}", output.status)
|
||||
} else {
|
||||
stderr
|
||||
})
|
||||
let stderr = child.stderr.take();
|
||||
let stderr_reader = std::thread::spawn(move || {
|
||||
let mut output = String::new();
|
||||
if let Some(mut stderr) = stderr {
|
||||
let _ = stderr.read_to_string(&mut output);
|
||||
}
|
||||
output
|
||||
});
|
||||
let status = loop {
|
||||
if cancel_flag.load(Ordering::Acquire) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
let _ = stderr_reader.join();
|
||||
return Err("operation cancelled".into());
|
||||
}
|
||||
if let Some(status) = child
|
||||
.try_wait()
|
||||
.map_err(|error| format!("failed to wait for {program}: {error}"))?
|
||||
{
|
||||
break status;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
};
|
||||
let stderr = stderr_reader.join().unwrap_or_default().trim().to_string();
|
||||
if status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
Err(if stderr.is_empty() {
|
||||
format!("{program} exited with {status}")
|
||||
} else {
|
||||
stderr
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -376,7 +429,7 @@ mod tests {
|
||||
commands.push((program.to_owned(), args.to_vec()));
|
||||
Ok(())
|
||||
},
|
||||
&mut |_, _, _| {},
|
||||
&mut |_, _, _| true,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -397,6 +450,28 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_stops_before_starting_the_next_target() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let cache = TempDir::new().unwrap();
|
||||
fs::create_dir_all(dir.path().join("html")).unwrap();
|
||||
let mut commands = 0;
|
||||
|
||||
let result = upload_site_with_runner(
|
||||
dir.path(),
|
||||
cache.path(),
|
||||
&preferences(SshMode::Rsync),
|
||||
&mut |_, _| {
|
||||
commands += 1;
|
||||
Ok(())
|
||||
},
|
||||
&mut |_, _, _| false,
|
||||
);
|
||||
|
||||
assert!(matches!(result, Err(EngineError::Cancelled)));
|
||||
assert_eq!(commands, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scp_excludes_sidecars_and_skips_unchanged_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -415,7 +490,7 @@ mod tests {
|
||||
first.push((program.to_owned(), args.to_vec()));
|
||||
Ok(())
|
||||
},
|
||||
&mut |_, _, _| {},
|
||||
&mut |_, _, _| true,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
@@ -437,7 +512,7 @@ mod tests {
|
||||
second.push(program.to_owned());
|
||||
Ok(())
|
||||
},
|
||||
&mut |_, _, _| {},
|
||||
&mut |_, _, _| true,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(second.is_empty());
|
||||
@@ -454,7 +529,7 @@ mod tests {
|
||||
cache.path(),
|
||||
&preferences(SshMode::Rsync),
|
||||
&mut |_, _| Err("network down".into()),
|
||||
&mut |_, _, _| {},
|
||||
&mut |_, _, _| true,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("network down"));
|
||||
|
||||
@@ -27,6 +27,8 @@ pub struct FullRebuildReport {
|
||||
pub templates_updated: usize,
|
||||
pub scripts_created: usize,
|
||||
pub scripts_updated: usize,
|
||||
pub thumbnails_generated: usize,
|
||||
pub thumbnail_media_failed: usize,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -91,8 +93,19 @@ pub enum RebuildProgress {
|
||||
name: String,
|
||||
},
|
||||
RebuildingTemplates,
|
||||
TemplateItem {
|
||||
current: usize,
|
||||
total: usize,
|
||||
name: String,
|
||||
},
|
||||
RebuildingScripts,
|
||||
ScriptItem {
|
||||
current: usize,
|
||||
total: usize,
|
||||
name: String,
|
||||
},
|
||||
ImportingTags,
|
||||
RebuildingThumbnails,
|
||||
RefreshingSemanticIndex,
|
||||
Complete,
|
||||
}
|
||||
@@ -116,8 +129,19 @@ impl RebuildProgress {
|
||||
name,
|
||||
} => localize_item(locale, "engine.progress.mediaItem", *current, *total, name),
|
||||
Self::RebuildingTemplates => translate(locale, "engine.progress.rebuildingTemplates"),
|
||||
Self::TemplateItem {
|
||||
current,
|
||||
total,
|
||||
name,
|
||||
} => localize_item(locale, "engine.checkingItem", *current, *total, name),
|
||||
Self::RebuildingScripts => translate(locale, "engine.progress.rebuildingScripts"),
|
||||
Self::ScriptItem {
|
||||
current,
|
||||
total,
|
||||
name,
|
||||
} => localize_item(locale, "engine.checkingItem", *current, *total, name),
|
||||
Self::ImportingTags => translate(locale, "engine.progress.importingTags"),
|
||||
Self::RebuildingThumbnails => translate(locale, "engine.progress.rebuildingThumbnails"),
|
||||
Self::RefreshingSemanticIndex => {
|
||||
translate(locale, "engine.progress.refreshingSemanticIndex")
|
||||
}
|
||||
@@ -137,7 +161,7 @@ fn localize_item(locale: UiLocale, key: &str, current: usize, total: usize, name
|
||||
}
|
||||
|
||||
/// Progress callback: (percent 0.0..1.0, semantic progress event).
|
||||
pub type ProgressFn = Arc<dyn Fn(f32, &RebuildProgress) + Send + Sync>;
|
||||
pub type ProgressFn = Arc<dyn Fn(f32, &RebuildProgress) -> bool + Send + Sync>;
|
||||
|
||||
/// Orchestrate a full rebuild from filesystem into the database.
|
||||
///
|
||||
@@ -178,23 +202,26 @@ fn rebuild_from_filesystem_inner(
|
||||
on_progress: Option<ProgressFn>,
|
||||
) -> EngineResult<FullRebuildReport> {
|
||||
let mut report = FullRebuildReport::default();
|
||||
let progress = |pct: f32, event: RebuildProgress| {
|
||||
if let Some(ref f) = on_progress {
|
||||
f(pct, &event);
|
||||
let progress = |pct: f32, event: RebuildProgress| -> EngineResult<()> {
|
||||
if let Some(ref f) = on_progress
|
||||
&& !f(pct, &event)
|
||||
{
|
||||
return Err(crate::engine::EngineError::Cancelled);
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
|
||||
// Phase weights: posts 0.0..0.35, media 0.35..0.70, templates 0.70..0.85, scripts 0.85..1.0
|
||||
|
||||
// 1. Load portable project metadata and clear all reconstructible rows.
|
||||
progress(0.0, RebuildProgress::LoadingProjectMetadata);
|
||||
progress(0.0, RebuildProgress::LoadingProjectMetadata)?;
|
||||
fts::ensure_fts_tables(conn)?;
|
||||
crate::engine::meta::startup_sync(data_dir)?;
|
||||
crate::engine::meta::sync_metadata_from_filesystem(conn, data_dir, project_id)?;
|
||||
clear_project_rows(conn, project_id)?;
|
||||
|
||||
// 2. Rebuild posts (0.00 .. 0.35)
|
||||
progress(0.01, RebuildProgress::ScanningPosts);
|
||||
progress(0.01, RebuildProgress::ScanningPosts)?;
|
||||
let post_item_cb: Option<post::ItemProgressFn> = on_progress.as_ref().map(|cb| {
|
||||
let cb = Arc::clone(cb);
|
||||
let f: post::ItemProgressFn = Box::new(move |current, total, name| {
|
||||
@@ -211,7 +238,7 @@ fn rebuild_from_filesystem_inner(
|
||||
total,
|
||||
name: name.to_string(),
|
||||
},
|
||||
);
|
||||
)
|
||||
});
|
||||
f
|
||||
});
|
||||
@@ -228,7 +255,7 @@ fn rebuild_from_filesystem_inner(
|
||||
report.errors.extend(post_report.errors);
|
||||
|
||||
// 3. Rebuild media (0.35 .. 0.70)
|
||||
progress(0.35, RebuildProgress::ScanningMedia);
|
||||
progress(0.35, RebuildProgress::ScanningMedia)?;
|
||||
let media_item_cb: Option<media::ItemProgressFn> = on_progress.as_ref().map(|cb| {
|
||||
let cb = Arc::clone(cb);
|
||||
let f: media::ItemProgressFn = Box::new(move |current, total, name| {
|
||||
@@ -245,7 +272,7 @@ fn rebuild_from_filesystem_inner(
|
||||
total,
|
||||
name: name.to_string(),
|
||||
},
|
||||
);
|
||||
)
|
||||
});
|
||||
f
|
||||
});
|
||||
@@ -262,26 +289,87 @@ fn rebuild_from_filesystem_inner(
|
||||
report.errors.extend(media_report.errors);
|
||||
|
||||
// 4. Rebuild templates (0.70 .. 0.85)
|
||||
progress(0.70, RebuildProgress::RebuildingTemplates);
|
||||
let tpl_report =
|
||||
template_rebuild::rebuild_templates_from_filesystem(conn, data_dir, project_id)?;
|
||||
progress(0.70, RebuildProgress::RebuildingTemplates)?;
|
||||
let template_progress = on_progress.as_ref().map(|callback| {
|
||||
let callback = Arc::clone(callback);
|
||||
Box::new(move |current: usize, total: usize, name: &str| {
|
||||
callback(
|
||||
0.70 + current as f32 / total.max(1) as f32 * 0.15,
|
||||
&RebuildProgress::TemplateItem {
|
||||
current,
|
||||
total,
|
||||
name: name.to_string(),
|
||||
},
|
||||
)
|
||||
}) as template_rebuild::ItemProgressFn
|
||||
});
|
||||
let tpl_report = template_rebuild::rebuild_templates_from_filesystem_with_progress(
|
||||
conn,
|
||||
data_dir,
|
||||
project_id,
|
||||
template_progress,
|
||||
)?;
|
||||
report.templates_created = tpl_report.created;
|
||||
report.templates_updated = tpl_report.updated;
|
||||
report.errors.extend(tpl_report.errors);
|
||||
|
||||
// 5. Rebuild scripts (0.85 .. 0.95)
|
||||
progress(0.85, RebuildProgress::RebuildingScripts);
|
||||
let script_report =
|
||||
script_rebuild::rebuild_scripts_from_filesystem(conn, data_dir, project_id)?;
|
||||
progress(0.85, RebuildProgress::RebuildingScripts)?;
|
||||
let script_progress = on_progress.as_ref().map(|callback| {
|
||||
let callback = Arc::clone(callback);
|
||||
Box::new(move |current: usize, total: usize, name: &str| {
|
||||
callback(
|
||||
0.85 + current as f32 / total.max(1) as f32 * 0.10,
|
||||
&RebuildProgress::ScriptItem {
|
||||
current,
|
||||
total,
|
||||
name: name.to_string(),
|
||||
},
|
||||
)
|
||||
}) as script_rebuild::ItemProgressFn
|
||||
});
|
||||
let script_report = script_rebuild::rebuild_scripts_from_filesystem_with_progress(
|
||||
conn,
|
||||
data_dir,
|
||||
project_id,
|
||||
script_progress,
|
||||
)?;
|
||||
report.scripts_created = script_report.created;
|
||||
report.scripts_updated = script_report.updated;
|
||||
report.errors.extend(script_report.errors);
|
||||
|
||||
// 6. Restore relationships and tags (0.95 .. 1.0)
|
||||
progress(0.95, RebuildProgress::ImportingTags);
|
||||
progress(0.95, RebuildProgress::ImportingTags)?;
|
||||
super::tag::import_tags_from_file(conn, data_dir, project_id)?;
|
||||
super::tag::sync_tags_from_posts(conn, project_id)?;
|
||||
post::rebuild_all_links(conn, data_dir, project_id)?;
|
||||
let link_progress = on_progress.as_ref().map(|callback| {
|
||||
let callback = Arc::clone(callback);
|
||||
Box::new(move |current: usize, total: usize, name: &str| {
|
||||
callback(
|
||||
0.95 + current as f32 / total.max(1) as f32 * 0.01,
|
||||
&RebuildProgress::PostItem {
|
||||
current,
|
||||
total,
|
||||
name: name.to_string(),
|
||||
},
|
||||
)
|
||||
}) as post::ItemProgressFn
|
||||
});
|
||||
post::rebuild_all_links_with_progress(conn, data_dir, project_id, link_progress)?;
|
||||
let media_link_progress = on_progress.as_ref().map(|callback| {
|
||||
let callback = Arc::clone(callback);
|
||||
Box::new(move |current: usize, total: usize, name: &str| {
|
||||
callback(
|
||||
0.96 + current as f32 / total.max(1) as f32 * 0.01,
|
||||
&RebuildProgress::MediaItem {
|
||||
current,
|
||||
total,
|
||||
name: name.to_string(),
|
||||
},
|
||||
)
|
||||
}) as media::ItemProgressFn
|
||||
});
|
||||
media::rebuild_media_links_with_progress(conn, data_dir, project_id, media_link_progress)?;
|
||||
|
||||
if !report.errors.is_empty() {
|
||||
return Err(crate::engine::EngineError::Validation(format!(
|
||||
@@ -290,11 +378,39 @@ fn rebuild_from_filesystem_inner(
|
||||
)));
|
||||
}
|
||||
|
||||
progress(0.98, RebuildProgress::RefreshingSemanticIndex);
|
||||
crate::engine::embedding::EmbeddingService::production(conn, data_dir)
|
||||
.index_unindexed(project_id)?;
|
||||
progress(0.97, RebuildProgress::RebuildingThumbnails)?;
|
||||
let thumbnail_report = media::regenerate_missing_thumbnails_with_progress(
|
||||
conn,
|
||||
data_dir,
|
||||
project_id,
|
||||
|current, total, name| {
|
||||
on_progress.as_ref().is_none_or(|callback| {
|
||||
callback(
|
||||
0.97 + current as f32 / total.max(1) as f32 * 0.02,
|
||||
&RebuildProgress::MediaItem {
|
||||
current,
|
||||
total,
|
||||
name: name.to_string(),
|
||||
},
|
||||
)
|
||||
})
|
||||
},
|
||||
)?;
|
||||
report.thumbnails_generated = thumbnail_report.thumbnails_generated;
|
||||
report.thumbnail_media_failed = thumbnail_report.media_failed;
|
||||
|
||||
progress(1.0, RebuildProgress::Complete);
|
||||
progress(0.99, RebuildProgress::RefreshingSemanticIndex)?;
|
||||
crate::engine::embedding::EmbeddingService::production(conn, data_dir)
|
||||
.index_unindexed_with_progress(project_id, |current, total| {
|
||||
on_progress.as_ref().is_none_or(|callback| {
|
||||
callback(
|
||||
0.99 + current as f32 / total.max(1) as f32 * 0.01,
|
||||
&RebuildProgress::RefreshingSemanticIndex,
|
||||
)
|
||||
})
|
||||
})?;
|
||||
|
||||
progress(1.0, RebuildProgress::Complete)?;
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
@@ -445,6 +561,36 @@ mod tests {
|
||||
assert!(report.errors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_rolls_back_the_full_rebuild_savepoint() {
|
||||
let (db, dir) = setup();
|
||||
let post = crate::engine::post::create_post(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
"Preserved",
|
||||
Some("body"),
|
||||
vec![],
|
||||
vec![],
|
||||
None,
|
||||
Some("en"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = rebuild_from_filesystem_with_progress(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
"p1",
|
||||
Some(Arc::new(|_, event| {
|
||||
!matches!(event, RebuildProgress::ScanningPosts)
|
||||
})),
|
||||
);
|
||||
|
||||
assert!(matches!(result, Err(crate::engine::EngineError::Cancelled)));
|
||||
assert!(qp::get_post_by_id(db.conn(), &post.id).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_creates_posts_and_media() {
|
||||
let (db, dir) = setup();
|
||||
|
||||
@@ -17,6 +17,8 @@ pub struct ScriptRebuildReport {
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) -> bool + Send>;
|
||||
|
||||
/// Rebuild scripts from the filesystem into the database.
|
||||
///
|
||||
/// Walks the `scripts/` directory for `*.lua` files, parses each
|
||||
@@ -26,6 +28,15 @@ pub fn rebuild_scripts_from_filesystem(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
) -> EngineResult<ScriptRebuildReport> {
|
||||
rebuild_scripts_from_filesystem_with_progress(conn, data_dir, project_id, None)
|
||||
}
|
||||
|
||||
pub fn rebuild_scripts_from_filesystem_with_progress(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
on_item: Option<ItemProgressFn>,
|
||||
) -> EngineResult<ScriptRebuildReport> {
|
||||
let mut report = ScriptRebuildReport::default();
|
||||
let scripts_dir = data_dir.join("scripts");
|
||||
@@ -34,17 +45,23 @@ pub fn rebuild_scripts_from_filesystem(
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
for entry in WalkDir::new(&scripts_dir)
|
||||
let files = WalkDir::new(&scripts_dir)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
.filter(|entry| entry.path().is_file())
|
||||
.filter(|entry| entry.path().extension().and_then(|ext| ext.to_str()) == Some("lua"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for (index, entry) in files.iter().enumerate() {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let ext = path.extension().and_then(|e| e.to_str());
|
||||
if ext != Some("lua") {
|
||||
continue;
|
||||
let name = path
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.unwrap_or("?");
|
||||
if let Some(ref callback) = on_item
|
||||
&& !callback(index + 1, files.len(), name)
|
||||
{
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
|
||||
match rebuild_single_script(conn, data_dir, project_id, path) {
|
||||
|
||||
@@ -67,7 +67,7 @@ pub struct ReindexReport {
|
||||
}
|
||||
|
||||
/// Per-item progress callback: (current_item, total_items, item_description).
|
||||
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) + Send>;
|
||||
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) -> bool + Send>;
|
||||
|
||||
/// Repair a missing or previously deployed FTS schema and report whether its
|
||||
/// derived content still needs to be rebuilt.
|
||||
@@ -215,8 +215,10 @@ fn index_project(
|
||||
|
||||
for post in post_q::list_posts_by_project(conn, project_id)? {
|
||||
*current += 1;
|
||||
if let Some(callback) = on_item {
|
||||
callback(*current, total, &post.title);
|
||||
if let Some(callback) = on_item
|
||||
&& !callback(*current, total, &post.title)
|
||||
{
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
let translations = post_translation::list_post_translations_by_post(conn, &post.id)?;
|
||||
let translation_data = translations
|
||||
@@ -255,8 +257,10 @@ fn index_project(
|
||||
|
||||
for media in media_q::list_media_by_project(conn, project_id)? {
|
||||
*current += 1;
|
||||
if let Some(callback) = on_item {
|
||||
callback(*current, total, &media.original_name);
|
||||
if let Some(callback) = on_item
|
||||
&& !callback(*current, total, &media.original_name)
|
||||
{
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
let translations = media_translation::list_media_translations_by_media(conn, &media.id)?;
|
||||
let translation_data = translations
|
||||
@@ -374,6 +378,29 @@ mod tests {
|
||||
assert_eq!(results.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_rolls_back_shared_index_rebuild() {
|
||||
let (db, project_id) = setup();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
engine::post::create_post(
|
||||
db.conn(),
|
||||
dir.path(),
|
||||
&project_id,
|
||||
"Cancel Me",
|
||||
Some("body"),
|
||||
vec![],
|
||||
vec![],
|
||||
None,
|
||||
Some("en"),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = rebuild_search_index(db.conn(), Some(Box::new(|_, _, _| false)));
|
||||
|
||||
assert!(matches!(result, Err(EngineError::Cancelled)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_indexes_published_post_and_translation_bodies_from_files() {
|
||||
let db = Database::open_in_memory().unwrap();
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use tokio::sync::Notify;
|
||||
|
||||
/// Unique task identifier.
|
||||
pub type TaskId = u64;
|
||||
|
||||
@@ -25,6 +28,7 @@ pub struct TaskSnapshot {
|
||||
pub status: TaskStatus,
|
||||
pub progress: Option<f32>,
|
||||
pub message: Option<String>,
|
||||
pub cancellation_requested: bool,
|
||||
pub created_at: Instant,
|
||||
}
|
||||
|
||||
@@ -42,228 +46,340 @@ struct TaskEntry {
|
||||
created_at: Instant,
|
||||
finished_at: Option<Instant>,
|
||||
last_progress_report: Option<Instant>,
|
||||
worker_active: bool,
|
||||
worker_started: bool,
|
||||
}
|
||||
|
||||
/// Manages concurrent tasks with a max concurrency limit and FIFO queue.
|
||||
pub struct TaskManager {
|
||||
max_concurrent: usize,
|
||||
next_id: Mutex<TaskId>,
|
||||
tasks: Mutex<Vec<TaskEntry>>,
|
||||
state: Mutex<TaskState>,
|
||||
state_changed: Condvar,
|
||||
async_changed: Notify,
|
||||
}
|
||||
|
||||
struct TaskState {
|
||||
next_id: TaskId,
|
||||
tasks: HashMap<TaskId, TaskEntry>,
|
||||
order: VecDeque<TaskId>,
|
||||
pending: VecDeque<TaskId>,
|
||||
worker_count: usize,
|
||||
}
|
||||
|
||||
/// Capacity reservation held from asynchronous admission until the worker exits.
|
||||
pub struct TaskWorker {
|
||||
manager: Arc<TaskManager>,
|
||||
task_id: TaskId,
|
||||
}
|
||||
|
||||
impl Drop for TaskWorker {
|
||||
fn drop(&mut self) {
|
||||
self.manager.worker_exited(self.task_id);
|
||||
}
|
||||
}
|
||||
|
||||
impl TaskManager {
|
||||
/// Create a new task manager with the given concurrency limit.
|
||||
pub fn new(max_concurrent: usize) -> Self {
|
||||
Self {
|
||||
max_concurrent,
|
||||
next_id: Mutex::new(1),
|
||||
tasks: Mutex::new(Vec::new()),
|
||||
max_concurrent: max_concurrent.max(1),
|
||||
state: Mutex::new(TaskState {
|
||||
next_id: 1,
|
||||
tasks: HashMap::new(),
|
||||
order: VecDeque::new(),
|
||||
pending: VecDeque::new(),
|
||||
worker_count: 0,
|
||||
}),
|
||||
state_changed: Condvar::new(),
|
||||
async_changed: Notify::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Submit a new task. Returns its unique identifier.
|
||||
pub fn submit(&self, label: &str) -> TaskId {
|
||||
let mut next = self.next_id.lock().unwrap();
|
||||
let id = *next;
|
||||
*next += 1;
|
||||
self.submit_with_group(label, None, None)
|
||||
}
|
||||
|
||||
let entry = TaskEntry {
|
||||
fn submit_with_group(
|
||||
&self,
|
||||
label: &str,
|
||||
group_id: Option<&str>,
|
||||
group_name: Option<&str>,
|
||||
) -> TaskId {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
Self::prune_expired(&mut state);
|
||||
let id = state.next_id;
|
||||
state.next_id += 1;
|
||||
state.tasks.insert(
|
||||
id,
|
||||
label: label.to_owned(),
|
||||
group_id: None,
|
||||
group_name: None,
|
||||
status: TaskStatus::Pending,
|
||||
cancel_flag: Arc::new(AtomicBool::new(false)),
|
||||
progress: None,
|
||||
message: None,
|
||||
created_at: Instant::now(),
|
||||
finished_at: None,
|
||||
last_progress_report: None,
|
||||
};
|
||||
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
tasks.push(entry);
|
||||
// Auto-start if under capacity
|
||||
let running = tasks
|
||||
.iter()
|
||||
.filter(|t| t.status == TaskStatus::Running)
|
||||
.count();
|
||||
if running < self.max_concurrent
|
||||
&& let Some(t) = tasks
|
||||
.iter_mut()
|
||||
.find(|t| t.id == id && t.status == TaskStatus::Pending)
|
||||
{
|
||||
t.status = TaskStatus::Running;
|
||||
}
|
||||
TaskEntry {
|
||||
id,
|
||||
label: label.to_owned(),
|
||||
group_id: group_id.map(str::to_owned),
|
||||
group_name: group_name.map(str::to_owned),
|
||||
status: TaskStatus::Pending,
|
||||
cancel_flag: Arc::new(AtomicBool::new(false)),
|
||||
progress: None,
|
||||
message: None,
|
||||
created_at: Instant::now(),
|
||||
finished_at: None,
|
||||
last_progress_report: None,
|
||||
worker_active: false,
|
||||
worker_started: false,
|
||||
},
|
||||
);
|
||||
state.order.push_back(id);
|
||||
state.pending.push_back(id);
|
||||
Self::promote_next(&mut state, self.max_concurrent);
|
||||
drop(state);
|
||||
self.notify_changed();
|
||||
id
|
||||
}
|
||||
|
||||
/// Submit a new task within a group. Returns its unique identifier.
|
||||
pub fn submit_grouped(&self, label: &str, group_id: &str, group_name: &str) -> TaskId {
|
||||
let id = self.submit(label);
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == id) {
|
||||
entry.group_id = Some(group_id.to_owned());
|
||||
entry.group_name = Some(group_name.to_owned());
|
||||
}
|
||||
id
|
||||
self.submit_with_group(label, Some(group_id), Some(group_name))
|
||||
}
|
||||
|
||||
/// Block a worker until its task may run. Returns false if cancelled.
|
||||
/// Wait synchronously for admission. Prefer [`Self::admit`] before spawning workers.
|
||||
pub fn wait_until_runnable(&self, task_id: TaskId) -> bool {
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
let mut state = self.state.lock().unwrap();
|
||||
loop {
|
||||
match tasks
|
||||
.iter()
|
||||
.find(|task| task.id == task_id)
|
||||
.map(|task| &task.status)
|
||||
{
|
||||
Some(TaskStatus::Running) => return true,
|
||||
Some(TaskStatus::Pending) => tasks = self.state_changed.wait(tasks).unwrap(),
|
||||
match state.tasks.get(&task_id).map(|task| &task.status) {
|
||||
Some(TaskStatus::Running) => {
|
||||
if let Some(task) = state.tasks.get_mut(&task_id) {
|
||||
task.worker_started = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
Some(TaskStatus::Pending) => state = self.state_changed.wait(state).unwrap(),
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Admit without occupying a blocking-pool thread while queued.
|
||||
pub async fn admit(self: &Arc<Self>, task_id: TaskId) -> Option<TaskWorker> {
|
||||
loop {
|
||||
let notified = self.async_changed.notified();
|
||||
{
|
||||
let mut state = self.state.lock().unwrap();
|
||||
match state.tasks.get_mut(&task_id) {
|
||||
Some(task) if task.status == TaskStatus::Running => {
|
||||
task.worker_started = true;
|
||||
return Some(TaskWorker {
|
||||
manager: Arc::clone(self),
|
||||
task_id,
|
||||
});
|
||||
}
|
||||
Some(task) if task.status == TaskStatus::Pending => {}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
notified.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Admit a non-Tokio worker while retaining capacity until its guard drops.
|
||||
pub fn admit_blocking(self: &Arc<Self>, task_id: TaskId) -> Option<TaskWorker> {
|
||||
if self.wait_until_runnable(task_id) {
|
||||
Some(TaskWorker {
|
||||
manager: Arc::clone(self),
|
||||
task_id,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark a task as completed.
|
||||
pub fn complete(&self, task_id: TaskId) {
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
|
||||
&& matches!(entry.status, TaskStatus::Running)
|
||||
{
|
||||
entry.status = TaskStatus::Completed;
|
||||
entry.progress = Some(1.0);
|
||||
entry.finished_at = Some(Instant::now());
|
||||
}
|
||||
Self::promote_next(&mut tasks, self.max_concurrent);
|
||||
self.state_changed.notify_all();
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let released = if let Some(entry) = state.tasks.get_mut(&task_id) {
|
||||
if matches!(entry.status, TaskStatus::Running) {
|
||||
entry.status = TaskStatus::Completed;
|
||||
entry.progress = Some(1.0);
|
||||
entry.finished_at = Some(Instant::now());
|
||||
}
|
||||
let released = entry.worker_active;
|
||||
entry.worker_active = false;
|
||||
entry.worker_started = false;
|
||||
released
|
||||
} else {
|
||||
false
|
||||
};
|
||||
state.worker_count = state.worker_count.saturating_sub(usize::from(released));
|
||||
Self::promote_next(&mut state, self.max_concurrent);
|
||||
drop(state);
|
||||
self.notify_changed();
|
||||
}
|
||||
|
||||
/// Mark a task as failed with an error message.
|
||||
pub fn fail(&self, task_id: TaskId, error: String) {
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
|
||||
&& matches!(entry.status, TaskStatus::Running)
|
||||
{
|
||||
entry.message = Some(error.clone());
|
||||
entry.status = TaskStatus::Failed(error);
|
||||
entry.finished_at = Some(Instant::now());
|
||||
}
|
||||
Self::promote_next(&mut tasks, self.max_concurrent);
|
||||
self.state_changed.notify_all();
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let released = if let Some(entry) = state.tasks.get_mut(&task_id) {
|
||||
if matches!(entry.status, TaskStatus::Running) {
|
||||
if entry.cancel_flag.load(Ordering::Acquire) {
|
||||
entry.status = TaskStatus::Cancelled;
|
||||
} else {
|
||||
entry.message = Some(error.clone());
|
||||
entry.status = TaskStatus::Failed(error);
|
||||
}
|
||||
entry.finished_at = Some(Instant::now());
|
||||
}
|
||||
let released = entry.worker_active;
|
||||
entry.worker_active = false;
|
||||
entry.worker_started = false;
|
||||
released
|
||||
} else {
|
||||
false
|
||||
};
|
||||
state.worker_count = state.worker_count.saturating_sub(usize::from(released));
|
||||
Self::promote_next(&mut state, self.max_concurrent);
|
||||
drop(state);
|
||||
self.notify_changed();
|
||||
}
|
||||
|
||||
/// Cancel a task by setting its cancel flag and status.
|
||||
pub fn cancel(&self, task_id: TaskId) {
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
|
||||
/// Cancel queued work immediately, or request a cooperative stop from a worker.
|
||||
pub fn cancel(&self, task_id: TaskId) -> bool {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let mut cancelled = false;
|
||||
let mut released = false;
|
||||
if let Some(entry) = state.tasks.get_mut(&task_id)
|
||||
&& matches!(entry.status, TaskStatus::Running | TaskStatus::Pending)
|
||||
{
|
||||
entry.cancel_flag.store(true, Ordering::Release);
|
||||
entry.status = TaskStatus::Cancelled;
|
||||
entry.finished_at = Some(Instant::now());
|
||||
if !entry.worker_started {
|
||||
if entry.worker_active {
|
||||
entry.worker_active = false;
|
||||
released = true;
|
||||
}
|
||||
entry.status = TaskStatus::Cancelled;
|
||||
entry.finished_at = Some(Instant::now());
|
||||
}
|
||||
cancelled = true;
|
||||
}
|
||||
Self::promote_next(&mut tasks, self.max_concurrent);
|
||||
self.state_changed.notify_all();
|
||||
state.worker_count = state.worker_count.saturating_sub(usize::from(released));
|
||||
Self::promote_next(&mut state, self.max_concurrent);
|
||||
drop(state);
|
||||
self.notify_changed();
|
||||
cancelled
|
||||
}
|
||||
|
||||
/// Cancel every active task in a group and release their workers.
|
||||
pub fn cancel_group(&self, group_id: &str) {
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let now = Instant::now();
|
||||
for entry in tasks.iter_mut().filter(|task| {
|
||||
task.group_id.as_deref() == Some(group_id)
|
||||
&& matches!(task.status, TaskStatus::Running | TaskStatus::Pending)
|
||||
}) {
|
||||
let group_ids = state
|
||||
.tasks
|
||||
.values()
|
||||
.filter(|task| {
|
||||
task.group_id.as_deref() == Some(group_id)
|
||||
&& matches!(task.status, TaskStatus::Running | TaskStatus::Pending)
|
||||
})
|
||||
.map(|task| task.id)
|
||||
.collect::<Vec<_>>();
|
||||
let mut released = 0;
|
||||
for id in group_ids {
|
||||
let entry = state.tasks.get_mut(&id).unwrap();
|
||||
entry.cancel_flag.store(true, Ordering::Release);
|
||||
entry.status = TaskStatus::Cancelled;
|
||||
entry.finished_at = Some(now);
|
||||
if !entry.worker_started {
|
||||
if entry.worker_active {
|
||||
entry.worker_active = false;
|
||||
released += 1;
|
||||
}
|
||||
entry.status = TaskStatus::Cancelled;
|
||||
entry.finished_at = Some(now);
|
||||
}
|
||||
}
|
||||
Self::promote_next(&mut tasks, self.max_concurrent);
|
||||
self.state_changed.notify_all();
|
||||
state.worker_count = state.worker_count.saturating_sub(released);
|
||||
Self::promote_next(&mut state, self.max_concurrent);
|
||||
drop(state);
|
||||
self.notify_changed();
|
||||
}
|
||||
|
||||
/// Return the group containing a task, if any.
|
||||
pub fn group_id(&self, task_id: TaskId) -> Option<String> {
|
||||
self.tasks
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|task| task.id == task_id)
|
||||
let mut state = self.state.lock().unwrap();
|
||||
Self::prune_expired(&mut state);
|
||||
state
|
||||
.tasks
|
||||
.get(&task_id)
|
||||
.and_then(|task| task.group_id.clone())
|
||||
}
|
||||
|
||||
/// Check whether a task has been cancelled.
|
||||
pub fn is_cancelled(&self, task_id: TaskId) -> bool {
|
||||
let tasks = self.tasks.lock().unwrap();
|
||||
tasks
|
||||
.iter()
|
||||
.find(|t| t.id == task_id)
|
||||
let mut state = self.state.lock().unwrap();
|
||||
Self::prune_expired(&mut state);
|
||||
state
|
||||
.tasks
|
||||
.get(&task_id)
|
||||
.map(|t| t.cancel_flag.load(Ordering::Acquire))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Shared cancellation flag for a worker owned by this task.
|
||||
pub fn cancellation_flag(&self, task_id: TaskId) -> Option<Arc<AtomicBool>> {
|
||||
self.tasks
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|task| task.id == task_id)
|
||||
let mut state = self.state.lock().unwrap();
|
||||
Self::prune_expired(&mut state);
|
||||
state
|
||||
.tasks
|
||||
.get(&task_id)
|
||||
.map(|task| Arc::clone(&task.cancel_flag))
|
||||
}
|
||||
|
||||
/// Return the current status of a task.
|
||||
pub fn status(&self, task_id: TaskId) -> Option<TaskStatus> {
|
||||
let tasks = self.tasks.lock().unwrap();
|
||||
tasks
|
||||
.iter()
|
||||
.find(|t| t.id == task_id)
|
||||
.map(|t| t.status.clone())
|
||||
self.get(task_id).map(|task| task.status)
|
||||
}
|
||||
|
||||
/// Count tasks that are still queued.
|
||||
pub fn pending_count(&self) -> usize {
|
||||
let tasks = self.tasks.lock().unwrap();
|
||||
tasks
|
||||
.iter()
|
||||
let mut state = self.state.lock().unwrap();
|
||||
Self::prune_expired(&mut state);
|
||||
state
|
||||
.tasks
|
||||
.values()
|
||||
.filter(|t| t.status == TaskStatus::Pending)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Count tasks that are currently running.
|
||||
pub fn running_count(&self) -> usize {
|
||||
let tasks = self.tasks.lock().unwrap();
|
||||
tasks
|
||||
.iter()
|
||||
let mut state = self.state.lock().unwrap();
|
||||
Self::prune_expired(&mut state);
|
||||
state
|
||||
.tasks
|
||||
.values()
|
||||
.filter(|t| t.status == TaskStatus::Running)
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Remove finished tasks older than the configured retention period.
|
||||
pub fn evict_expired(&self) {
|
||||
let cutoff = Instant::now() - FINISHED_TASK_TTL;
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
tasks.retain(|task| {
|
||||
task.finished_at
|
||||
.is_none_or(|finished_at| finished_at > cutoff)
|
||||
});
|
||||
let mut state = self.state.lock().unwrap();
|
||||
Self::prune_expired(&mut state);
|
||||
}
|
||||
|
||||
/// Remove every finished task while preserving running and queued work.
|
||||
pub fn clear_completed(&self) {
|
||||
self.tasks
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|task| matches!(task.status, TaskStatus::Pending | TaskStatus::Running));
|
||||
let mut state = self.state.lock().unwrap();
|
||||
Self::remove_where(&mut state, |task| task.status == TaskStatus::Completed);
|
||||
}
|
||||
|
||||
/// Remove all terminal task results while preserving active work.
|
||||
pub fn clear_finished(&self) {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
Self::remove_where(&mut state, |task| {
|
||||
task.finished_at.is_some() && !task.worker_active
|
||||
});
|
||||
}
|
||||
|
||||
/// Update progress for a running task. Throttled to at most once per 250ms.
|
||||
pub fn report_progress(&self, task_id: TaskId, progress: Option<f32>, message: Option<String>) {
|
||||
let mut tasks = self.tasks.lock().unwrap();
|
||||
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id)
|
||||
let mut state = self.state.lock().unwrap();
|
||||
if let Some(entry) = state.tasks.get_mut(&task_id)
|
||||
&& entry.status == TaskStatus::Running
|
||||
{
|
||||
let now = Instant::now();
|
||||
@@ -271,7 +387,7 @@ impl TaskManager {
|
||||
Some(prev) => now.duration_since(prev).as_millis() >= PROGRESS_THROTTLE_MS as u128,
|
||||
None => true,
|
||||
};
|
||||
if should_report {
|
||||
if should_report || progress.is_some_and(|value| value >= 1.0) {
|
||||
entry.progress = progress;
|
||||
entry.message = message;
|
||||
entry.last_progress_report = Some(now);
|
||||
@@ -281,58 +397,157 @@ impl TaskManager {
|
||||
|
||||
/// Return the current progress of a task.
|
||||
pub fn progress(&self, task_id: TaskId) -> Option<f32> {
|
||||
let tasks = self.tasks.lock().unwrap();
|
||||
tasks
|
||||
.iter()
|
||||
.find(|t| t.id == task_id)
|
||||
.and_then(|t| t.progress)
|
||||
self.get(task_id).and_then(|task| task.progress)
|
||||
}
|
||||
|
||||
/// Return a snapshot of all tasks for UI display.
|
||||
/// Return active tasks plus ten recent finished tasks for status surfaces.
|
||||
pub fn snapshots(&self) -> Vec<TaskSnapshot> {
|
||||
let tasks = self.tasks.lock().unwrap();
|
||||
let mut snapshots = tasks
|
||||
.iter()
|
||||
let mut state = self.state.lock().unwrap();
|
||||
Self::prune_expired(&mut state);
|
||||
let mut active = state
|
||||
.tasks
|
||||
.values()
|
||||
.filter(|task| task.finished_at.is_none())
|
||||
.chain(
|
||||
tasks
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|task| task.finished_at.is_some())
|
||||
.take(RECENT_FINISHED_LIMIT),
|
||||
)
|
||||
.map(|task| TaskSnapshot {
|
||||
id: task.id,
|
||||
label: task.label.clone(),
|
||||
group_id: task.group_id.clone(),
|
||||
group_name: task.group_name.clone(),
|
||||
status: task.status.clone(),
|
||||
progress: task.progress,
|
||||
message: task.message.clone(),
|
||||
created_at: task.created_at,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
snapshots.sort_by_key(|snapshot| snapshot.created_at);
|
||||
snapshots
|
||||
active.sort_by_key(|task| (task.status != TaskStatus::Running, task.created_at));
|
||||
let active_groups = active
|
||||
.iter()
|
||||
.filter_map(|task| task.group_id.as_deref())
|
||||
.collect::<HashSet<_>>();
|
||||
let mut finished = state
|
||||
.tasks
|
||||
.values()
|
||||
.filter(|task| task.finished_at.is_some())
|
||||
.collect::<Vec<_>>();
|
||||
finished.sort_by_key(|task| std::cmp::Reverse(task.finished_at));
|
||||
let recent_ids = finished
|
||||
.iter()
|
||||
.take(RECENT_FINISHED_LIMIT)
|
||||
.map(|task| task.id)
|
||||
.collect::<HashSet<_>>();
|
||||
active
|
||||
.into_iter()
|
||||
.chain(finished.into_iter().filter(|task| {
|
||||
recent_ids.contains(&task.id)
|
||||
|| task
|
||||
.group_id
|
||||
.as_deref()
|
||||
.is_some_and(|group| active_groups.contains(group))
|
||||
}))
|
||||
.map(Self::snapshot)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Promote the next queued task to running if capacity allows.
|
||||
fn promote_next(tasks: &mut [TaskEntry], max_concurrent: usize) {
|
||||
while tasks
|
||||
/// Return one retained task by id.
|
||||
pub fn get(&self, task_id: TaskId) -> Option<TaskSnapshot> {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
Self::prune_expired(&mut state);
|
||||
state.tasks.get(&task_id).map(Self::snapshot)
|
||||
}
|
||||
|
||||
/// Return every retained task, newest first.
|
||||
pub fn all(&self) -> Vec<TaskSnapshot> {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
Self::prune_expired(&mut state);
|
||||
state
|
||||
.order
|
||||
.iter()
|
||||
.rev()
|
||||
.filter_map(|id| state.tasks.get(id).map(Self::snapshot))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Return running tasks in start order.
|
||||
pub fn running(&self) -> Vec<TaskSnapshot> {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
Self::prune_expired(&mut state);
|
||||
state
|
||||
.order
|
||||
.iter()
|
||||
.filter_map(|id| state.tasks.get(id))
|
||||
.filter(|task| task.status == TaskStatus::Running)
|
||||
.count()
|
||||
< max_concurrent
|
||||
{
|
||||
let Some(task) = tasks
|
||||
.iter_mut()
|
||||
.find(|task| task.status == TaskStatus::Pending)
|
||||
else {
|
||||
.map(Self::snapshot)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn worker_exited(&self, task_id: TaskId) {
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let released = if let Some(task) = state.tasks.get_mut(&task_id) {
|
||||
if task.cancel_flag.load(Ordering::Acquire) {
|
||||
task.worker_started = false;
|
||||
drop(state);
|
||||
self.notify_changed();
|
||||
return;
|
||||
}
|
||||
let released = task.worker_active;
|
||||
task.worker_active = false;
|
||||
task.worker_started = false;
|
||||
released
|
||||
} else {
|
||||
false
|
||||
};
|
||||
state.worker_count = state.worker_count.saturating_sub(usize::from(released));
|
||||
Self::promote_next(&mut state, self.max_concurrent);
|
||||
drop(state);
|
||||
self.notify_changed();
|
||||
}
|
||||
|
||||
fn promote_next(state: &mut TaskState, max_concurrent: usize) {
|
||||
while state.worker_count < max_concurrent {
|
||||
let Some(id) = state.pending.pop_front() else {
|
||||
break;
|
||||
};
|
||||
let Some(task) = state.tasks.get_mut(&id) else {
|
||||
continue;
|
||||
};
|
||||
if task.status != TaskStatus::Pending {
|
||||
continue;
|
||||
}
|
||||
task.status = TaskStatus::Running;
|
||||
task.worker_active = true;
|
||||
state.worker_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(task: &TaskEntry) -> TaskSnapshot {
|
||||
TaskSnapshot {
|
||||
id: task.id,
|
||||
label: task.label.clone(),
|
||||
group_id: task.group_id.clone(),
|
||||
group_name: task.group_name.clone(),
|
||||
status: task.status.clone(),
|
||||
progress: task.progress,
|
||||
message: task.message.clone(),
|
||||
cancellation_requested: task.cancel_flag.load(Ordering::Acquire),
|
||||
created_at: task.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn prune_expired(state: &mut TaskState) {
|
||||
let cutoff = Instant::now() - FINISHED_TASK_TTL;
|
||||
Self::remove_where(state, |task| {
|
||||
task.finished_at
|
||||
.is_some_and(|finished_at| finished_at <= cutoff)
|
||||
&& !task.worker_active
|
||||
});
|
||||
}
|
||||
|
||||
fn remove_where(state: &mut TaskState, predicate: impl Fn(&TaskEntry) -> bool) {
|
||||
let removed = state
|
||||
.tasks
|
||||
.values()
|
||||
.filter(|task| predicate(task))
|
||||
.map(|task| task.id)
|
||||
.collect::<HashSet<_>>();
|
||||
state.tasks.retain(|id, _| !removed.contains(id));
|
||||
state.order.retain(|id| !removed.contains(id));
|
||||
state.pending.retain(|id| !removed.contains(id));
|
||||
}
|
||||
|
||||
fn notify_changed(&self) {
|
||||
self.state_changed.notify_all();
|
||||
self.async_changed.notify_waiters();
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for TaskManager {
|
||||
@@ -399,7 +614,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_sets_flag() {
|
||||
fn cancelling_unstarted_task_settles_immediately() {
|
||||
let mgr = TaskManager::default();
|
||||
let id = mgr.submit("upload");
|
||||
// Task is auto-started (Running)
|
||||
@@ -409,6 +624,35 @@ mod tests {
|
||||
assert_eq!(mgr.status(id), Some(TaskStatus::Cancelled));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelling_started_task_stays_running_until_worker_stops() {
|
||||
let mgr = Arc::new(TaskManager::new(1));
|
||||
let id = mgr.submit("upload");
|
||||
let worker = mgr.admit(id).await.unwrap();
|
||||
|
||||
mgr.cancel(id);
|
||||
|
||||
let snapshot = mgr.get(id).unwrap();
|
||||
assert_eq!(snapshot.status, TaskStatus::Running);
|
||||
assert!(snapshot.cancellation_requested);
|
||||
drop(worker);
|
||||
mgr.fail(id, "operation cancelled".into());
|
||||
assert_eq!(mgr.status(id), Some(TaskStatus::Cancelled));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn completed_work_wins_a_late_cancellation_request() {
|
||||
let mgr = Arc::new(TaskManager::new(1));
|
||||
let id = mgr.submit("atomic update");
|
||||
let worker = mgr.admit(id).await.unwrap();
|
||||
mgr.cancel(id);
|
||||
drop(worker);
|
||||
|
||||
mgr.complete(id);
|
||||
|
||||
assert_eq!(mgr.status(id), Some(TaskStatus::Completed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_and_fail() {
|
||||
let mgr = TaskManager::default();
|
||||
@@ -444,8 +688,12 @@ mod tests {
|
||||
// After b fails: d promoted to running
|
||||
|
||||
{
|
||||
let mut tasks = mgr.tasks.lock().unwrap();
|
||||
for task in tasks.iter_mut().filter(|task| task.finished_at.is_some()) {
|
||||
let mut state = mgr.state.lock().unwrap();
|
||||
for task in state
|
||||
.tasks
|
||||
.values_mut()
|
||||
.filter(|task| task.finished_at.is_some())
|
||||
{
|
||||
task.finished_at =
|
||||
Some(Instant::now() - FINISHED_TASK_TTL - Duration::from_secs(1));
|
||||
}
|
||||
@@ -469,6 +717,34 @@ mod tests {
|
||||
assert_eq!(mgr.snapshots().len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshots_retain_every_active_task_and_complete_active_groups() {
|
||||
let mgr = TaskManager::new(20);
|
||||
let finished_group_member = mgr.submit_grouped("finished", "group", "Group");
|
||||
mgr.complete(finished_group_member);
|
||||
for index in 0..12 {
|
||||
mgr.submit_grouped(&format!("active {index}"), "group", "Group");
|
||||
}
|
||||
for index in 0..12 {
|
||||
let id = mgr.submit(&format!("history {index}"));
|
||||
mgr.complete(id);
|
||||
}
|
||||
|
||||
let snapshots = mgr.snapshots();
|
||||
assert_eq!(
|
||||
snapshots
|
||||
.iter()
|
||||
.filter(|task| matches!(task.status, TaskStatus::Pending | TaskStatus::Running))
|
||||
.count(),
|
||||
12
|
||||
);
|
||||
assert!(
|
||||
snapshots
|
||||
.iter()
|
||||
.any(|task| task.id == finished_group_member)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_completed_preserves_active_tasks() {
|
||||
let mgr = TaskManager::new(2);
|
||||
@@ -555,4 +831,70 @@ mod tests {
|
||||
assert_eq!(mgr.progress(id), Some(0.5));
|
||||
assert_eq!(mgr.snapshots()[0].message.as_deref(), Some("halfway"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_holds_capacity_until_worker_exits() {
|
||||
let mgr = Arc::new(TaskManager::new(1));
|
||||
let running = mgr.submit("running");
|
||||
let queued = mgr.submit("queued");
|
||||
let worker = mgr.admit(running).await.unwrap();
|
||||
|
||||
mgr.cancel(running);
|
||||
|
||||
assert_eq!(mgr.status(running), Some(TaskStatus::Running));
|
||||
assert!(mgr.get(running).unwrap().cancellation_requested);
|
||||
assert_eq!(mgr.status(queued), Some(TaskStatus::Pending));
|
||||
drop(worker);
|
||||
assert_eq!(mgr.status(queued), Some(TaskStatus::Pending));
|
||||
mgr.fail(running, "operation cancelled".into());
|
||||
assert_eq!(mgr.status(queued), Some(TaskStatus::Running));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn panicking_worker_guard_releases_capacity() {
|
||||
let mgr = Arc::new(TaskManager::new(1));
|
||||
let panicking = mgr.submit("panicking");
|
||||
let queued = mgr.submit("queued");
|
||||
let worker = mgr.admit(panicking).await.unwrap();
|
||||
|
||||
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _worker = worker;
|
||||
panic!("boom");
|
||||
}));
|
||||
|
||||
assert_eq!(mgr.status(queued), Some(TaskStatus::Running));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_progress_bypasses_throttle() {
|
||||
let mgr = TaskManager::new(1);
|
||||
let id = mgr.submit("work");
|
||||
mgr.report_progress(id, Some(0.5), Some("working".into()));
|
||||
mgr.report_progress(id, Some(1.0), Some("done".into()));
|
||||
|
||||
let task = mgr
|
||||
.snapshots()
|
||||
.into_iter()
|
||||
.find(|task| task.id == id)
|
||||
.unwrap();
|
||||
assert_eq!(task.progress, Some(1.0));
|
||||
assert_eq!(task.message.as_deref(), Some("done"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_completed_keeps_other_terminal_results() {
|
||||
let mgr = TaskManager::new(3);
|
||||
let completed = mgr.submit("completed");
|
||||
let failed = mgr.submit("failed");
|
||||
let cancelled = mgr.submit("cancelled");
|
||||
mgr.complete(completed);
|
||||
mgr.fail(failed, "failed".into());
|
||||
mgr.cancel(cancelled);
|
||||
|
||||
mgr.clear_completed();
|
||||
|
||||
assert_eq!(mgr.status(completed), None);
|
||||
assert!(matches!(mgr.status(failed), Some(TaskStatus::Failed(_))));
|
||||
assert_eq!(mgr.status(cancelled), Some(TaskStatus::Cancelled));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ pub struct TemplateRebuildReport {
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) -> bool + Send>;
|
||||
|
||||
/// Rebuild templates from the filesystem into the database.
|
||||
///
|
||||
/// Walks the `templates/` directory for `*.liquid` files, parses each via
|
||||
@@ -26,6 +28,15 @@ pub fn rebuild_templates_from_filesystem(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
) -> EngineResult<TemplateRebuildReport> {
|
||||
rebuild_templates_from_filesystem_with_progress(conn, data_dir, project_id, None)
|
||||
}
|
||||
|
||||
pub fn rebuild_templates_from_filesystem_with_progress(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
on_item: Option<ItemProgressFn>,
|
||||
) -> EngineResult<TemplateRebuildReport> {
|
||||
let mut report = TemplateRebuildReport::default();
|
||||
let templates_dir = data_dir.join("templates");
|
||||
@@ -34,17 +45,23 @@ pub fn rebuild_templates_from_filesystem(
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
for entry in WalkDir::new(&templates_dir)
|
||||
let files = WalkDir::new(&templates_dir)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
.filter(|entry| entry.path().is_file())
|
||||
.filter(|entry| entry.path().extension().and_then(|ext| ext.to_str()) == Some("liquid"))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for (index, entry) in files.iter().enumerate() {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let ext = path.extension().and_then(|e| e.to_str());
|
||||
if ext != Some("liquid") {
|
||||
continue;
|
||||
let name = path
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.unwrap_or("?");
|
||||
if let Some(ref callback) = on_item
|
||||
&& !callback(index + 1, files.len(), name)
|
||||
{
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
|
||||
match rebuild_single_template(conn, data_dir, project_id, path) {
|
||||
|
||||
@@ -6,8 +6,8 @@ use crate::db::DbConnection as Connection;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::db::queries;
|
||||
use crate::engine::EngineResult;
|
||||
use crate::engine::generation::has_published_snapshot;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::Post;
|
||||
use crate::render::{build_canonical_post_path, build_site_route_manifest};
|
||||
|
||||
@@ -25,11 +25,27 @@ pub fn validate_site(
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
) -> EngineResult<SiteValidationReport> {
|
||||
validate_site_with_progress(conn, data_dir, project_id, |_, _| true)
|
||||
}
|
||||
|
||||
pub fn validate_site_with_progress(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
mut on_progress: impl FnMut(usize, usize) -> bool,
|
||||
) -> EngineResult<SiteValidationReport> {
|
||||
const PHASES: usize = 4;
|
||||
if !on_progress(0, PHASES) {
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
let metadata = crate::engine::meta::read_project_json(data_dir)?;
|
||||
let output_dir = generated_output_dir(data_dir);
|
||||
let published_posts = load_published_posts(conn, project_id)?;
|
||||
let route_manifest = build_site_route_manifest(data_dir, &metadata, &published_posts)
|
||||
.map_err(|error| crate::engine::EngineError::Parse(error.to_string()))?;
|
||||
if !on_progress(1, PHASES) {
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
crate::engine::generation::refresh_validation_sitemap(
|
||||
conn,
|
||||
&output_dir,
|
||||
@@ -43,6 +59,9 @@ pub fn validate_site(
|
||||
.into_iter()
|
||||
.map(|page| page.relative_path)
|
||||
.collect::<HashSet<_>>();
|
||||
if !on_progress(2, PHASES) {
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
|
||||
let mut actual = HashSet::new();
|
||||
let mut zero_byte = HashSet::new();
|
||||
@@ -67,6 +86,9 @@ pub fn validate_site(
|
||||
.into_iter()
|
||||
.map(|file| (file.relative_path, file.updated_at))
|
||||
.collect::<HashMap<_, _>>();
|
||||
if !on_progress(3, PHASES) {
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
let mut stale_pages = stale_post_paths(
|
||||
data_dir,
|
||||
&output_dir,
|
||||
@@ -83,6 +105,10 @@ pub fn validate_site(
|
||||
stale_pages.sort();
|
||||
stale_pages.dedup();
|
||||
|
||||
if !on_progress(PHASES, PHASES) {
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
|
||||
Ok(SiteValidationReport {
|
||||
missing_pages,
|
||||
extra_pages,
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::path::Path;
|
||||
use crate::db::DbConnection as Connection;
|
||||
|
||||
use crate::db::queries::{post as post_q, post_translation};
|
||||
use crate::engine::EngineResult;
|
||||
use crate::engine::{EngineError, EngineResult};
|
||||
use crate::model::PostStatus;
|
||||
|
||||
/// Normalize a language code for comparison (lowercase, strip region).
|
||||
@@ -53,7 +53,7 @@ pub struct TranslationValidationReport {
|
||||
}
|
||||
|
||||
/// Per-item progress callback: (current_item, total_items, item_description).
|
||||
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) + Send>;
|
||||
pub type ItemProgressFn = Box<dyn Fn(usize, usize, &str) -> bool + Send>;
|
||||
|
||||
/// Validate all translations with optional per-item progress.
|
||||
pub fn validate_translations_with_progress(
|
||||
@@ -75,8 +75,10 @@ pub fn validate_translations_with_progress(
|
||||
let post_count = posts.len();
|
||||
|
||||
for (i, post) in posts.iter().enumerate() {
|
||||
if let Some(ref cb) = on_item {
|
||||
cb(i + 1, post_count, &post.title);
|
||||
if let Some(ref cb) = on_item
|
||||
&& !cb(i + 1, post_count, &post.title)
|
||||
{
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
let translations = post_translation::list_post_translations_by_post(conn, &post.id)?;
|
||||
|
||||
@@ -170,8 +172,10 @@ pub fn validate_translations_with_progress(
|
||||
let path = entry.path();
|
||||
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
|
||||
|
||||
if let Some(ref cb) = on_item {
|
||||
cb(i + 1, fs_total, stem);
|
||||
if let Some(ref cb) = on_item
|
||||
&& !cb(i + 1, fs_total, stem)
|
||||
{
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
|
||||
checked_fs_files += 1;
|
||||
|
||||
@@ -21,6 +21,7 @@ pub use routes::{
|
||||
render_starter_list_page_with_media_map, render_starter_single_post_page,
|
||||
render_starter_single_post_page_with_media_map,
|
||||
};
|
||||
pub(crate) use site::count_site_render_pages_from_context;
|
||||
pub use site::{
|
||||
PagefindDocument, PreviewRenderResult, SitePage, SiteRenderArtifacts, SiteRenderContext,
|
||||
build_preview_response, build_site_render_artifacts, build_site_render_artifacts_from_context,
|
||||
|
||||
@@ -391,12 +391,7 @@ pub fn prepare_site_render_context(
|
||||
let linked_media_by_post_id =
|
||||
build_linked_media_by_post_id(&posts, &linked_media_by_source_post);
|
||||
let post_data_json_by_id = build_post_data_json_by_id(&posts, &linked_media_by_post_id);
|
||||
let menu_items = build_menu_items(
|
||||
data_dir,
|
||||
&language,
|
||||
&main_language,
|
||||
&category_settings,
|
||||
)?;
|
||||
let menu_items = build_menu_items(data_dir, &language, &main_language, &category_settings)?;
|
||||
let canonical_post_path_by_slug =
|
||||
canonical_post_path_by_slug(&posts, &language, &main_language);
|
||||
let taxonomy = build_taxonomy_context(&posts, &tags);
|
||||
@@ -590,6 +585,39 @@ pub fn build_site_render_artifacts_from_context(
|
||||
Ok(artifacts)
|
||||
}
|
||||
|
||||
pub(crate) fn count_site_render_pages_from_context(
|
||||
context: &SiteRenderContext,
|
||||
section: GenerationSection,
|
||||
requested_paths: Option<&HashSet<String>>,
|
||||
) -> usize {
|
||||
context
|
||||
.languages
|
||||
.iter()
|
||||
.map(|language_context| {
|
||||
let expanded = requested_paths.map(|requested| {
|
||||
expand_requested_aggregate_paths(
|
||||
requested,
|
||||
&language_context.posts,
|
||||
&language_context.routes,
|
||||
&language_context.language,
|
||||
&context.metadata,
|
||||
)
|
||||
});
|
||||
language_context
|
||||
.routes
|
||||
.iter()
|
||||
.filter(|route| {
|
||||
classify_generated_path(&route.relative_path, &context.metadata)
|
||||
== Some(section)
|
||||
&& expanded
|
||||
.as_ref()
|
||||
.is_none_or(|requested| requested.contains(&route.relative_path))
|
||||
})
|
||||
.count()
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
pub fn build_preview_response(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use base64::Engine as _;
|
||||
use chrono::{SecondsFormat, TimeZone, Utc};
|
||||
@@ -1215,33 +1216,39 @@ impl CoreHost {
|
||||
"get" => {
|
||||
let id = task_id_arg(args, 0)?;
|
||||
manager
|
||||
.snapshots()
|
||||
.into_iter()
|
||||
.find(|task| task.id == id)
|
||||
.get(id)
|
||||
.map(public_task)
|
||||
.transpose()
|
||||
.map(|value| value.unwrap_or(Value::Null))
|
||||
}
|
||||
"get_all" => public_tasks(manager.snapshots()),
|
||||
"get_running" => public_tasks(
|
||||
manager
|
||||
.snapshots()
|
||||
.into_iter()
|
||||
.filter(|task| task.status == TaskStatus::Running)
|
||||
.collect(),
|
||||
),
|
||||
"get_all" => public_tasks(manager.all()),
|
||||
"get_running" => public_tasks(manager.running()),
|
||||
"status_snapshot" => {
|
||||
let tasks = manager.snapshots();
|
||||
let active = tasks
|
||||
.iter()
|
||||
.filter(|task| matches!(task.status, TaskStatus::Pending | TaskStatus::Running))
|
||||
.collect::<Vec<_>>();
|
||||
let running_task_message = active.first().map(|task| {
|
||||
if task.status == TaskStatus::Pending {
|
||||
format!("Queued: {}", task.label)
|
||||
} else if let Some(message) =
|
||||
task.message.as_deref().filter(|value| !value.is_empty())
|
||||
{
|
||||
format!("{}: {message}", task.label)
|
||||
} else {
|
||||
task.label.clone()
|
||||
}
|
||||
});
|
||||
Ok(json!({
|
||||
"active_count": tasks.iter().filter(|task| matches!(task.status, TaskStatus::Pending | TaskStatus::Running)).count(),
|
||||
"active_count": active.len(),
|
||||
"running_count": manager.running_count(), "pending_count": manager.pending_count(),
|
||||
"running_task_message": running_task_message,
|
||||
"running_task_overflow": active.len().saturating_sub(1),
|
||||
"tasks": public_tasks(tasks)?,
|
||||
}))
|
||||
}
|
||||
"cancel" => {
|
||||
manager.cancel(task_id_arg(args, 0)?);
|
||||
Ok(Value::Bool(true))
|
||||
}
|
||||
"cancel" => Ok(Value::Bool(manager.cancel(task_id_arg(args, 0)?))),
|
||||
"clear_completed" => {
|
||||
manager.clear_completed();
|
||||
Ok(Value::Bool(true))
|
||||
@@ -1355,10 +1362,17 @@ impl CoreHost {
|
||||
}
|
||||
let manager = self.task_manager.clone();
|
||||
let task_id = self.task_id;
|
||||
engine::publishing::upload_site(
|
||||
let cancel_flag = manager
|
||||
.as_ref()
|
||||
.zip(task_id)
|
||||
.and_then(|(manager, task_id)| manager.cancellation_flag(task_id))
|
||||
.unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
|
||||
let progress_cancel_flag = Arc::clone(&cancel_flag);
|
||||
engine::publishing::upload_site_cancellable(
|
||||
&self.data_dir,
|
||||
&self.private_cache_dir,
|
||||
&preferences,
|
||||
cancel_flag.as_ref(),
|
||||
move |current, total, _| {
|
||||
if let (Some(manager), Some(task_id)) = (&manager, task_id) {
|
||||
manager.report_progress(
|
||||
@@ -1367,6 +1381,7 @@ impl CoreHost {
|
||||
Some("uploading site".into()),
|
||||
);
|
||||
}
|
||||
!progress_cancel_flag.load(Ordering::Acquire)
|
||||
},
|
||||
)?;
|
||||
match (&self.task_manager, self.task_id) {
|
||||
@@ -1659,6 +1674,8 @@ fn public_tag(value: Tag) -> HostResult<Value> {
|
||||
}
|
||||
|
||||
fn public_task(value: TaskSnapshot) -> HostResult<Value> {
|
||||
let cancellable = matches!(value.status, TaskStatus::Pending | TaskStatus::Running)
|
||||
&& !value.cancellation_requested;
|
||||
let (status, error) = match value.status {
|
||||
TaskStatus::Pending => ("pending", None),
|
||||
TaskStatus::Running => ("running", None),
|
||||
@@ -1668,6 +1685,9 @@ fn public_task(value: TaskSnapshot) -> HostResult<Value> {
|
||||
};
|
||||
Ok(
|
||||
json!({"id":value.id.to_string(),"name":value.label,"status":status,
|
||||
"group_id":value.group_id,"group_name":value.group_name,
|
||||
"cancellable":cancellable,
|
||||
"cancellation_requested":value.cancellation_requested,
|
||||
"progress":value.progress,"message":value.message.or(error)}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -395,6 +395,10 @@ fn field_description(field: &str) -> &'static str {
|
||||
"updated" => "Whether the Git network command completed successfully.",
|
||||
"progress" => "Completion value reported by the task.",
|
||||
"message" => "Latest user-facing task message.",
|
||||
"cancellable" => "Whether a cancellation request can still be submitted.",
|
||||
"cancellation_requested" => "Whether cooperative cancellation is in progress.",
|
||||
"group_id" => "Identifier shared by tasks in one workflow.",
|
||||
"group_name" => "Human-readable workflow name.",
|
||||
"created_at" => "Creation timestamp.",
|
||||
"updated_at" => "Last-update timestamp.",
|
||||
"language" | "main_language" => "BCP 47 language code.",
|
||||
|
||||
@@ -408,6 +408,8 @@ impl ProtocolError {
|
||||
}
|
||||
|
||||
fn remote_task(snapshot: TaskSnapshot) -> RemoteTask {
|
||||
let cancellable = matches!(snapshot.status, TaskStatus::Pending | TaskStatus::Running)
|
||||
&& !snapshot.cancellation_requested;
|
||||
let (status, failure) = match snapshot.status {
|
||||
TaskStatus::Pending => ("pending", None),
|
||||
TaskStatus::Running => ("running", None),
|
||||
@@ -418,6 +420,10 @@ fn remote_task(snapshot: TaskSnapshot) -> RemoteTask {
|
||||
RemoteTask {
|
||||
id: snapshot.id,
|
||||
label: snapshot.label,
|
||||
group_id: snapshot.group_id,
|
||||
group_name: snapshot.group_name,
|
||||
cancellation_requested: snapshot.cancellation_requested,
|
||||
cancellable,
|
||||
status: status.to_owned(),
|
||||
progress: snapshot.progress,
|
||||
message: snapshot.message.or(failure),
|
||||
@@ -610,7 +616,11 @@ mod tests {
|
||||
let fixture = Fixture::new();
|
||||
let mut session = fixture.session();
|
||||
let _ = session.pending();
|
||||
let task = fixture.host.tasks().submit("Generate site");
|
||||
let task =
|
||||
fixture
|
||||
.host
|
||||
.tasks()
|
||||
.submit_grouped("Generate site", "generation-1", "Generate Site");
|
||||
fixture
|
||||
.host
|
||||
.tasks()
|
||||
@@ -618,7 +628,11 @@ mod tests {
|
||||
let update = session.pending();
|
||||
assert!(matches!(
|
||||
update.as_slice(),
|
||||
[ServerMessage::Tasks { tasks, .. }] if tasks[0].progress == Some(0.5)
|
||||
[ServerMessage::Tasks { tasks, .. }]
|
||||
if tasks[0].progress == Some(0.5)
|
||||
&& tasks[0].group_id.as_deref() == Some("generation-1")
|
||||
&& tasks[0].group_name.as_deref() == Some("Generate Site")
|
||||
&& tasks[0].cancellable
|
||||
));
|
||||
assert!(
|
||||
session
|
||||
|
||||
@@ -57,7 +57,12 @@ pub enum ServerMessage {
|
||||
pub struct RemoteTask {
|
||||
pub id: u64,
|
||||
pub label: String,
|
||||
pub group_id: Option<String>,
|
||||
pub group_name: Option<String>,
|
||||
pub status: String,
|
||||
pub progress: Option<f32>,
|
||||
pub message: Option<String>,
|
||||
#[serde(default)]
|
||||
pub cancellation_requested: bool,
|
||||
pub cancellable: bool,
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use bds_core::db::Database;
|
||||
use bds_core::engine::ai::{AiEndpointConfig, AiEndpointKind};
|
||||
use bds_core::engine::task::{TaskId, TaskManager};
|
||||
use bds_core::engine::{self, domain_events};
|
||||
use bds_core::i18n::{UiLocale, normalize_language};
|
||||
use bds_core::model::metadata::ProjectMetadata;
|
||||
@@ -600,7 +602,7 @@ impl TuiApp {
|
||||
fn queue_task(
|
||||
&mut self,
|
||||
label: &str,
|
||||
work: impl FnOnce() -> Result<BackgroundResult> + Send + 'static,
|
||||
work: impl FnOnce(Arc<TaskManager>, TaskId) -> Result<BackgroundResult> + Send + 'static,
|
||||
) {
|
||||
let tasks = self.host.tasks();
|
||||
let task_id = tasks.submit(label);
|
||||
@@ -608,22 +610,32 @@ impl TuiApp {
|
||||
self.started_task_ids.insert(task_id);
|
||||
self.status = self.tr_with("tui.taskRunning", &[("label", label)]);
|
||||
std::thread::spawn(move || {
|
||||
if !tasks.wait_until_runnable(task_id) {
|
||||
let Some(worker) = tasks.admit_blocking(task_id) else {
|
||||
return;
|
||||
}
|
||||
match work() {
|
||||
};
|
||||
let work_tasks = Arc::clone(&tasks);
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
work(work_tasks, task_id)
|
||||
}))
|
||||
.map_err(|_| anyhow!("background task panicked"))
|
||||
.and_then(|result| result);
|
||||
drop(worker);
|
||||
match result {
|
||||
Ok(result) => {
|
||||
tasks.complete(task_id);
|
||||
let _ = sender.send(result);
|
||||
}
|
||||
Err(error) => {
|
||||
let cancelled = tasks.is_cancelled(task_id);
|
||||
let message = error.to_string();
|
||||
tasks.fail(task_id, message.clone());
|
||||
let _ = sender.send(BackgroundResult {
|
||||
status: message,
|
||||
panel: None,
|
||||
reload: false,
|
||||
});
|
||||
if !cancelled {
|
||||
let _ = sender.send(BackgroundResult {
|
||||
status: message,
|
||||
panel: None,
|
||||
reload: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -636,15 +648,19 @@ impl TuiApp {
|
||||
}
|
||||
match task.status {
|
||||
engine::task::TaskStatus::Pending | engine::task::TaskStatus::Running => {
|
||||
self.status = match task.progress {
|
||||
Some(progress) => self.tr_with(
|
||||
"tui.taskProgress",
|
||||
&[
|
||||
("label", &task.label),
|
||||
("percent", &format!("{:.0}", progress * 100.0)),
|
||||
],
|
||||
),
|
||||
None => task.label,
|
||||
self.status = if task.cancellation_requested {
|
||||
self.tr_with("tui.taskCancelling", &[("label", &task.label)])
|
||||
} else {
|
||||
match task.progress {
|
||||
Some(progress) => self.tr_with(
|
||||
"tui.taskProgress",
|
||||
&[
|
||||
("label", &task.label),
|
||||
("percent", &format!("{:.0}", progress * 100.0)),
|
||||
],
|
||||
),
|
||||
None => task.label,
|
||||
}
|
||||
};
|
||||
}
|
||||
engine::task::TaskStatus::Completed if self.completed_task_ids.insert(task.id) => {
|
||||
@@ -1550,7 +1566,10 @@ impl TuiApp {
|
||||
let message = prompt.value;
|
||||
let locale = self.locale;
|
||||
let label = self.tr("tui.taskGitCommit");
|
||||
self.queue_task(&label, move || {
|
||||
self.queue_task(&label, move |tasks, task_id| {
|
||||
if tasks.is_cancelled(task_id) {
|
||||
return Err(anyhow!("operation cancelled"));
|
||||
}
|
||||
let output = engine::git::GitEngine::new(&data_dir)
|
||||
.commit_all(&message)?
|
||||
.output;
|
||||
@@ -2730,10 +2749,12 @@ impl TuiApp {
|
||||
let data_dir = self.data_dir()?.to_owned();
|
||||
let locale = self.locale;
|
||||
let label = self.tr("tui.taskGitPull");
|
||||
self.queue_task(&label, move || {
|
||||
self.queue_task(&label, move |tasks, task_id| {
|
||||
let mut output = Vec::new();
|
||||
let result = engine::git::GitEngine::new(&data_dir)
|
||||
.pull(|| false, |line| output.push(line.text))?;
|
||||
let result = engine::git::GitEngine::new(&data_dir).pull(
|
||||
|| tasks.is_cancelled(task_id),
|
||||
|line| output.push(line.text),
|
||||
)?;
|
||||
output.push(result.output);
|
||||
Ok(BackgroundResult {
|
||||
status: bds_core::i18n::translate_with(
|
||||
@@ -2759,10 +2780,12 @@ impl TuiApp {
|
||||
let data_dir = self.data_dir()?.to_owned();
|
||||
let locale = self.locale;
|
||||
let label = self.tr("tui.taskGitPush");
|
||||
self.queue_task(&label, move || {
|
||||
self.queue_task(&label, move |tasks, task_id| {
|
||||
let mut output = Vec::new();
|
||||
let result = engine::git::GitEngine::new(&data_dir)
|
||||
.push(|| false, |line| output.push(line.text))?;
|
||||
let result = engine::git::GitEngine::new(&data_dir).push(
|
||||
|| tasks.is_cancelled(task_id),
|
||||
|line| output.push(line.text),
|
||||
)?;
|
||||
output.push(result.output);
|
||||
Ok(BackgroundResult {
|
||||
status: bds_core::i18n::translate_with(
|
||||
@@ -2807,13 +2830,26 @@ impl TuiApp {
|
||||
"metadata-diff" => {
|
||||
let locale = self.locale;
|
||||
let label = self.tr("tui.commandMetadataDiff");
|
||||
self.queue_task(&label, move || {
|
||||
self.queue_task(&label, move |tasks, task_id| {
|
||||
let db = Database::open(&database_path)?;
|
||||
let report = engine::metadata_diff::compute_metadata_diff_with_progress(
|
||||
db.conn(),
|
||||
&data_dir,
|
||||
&project_id,
|
||||
|current, total| {
|
||||
tasks.report_progress(
|
||||
task_id,
|
||||
Some(current as f32 / total.max(1) as f32),
|
||||
Some(bds_core::i18n::translate(locale, "tui.commandMetadataDiff")),
|
||||
);
|
||||
!tasks.is_cancelled(task_id)
|
||||
},
|
||||
)?;
|
||||
Ok(BackgroundResult {
|
||||
status: bds_core::i18n::translate(locale, "tui.metadataDiffComplete"),
|
||||
panel: Some(Panel::Report {
|
||||
title: bds_core::i18n::translate(locale, "menu.item.metadataDiff"),
|
||||
body: metadata_report_body(db.conn(), &data_dir, &project_id, locale)?,
|
||||
body: metadata_report_body_from_report(&report, locale),
|
||||
action: ReportAction::MetadataDiff,
|
||||
}),
|
||||
reload: false,
|
||||
@@ -2823,13 +2859,26 @@ impl TuiApp {
|
||||
"validate-site" => {
|
||||
let locale = self.locale;
|
||||
let label = self.tr("tui.commandValidateSite");
|
||||
self.queue_task(&label, move || {
|
||||
self.queue_task(&label, move |tasks, task_id| {
|
||||
let db = Database::open(&database_path)?;
|
||||
let report = engine::validate_site::validate_site_with_progress(
|
||||
db.conn(),
|
||||
&data_dir,
|
||||
&project_id,
|
||||
|current, total| {
|
||||
tasks.report_progress(
|
||||
task_id,
|
||||
Some(current as f32 / total.max(1) as f32),
|
||||
Some(bds_core::i18n::translate(locale, "tui.commandValidateSite")),
|
||||
);
|
||||
!tasks.is_cancelled(task_id)
|
||||
},
|
||||
)?;
|
||||
Ok(BackgroundResult {
|
||||
status: bds_core::i18n::translate(locale, "tui.siteValidationComplete"),
|
||||
panel: Some(Panel::Report {
|
||||
title: bds_core::i18n::translate(locale, "menu.item.validateSite"),
|
||||
body: site_validation_body(db.conn(), &data_dir, &project_id, locale)?,
|
||||
body: site_validation_body_from_report(&report, locale),
|
||||
action: ReportAction::SiteValidation,
|
||||
}),
|
||||
reload: false,
|
||||
@@ -2839,18 +2888,86 @@ impl TuiApp {
|
||||
"force-render" => {
|
||||
let locale = self.locale;
|
||||
let label = self.tr("tui.commandForceRender");
|
||||
self.queue_task(&label, move || {
|
||||
self.queue_task(&label, move |tasks, task_id| {
|
||||
let db = Database::open(&database_path)?;
|
||||
let metadata = engine::meta::read_project_json(&data_dir)?;
|
||||
let posts = published_sources(db.conn(), &data_dir, &project_id)?;
|
||||
let report = engine::generation::generate_starter_site_forced(
|
||||
let posts =
|
||||
bds_core::db::queries::post::list_posts_by_project(db.conn(), &project_id)?;
|
||||
let total_posts = posts.len();
|
||||
let mut sources = Vec::new();
|
||||
for (index, post) in posts.into_iter().enumerate() {
|
||||
tasks.report_progress(
|
||||
task_id,
|
||||
Some((index + 1) as f32 / total_posts.max(1) as f32 * 0.1),
|
||||
Some(post.title.clone()),
|
||||
);
|
||||
if tasks.is_cancelled(task_id) {
|
||||
return Err(anyhow!("operation cancelled"));
|
||||
}
|
||||
if let Some(source) =
|
||||
engine::generation::load_published_post_source(&data_dir, post)?
|
||||
{
|
||||
sources.push(source);
|
||||
}
|
||||
}
|
||||
let prepared = engine::generation::prepare_site_generation(
|
||||
db.conn(),
|
||||
&data_dir.join("html"),
|
||||
&data_dir,
|
||||
&project_id,
|
||||
&metadata,
|
||||
&posts,
|
||||
metadata.main_language.as_deref().unwrap_or("en"),
|
||||
&sources,
|
||||
)?;
|
||||
let output_dir = data_dir.join("html");
|
||||
let mut report = engine::generation::GenerationReport::default();
|
||||
for (index, section) in engine::generation::GenerationSection::ALL
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let progress_tasks = Arc::clone(&tasks);
|
||||
let cancel_tasks = Arc::clone(&tasks);
|
||||
report.append(
|
||||
engine::generation::render_prepared_site_section_with_progress(
|
||||
db.conn(),
|
||||
&output_dir,
|
||||
&project_id,
|
||||
&prepared,
|
||||
section,
|
||||
true,
|
||||
&|_| {},
|
||||
move |current, total, path| {
|
||||
progress_tasks.report_progress(
|
||||
task_id,
|
||||
Some(
|
||||
0.1 + (index as f32
|
||||
+ current as f32 / total.max(1) as f32)
|
||||
/ 6.0
|
||||
* 0.9,
|
||||
),
|
||||
Some(path.to_string()),
|
||||
);
|
||||
},
|
||||
move || cancel_tasks.is_cancelled(task_id),
|
||||
)?,
|
||||
);
|
||||
}
|
||||
let progress_tasks = Arc::clone(&tasks);
|
||||
let cancel_tasks = Arc::clone(&tasks);
|
||||
report.append(
|
||||
engine::generation::build_site_search_index_forced_with_progress(
|
||||
db.conn(),
|
||||
&output_dir,
|
||||
&project_id,
|
||||
&metadata,
|
||||
move |current, total, path| {
|
||||
progress_tasks.report_progress(
|
||||
task_id,
|
||||
Some(0.85 + current as f32 / total.max(1) as f32 * 0.15),
|
||||
Some(path.to_string()),
|
||||
);
|
||||
},
|
||||
move || cancel_tasks.is_cancelled(task_id),
|
||||
)?,
|
||||
);
|
||||
Ok(BackgroundResult {
|
||||
status: bds_core::i18n::translate_with(
|
||||
locale,
|
||||
@@ -2865,12 +2982,21 @@ impl TuiApp {
|
||||
"rebuild-database" => {
|
||||
let locale = self.locale;
|
||||
let label = self.tr("tui.commandRebuildDatabase");
|
||||
self.queue_task(&label, move || {
|
||||
self.queue_task(&label, move |tasks, task_id| {
|
||||
let db = Database::open(&database_path)?;
|
||||
let report = engine::rebuild::rebuild_from_filesystem(
|
||||
let progress_tasks = Arc::clone(&tasks);
|
||||
let report = engine::rebuild::rebuild_from_filesystem_with_progress(
|
||||
db.conn(),
|
||||
&data_dir,
|
||||
&project_id,
|
||||
Some(Arc::new(move |progress, event| {
|
||||
progress_tasks.report_progress(
|
||||
task_id,
|
||||
Some(progress),
|
||||
Some(event.localized(locale)),
|
||||
);
|
||||
!progress_tasks.is_cancelled(task_id)
|
||||
})),
|
||||
)?;
|
||||
let count = report.posts_created
|
||||
+ report.posts_updated
|
||||
@@ -2894,9 +3020,21 @@ impl TuiApp {
|
||||
"reindex-search" => {
|
||||
let locale = self.locale;
|
||||
let label = self.tr("tui.commandReindexSearch");
|
||||
self.queue_task(&label, move || {
|
||||
self.queue_task(&label, move |tasks, task_id| {
|
||||
let db = Database::open(&database_path)?;
|
||||
let report = engine::search::reindex_project(db.conn(), &project_id, None)?;
|
||||
let progress_tasks = Arc::clone(&tasks);
|
||||
let report = engine::search::reindex_project(
|
||||
db.conn(),
|
||||
&project_id,
|
||||
Some(Box::new(move |current, total, name| {
|
||||
progress_tasks.report_progress(
|
||||
task_id,
|
||||
Some(current as f32 / total.max(1) as f32),
|
||||
Some(name.to_string()),
|
||||
);
|
||||
!progress_tasks.is_cancelled(task_id)
|
||||
})),
|
||||
)?;
|
||||
Ok(BackgroundResult {
|
||||
status: bds_core::i18n::translate_with(
|
||||
locale,
|
||||
@@ -3027,6 +3165,13 @@ fn metadata_report_body(
|
||||
locale: UiLocale,
|
||||
) -> Result<String> {
|
||||
let report = engine::metadata_diff::compute_metadata_diff(conn, data_dir, project_id)?;
|
||||
Ok(metadata_report_body_from_report(&report, locale))
|
||||
}
|
||||
|
||||
fn metadata_report_body_from_report(
|
||||
report: &engine::metadata_diff::DiffReport,
|
||||
locale: UiLocale,
|
||||
) -> String {
|
||||
let mut lines = report
|
||||
.diffs
|
||||
.iter()
|
||||
@@ -3058,7 +3203,7 @@ fn metadata_report_body(
|
||||
"tui.noMetadataDifferences",
|
||||
));
|
||||
}
|
||||
Ok(lines.join("\n"))
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn site_validation_body(
|
||||
@@ -3068,7 +3213,14 @@ fn site_validation_body(
|
||||
locale: UiLocale,
|
||||
) -> Result<String> {
|
||||
let report = engine::validate_site::validate_site(conn, data_dir, project_id)?;
|
||||
Ok(bds_core::i18n::translate_with(
|
||||
Ok(site_validation_body_from_report(&report, locale))
|
||||
}
|
||||
|
||||
fn site_validation_body_from_report(
|
||||
report: &engine::validate_site::SiteValidationReport,
|
||||
locale: UiLocale,
|
||||
) -> String {
|
||||
bds_core::i18n::translate_with(
|
||||
locale,
|
||||
"tui.siteValidationReport",
|
||||
&[
|
||||
@@ -3076,7 +3228,7 @@ fn site_validation_body(
|
||||
("extra", &report.extra_pages.join("\n")),
|
||||
("stale", &report.stale_pages.join("\n")),
|
||||
],
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
impl TuiApp {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -12,6 +12,7 @@ impl BdsApp {
|
||||
let on_progress: engine::rebuild::ProgressFn =
|
||||
Arc::new(move |pct, event| {
|
||||
tm.report_progress(tid, Some(pct), Some(event.localized(locale)));
|
||||
!tm.is_cancelled(tid)
|
||||
});
|
||||
let report = engine::rebuild::rebuild_from_filesystem_with_progress(
|
||||
db.conn(),
|
||||
@@ -42,14 +43,28 @@ impl BdsApp {
|
||||
"engine.calendarStarted",
|
||||
move |db_path, project_id, data_dir, tm, tid| {
|
||||
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
|
||||
tm.report_progress(tid, Some(0.20), Some(t(locale, "engine.loadingPosts")));
|
||||
engine::calendar::regenerate_calendar(db.conn(), &data_dir, &project_id)
|
||||
.map_err(|e| e.to_string())?;
|
||||
tm.report_progress(
|
||||
tid,
|
||||
Some(0.90),
|
||||
Some(t(locale, "engine.writingCalendar")),
|
||||
);
|
||||
engine::calendar::regenerate_calendar_with_progress(
|
||||
db.conn(),
|
||||
&data_dir,
|
||||
&project_id,
|
||||
|current, total, name| {
|
||||
tm.report_progress(
|
||||
tid,
|
||||
Some(current as f32 / total.max(1) as f32),
|
||||
Some(tw(
|
||||
locale,
|
||||
"engine.checkingItem",
|
||||
&[
|
||||
("current", ¤t.to_string()),
|
||||
("total", &total.to_string()),
|
||||
("name", name),
|
||||
],
|
||||
)),
|
||||
);
|
||||
!tm.is_cancelled(tid)
|
||||
},
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok("done".to_string())
|
||||
},
|
||||
)
|
||||
@@ -59,24 +74,39 @@ impl BdsApp {
|
||||
TabType::TranslationValidation,
|
||||
"tabBar.translationValidation",
|
||||
);
|
||||
let (Some(project), Some(data_dir)) = (&self.active_project, &self.data_dir) else {
|
||||
if self.active_project.is_none() || self.data_dir.is_none() {
|
||||
return Task::none();
|
||||
};
|
||||
}
|
||||
self.translation_validation_state.is_running = true;
|
||||
self.translation_validation_state.error_message = None;
|
||||
let db_path = self.db_path.clone();
|
||||
let project_id = project.id.clone();
|
||||
let data_dir = data_dir.clone();
|
||||
Task::perform(
|
||||
async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
|
||||
let meta = engine::meta::read_project_json(&data_dir)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let main_lang = meta.main_language.as_deref().unwrap_or("en");
|
||||
let blog_langs = meta.blog_languages.clone();
|
||||
let on_item: engine::validate_translations::ItemProgressFn =
|
||||
Box::new(move |_current, _total, _name| {});
|
||||
let locale = self.ui_locale;
|
||||
self.spawn_result_task(
|
||||
"menu.item.validateTranslations",
|
||||
move |db_path, project_id, data_dir, tm, tid| {
|
||||
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
|
||||
let meta = engine::meta::read_project_json(&data_dir)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let main_lang = meta.main_language.as_deref().unwrap_or("en");
|
||||
let blog_langs = meta.blog_languages.clone();
|
||||
let progress_tm = Arc::clone(&tm);
|
||||
let on_item: engine::validate_translations::ItemProgressFn =
|
||||
Box::new(move |current, total, name| {
|
||||
progress_tm.report_progress(
|
||||
tid,
|
||||
Some(current as f32 / total.max(1) as f32),
|
||||
Some(tw(
|
||||
locale,
|
||||
"engine.checkingItem",
|
||||
&[
|
||||
("current", ¤t.to_string()),
|
||||
("total", &total.to_string()),
|
||||
("name", name),
|
||||
],
|
||||
)),
|
||||
);
|
||||
!progress_tm.is_cancelled(tid)
|
||||
});
|
||||
let report =
|
||||
engine::validate_translations::validate_translations_with_progress(
|
||||
db.conn(),
|
||||
&data_dir,
|
||||
@@ -85,16 +115,31 @@ impl BdsApp {
|
||||
main_lang,
|
||||
Some(on_item),
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
|
||||
.map_err(|e| e.to_string())?;
|
||||
tm.report_progress(
|
||||
tid,
|
||||
Some(1.0),
|
||||
Some(tw(
|
||||
locale,
|
||||
"engine.validateTranslationsComplete",
|
||||
&[
|
||||
("dbIssues", &report.db_issues.len().to_string()),
|
||||
("fsIssues", &report.fs_issues.len().to_string()),
|
||||
],
|
||||
)),
|
||||
);
|
||||
Ok(report)
|
||||
},
|
||||
Message::TranslationValidationLoaded,
|
||||
|task_id, result| Message::TranslationValidationLoaded { task_id, result },
|
||||
)
|
||||
}
|
||||
Message::TranslationValidationLoaded(result) => {
|
||||
Message::TranslationValidationLoaded { task_id, result } => {
|
||||
let cancelled = self.finish_result_task(task_id, &result);
|
||||
self.refresh_task_snapshots();
|
||||
self.translation_validation_state.is_running = false;
|
||||
if cancelled {
|
||||
return Task::none();
|
||||
}
|
||||
match result {
|
||||
Ok(report) => {
|
||||
self.translation_validation_state.report = Some(report);
|
||||
@@ -149,8 +194,13 @@ impl BdsApp {
|
||||
self.open_singleton_tab(TabType::MetadataDiff, "tabBar.metadataDiff");
|
||||
self.start_metadata_diff()
|
||||
}
|
||||
Message::MetadataDiffLoaded(result) => {
|
||||
Message::MetadataDiffLoaded { task_id, result } => {
|
||||
let cancelled = self.finish_result_task(task_id, &result);
|
||||
self.refresh_task_snapshots();
|
||||
self.metadata_diff_state.is_running = false;
|
||||
if cancelled {
|
||||
return Task::none();
|
||||
}
|
||||
match result {
|
||||
Ok(report) => {
|
||||
self.metadata_diff_state.report = Some(report);
|
||||
@@ -170,36 +220,38 @@ impl BdsApp {
|
||||
else {
|
||||
return Task::none();
|
||||
};
|
||||
let (Some(project), Some(data_dir)) =
|
||||
(self.active_project.as_ref(), self.data_dir.as_ref())
|
||||
else {
|
||||
if self.active_project.is_none() || self.data_dir.is_none() {
|
||||
return Task::none();
|
||||
};
|
||||
}
|
||||
self.metadata_diff_state.is_repairing = true;
|
||||
let db_path = self.db_path.clone();
|
||||
let project_id = project.id.clone();
|
||||
let data_dir = data_dir.clone();
|
||||
Task::perform(
|
||||
async move {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
||||
engine::metadata_diff::repair_metadata_diff_item(
|
||||
db.conn(),
|
||||
&data_dir,
|
||||
&project_id,
|
||||
direction,
|
||||
&item,
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
let locale = self.ui_locale;
|
||||
self.spawn_result_task(
|
||||
"metadataDiff.repair",
|
||||
move |db_path, project_id, data_dir, tm, tid| {
|
||||
tm.report_progress(tid, Some(0.0), Some(t(locale, "metadataDiff.repair")));
|
||||
if tm.is_cancelled(tid) {
|
||||
return Err("operation cancelled".into());
|
||||
}
|
||||
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
||||
engine::metadata_diff::repair_metadata_diff_item(
|
||||
db.conn(),
|
||||
&data_dir,
|
||||
&project_id,
|
||||
direction,
|
||||
&item,
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
},
|
||||
Message::MetadataDiffItemRepaired,
|
||||
|task_id, result| Message::MetadataDiffItemRepaired { task_id, result },
|
||||
)
|
||||
}
|
||||
Message::MetadataDiffItemRepaired(result) => {
|
||||
Message::MetadataDiffItemRepaired { task_id, result } => {
|
||||
let cancelled = self.finish_result_task(task_id, &result);
|
||||
self.refresh_task_snapshots();
|
||||
self.metadata_diff_state.is_repairing = false;
|
||||
if cancelled {
|
||||
return Task::none();
|
||||
}
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.notify(
|
||||
@@ -214,6 +266,62 @@ impl BdsApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::ImportMetadataOrphan(index) => {
|
||||
let Some(orphan) = self
|
||||
.metadata_diff_state
|
||||
.report
|
||||
.as_ref()
|
||||
.and_then(|report| report.orphans.get(index))
|
||||
.cloned()
|
||||
else {
|
||||
return Task::none();
|
||||
};
|
||||
self.metadata_diff_state.is_repairing = true;
|
||||
let locale = self.ui_locale;
|
||||
self.spawn_result_task(
|
||||
"metadataDiff.importOrphan",
|
||||
move |db_path, project_id, data_dir, tm, tid| {
|
||||
tm.report_progress(
|
||||
tid,
|
||||
Some(0.0),
|
||||
Some(t(locale, "metadataDiff.importOrphan")),
|
||||
);
|
||||
if tm.is_cancelled(tid) {
|
||||
return Err("operation cancelled".into());
|
||||
}
|
||||
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
||||
engine::metadata_diff::import_orphan_file(
|
||||
db.conn(),
|
||||
&data_dir,
|
||||
&project_id,
|
||||
&orphan,
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
},
|
||||
|task_id, result| Message::MetadataOrphanImported { task_id, result },
|
||||
)
|
||||
}
|
||||
Message::MetadataOrphanImported { task_id, result } => {
|
||||
let cancelled = self.finish_result_task(task_id, &result);
|
||||
self.refresh_task_snapshots();
|
||||
self.metadata_diff_state.is_repairing = false;
|
||||
if cancelled {
|
||||
return Task::none();
|
||||
}
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.notify(
|
||||
ToastLevel::Success,
|
||||
&t(self.ui_locale, "metadataDiff.orphanImported"),
|
||||
);
|
||||
self.start_metadata_diff()
|
||||
}
|
||||
Err(error) => {
|
||||
self.notify(ToastLevel::Error, &error);
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::RunSiteValidation => self.start_site_validation(),
|
||||
Message::ApplySiteValidation => self.apply_site_validation(),
|
||||
Message::EngineTaskDone {
|
||||
@@ -223,15 +331,14 @@ impl BdsApp {
|
||||
result,
|
||||
} => {
|
||||
let search_rebuild_finished = self.search_index_rebuild_task_id == Some(task_id);
|
||||
let cancelled = self.task_manager.status(task_id) == Some(TaskStatus::Cancelled);
|
||||
let refresh_semantic_tags = !cancelled
|
||||
let cancellation_requested = self.task_manager.is_cancelled(task_id);
|
||||
let refresh_semantic_tags = !cancellation_requested
|
||||
&& result.is_ok()
|
||||
&& matches!(
|
||||
operation,
|
||||
"embeddings.indexing" | "menu.item.rebuildEmbeddingIndex"
|
||||
);
|
||||
match &result {
|
||||
_ if cancelled => {}
|
||||
Ok(detail) => {
|
||||
self.task_manager.complete(task_id);
|
||||
if operation == "engine.rebuildStarted" {
|
||||
@@ -258,12 +365,14 @@ impl BdsApp {
|
||||
}
|
||||
Err(err) => {
|
||||
self.task_manager.fail(task_id, err.clone());
|
||||
let message = tw(
|
||||
self.ui_locale,
|
||||
"common.operationFailed",
|
||||
&[("operation", &label), ("error", err)],
|
||||
);
|
||||
self.notify(ToastLevel::Error, &message);
|
||||
if !cancellation_requested {
|
||||
let message = tw(
|
||||
self.ui_locale,
|
||||
"common.operationFailed",
|
||||
&[("operation", &label), ("error", err)],
|
||||
);
|
||||
self.notify(ToastLevel::Error, &message);
|
||||
}
|
||||
}
|
||||
}
|
||||
if search_rebuild_finished {
|
||||
@@ -293,12 +402,41 @@ impl BdsApp {
|
||||
self.refresh_task_snapshots();
|
||||
Task::batch([sidebar_task, semantic_task])
|
||||
}
|
||||
Message::SiteGenerationPrepared {
|
||||
task_id,
|
||||
validation,
|
||||
force,
|
||||
result,
|
||||
} => {
|
||||
let cancelled = self.finish_result_task(task_id, &result);
|
||||
self.refresh_task_snapshots();
|
||||
if cancelled {
|
||||
if validation.is_some() {
|
||||
self.site_validation_state.is_applying = false;
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
match result {
|
||||
Ok((prepared, counts)) => {
|
||||
self.queue_prepared_site_generation(validation, force, prepared, counts)
|
||||
}
|
||||
Err(error) => {
|
||||
if validation.is_some() {
|
||||
self.site_validation_state.is_applying = false;
|
||||
self.site_validation_state.error_message = Some(error.clone());
|
||||
}
|
||||
self.notify(ToastLevel::Error, &error);
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::SiteGenerationSectionDone {
|
||||
group_id,
|
||||
task_id,
|
||||
result,
|
||||
} => {
|
||||
if self.task_manager.status(task_id) == Some(TaskStatus::Cancelled) {
|
||||
if self.task_manager.is_cancelled(task_id) {
|
||||
self.finish_result_task(task_id, &result);
|
||||
self.refresh_task_snapshots();
|
||||
return Task::none();
|
||||
}
|
||||
@@ -335,21 +473,54 @@ impl BdsApp {
|
||||
}
|
||||
}
|
||||
|
||||
let should_index =
|
||||
let next_phase =
|
||||
self.site_generation_workflows
|
||||
.get(&group_id)
|
||||
.is_some_and(|workflow| {
|
||||
workflow.index_task_id.is_none()
|
||||
.and_then(|workflow| {
|
||||
(workflow.calendar_task_id.is_none()
|
||||
&& workflow.index_task_id.is_none()
|
||||
&& workflow.render_task_ids.iter().all(|task_id| {
|
||||
self.task_manager.status(*task_id)
|
||||
== Some(TaskStatus::Completed)
|
||||
})
|
||||
}))
|
||||
.then_some(workflow.calendar_needed)
|
||||
});
|
||||
self.refresh_task_snapshots();
|
||||
if should_index {
|
||||
self.queue_site_search_index(&group_id)
|
||||
} else {
|
||||
Task::none()
|
||||
match next_phase {
|
||||
Some(true) => self.queue_site_calendar(&group_id),
|
||||
Some(false) => self.queue_site_search_index(&group_id),
|
||||
None => Task::none(),
|
||||
}
|
||||
}
|
||||
Message::SiteGenerationCalendarDone {
|
||||
group_id,
|
||||
task_id,
|
||||
result,
|
||||
} => {
|
||||
if self.task_manager.is_cancelled(task_id) {
|
||||
self.finish_result_task(task_id, &result);
|
||||
self.refresh_task_snapshots();
|
||||
return Task::none();
|
||||
}
|
||||
match result {
|
||||
Ok(()) => {
|
||||
self.task_manager.complete(task_id);
|
||||
self.refresh_task_snapshots();
|
||||
self.queue_site_search_index(&group_id)
|
||||
}
|
||||
Err(error) => {
|
||||
self.task_manager.fail(task_id, error.clone());
|
||||
self.task_manager.cancel_group(&group_id);
|
||||
if let Some(workflow) = self.site_generation_workflows.remove(&group_id)
|
||||
&& workflow.kind == SiteGenerationKind::Validation
|
||||
{
|
||||
self.site_validation_state.is_applying = false;
|
||||
self.site_validation_state.error_message = Some(error.clone());
|
||||
}
|
||||
self.notify(ToastLevel::Error, &error);
|
||||
self.refresh_task_snapshots();
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::SiteGenerationIndexDone {
|
||||
@@ -357,7 +528,8 @@ impl BdsApp {
|
||||
task_id,
|
||||
result,
|
||||
} => {
|
||||
if self.task_manager.status(task_id) == Some(TaskStatus::Cancelled) {
|
||||
if self.task_manager.is_cancelled(task_id) {
|
||||
self.finish_result_task(task_id, &result);
|
||||
self.refresh_task_snapshots();
|
||||
return Task::none();
|
||||
}
|
||||
@@ -415,8 +587,13 @@ impl BdsApp {
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::SiteValidationLoaded(result) => {
|
||||
Message::SiteValidationLoaded { task_id, result } => {
|
||||
let cancelled = self.finish_result_task(task_id, &result);
|
||||
self.refresh_task_snapshots();
|
||||
self.site_validation_state.is_running = false;
|
||||
if cancelled {
|
||||
return Task::none();
|
||||
}
|
||||
self.site_validation_state.has_run = true;
|
||||
match result {
|
||||
Ok(report) => {
|
||||
|
||||
@@ -259,10 +259,11 @@ impl BdsApp {
|
||||
|
||||
Task::perform(
|
||||
async move {
|
||||
let Some(worker) = task_manager.admit(task_id).await else {
|
||||
return Err("cancelled".to_string());
|
||||
};
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if !task_manager.wait_until_runnable(task_id) {
|
||||
return Err("cancelled".to_string());
|
||||
}
|
||||
let _worker = worker;
|
||||
let engine = GitEngine::new(&data_dir);
|
||||
let old_head = (operation == GitOperation::Pull)
|
||||
.then(|| engine.head())
|
||||
|
||||
@@ -14,35 +14,40 @@ fn shorten_task_url(url: &str) -> String {
|
||||
}
|
||||
|
||||
impl BdsApp {
|
||||
pub(super) fn finish_result_task<T>(
|
||||
&self,
|
||||
task_id: TaskId,
|
||||
result: &Result<T, String>,
|
||||
) -> bool {
|
||||
let cancellation_requested = self.task_manager.is_cancelled(task_id);
|
||||
match result {
|
||||
Ok(_) => self.task_manager.complete(task_id),
|
||||
Err(error) => self.task_manager.fail(task_id, error.clone()),
|
||||
}
|
||||
cancellation_requested && result.is_err()
|
||||
}
|
||||
|
||||
pub(super) fn refresh_task_snapshots(&mut self) {
|
||||
self.task_snapshots = self
|
||||
.task_manager
|
||||
.snapshots()
|
||||
.into_iter()
|
||||
.map(|snapshot| {
|
||||
let status_str = match &snapshot.status {
|
||||
TaskStatus::Pending => t(self.ui_locale, "tasks.statusPending"),
|
||||
TaskStatus::Running => t(self.ui_locale, "tasks.statusRunning"),
|
||||
TaskStatus::Completed => t(self.ui_locale, "tasks.statusCompleted"),
|
||||
TaskStatus::Failed(error) => {
|
||||
tw(self.ui_locale, "tasks.statusFailed", &[("error", error)])
|
||||
}
|
||||
TaskStatus::Cancelled => t(self.ui_locale, "tasks.statusCancelled"),
|
||||
};
|
||||
TaskSnapshot {
|
||||
id: snapshot.id,
|
||||
label: snapshot.label,
|
||||
group_id: snapshot.group_id,
|
||||
group_name: snapshot.group_name,
|
||||
status: status_str,
|
||||
progress: snapshot.progress,
|
||||
message: snapshot.message,
|
||||
is_cancellable: matches!(
|
||||
snapshot.status,
|
||||
TaskStatus::Pending | TaskStatus::Running
|
||||
),
|
||||
}
|
||||
.map(|snapshot| TaskSnapshot {
|
||||
id: snapshot.id,
|
||||
source: crate::state::navigation::TaskSource::Local,
|
||||
label: snapshot.label,
|
||||
group_id: snapshot.group_id,
|
||||
group_name: snapshot.group_name,
|
||||
status: snapshot.status.clone(),
|
||||
progress: snapshot.progress,
|
||||
message: snapshot.message,
|
||||
cancellation_requested: snapshot.cancellation_requested,
|
||||
is_cancellable: matches!(
|
||||
snapshot.status,
|
||||
TaskStatus::Pending | TaskStatus::Running
|
||||
) && !snapshot.cancellation_requested,
|
||||
})
|
||||
.chain(self.remote_task_snapshots.iter().cloned())
|
||||
.collect();
|
||||
}
|
||||
|
||||
@@ -69,7 +74,7 @@ impl BdsApp {
|
||||
} else {
|
||||
SiteGenerationKind::Full
|
||||
};
|
||||
let (Some(project), Some(data_dir)) = (&self.active_project, &self.data_dir) else {
|
||||
let Some(data_dir) = &self.data_dir else {
|
||||
if kind == SiteGenerationKind::Validation {
|
||||
self.site_validation_state.is_applying = false;
|
||||
}
|
||||
@@ -79,6 +84,16 @@ impl BdsApp {
|
||||
);
|
||||
return Task::none();
|
||||
};
|
||||
if self.active_project.is_none() {
|
||||
if kind == SiteGenerationKind::Validation {
|
||||
self.site_validation_state.is_applying = false;
|
||||
}
|
||||
self.notify(
|
||||
ToastLevel::Error,
|
||||
&t(self.ui_locale, "engine.generateSiteNoProject"),
|
||||
);
|
||||
return Task::none();
|
||||
}
|
||||
let metadata = match engine::meta::read_project_json(data_dir) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) => {
|
||||
@@ -107,37 +122,122 @@ impl BdsApp {
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
let sections = validation.as_ref().map_or_else(
|
||||
|| engine::generation::GenerationSection::ALL.to_vec(),
|
||||
|validation| engine::generation::sections_from_validation_report(validation, &metadata),
|
||||
);
|
||||
if sections.is_empty() {
|
||||
return Task::none();
|
||||
}
|
||||
let task_validation = validation.clone();
|
||||
let locale = self.ui_locale;
|
||||
self.spawn_result_task(
|
||||
"engine.prepareGeneration",
|
||||
move |db_path, project_id, data_dir, tm, tid| {
|
||||
tm.report_progress(
|
||||
tid,
|
||||
Some(0.0),
|
||||
Some(t(locale, "engine.preparingGeneration")),
|
||||
);
|
||||
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
||||
let metadata = engine::meta::read_project_json(&data_dir)
|
||||
.map_err(|error| error.to_string())?;
|
||||
if tm.is_cancelled(tid) {
|
||||
return Err("operation cancelled".into());
|
||||
}
|
||||
let posts =
|
||||
bds_core::db::queries::post::list_posts_by_project(db.conn(), &project_id)
|
||||
.map_err(|error| error.to_string())?
|
||||
.into_iter()
|
||||
.filter(engine::generation::has_published_snapshot)
|
||||
.collect::<Vec<_>>();
|
||||
let total_posts = posts.len();
|
||||
let mut sources = Vec::with_capacity(total_posts);
|
||||
for (index, post) in posts.into_iter().enumerate() {
|
||||
tm.report_progress(
|
||||
tid,
|
||||
Some(0.05 + (index + 1) as f32 / total_posts.max(1) as f32 * 0.35),
|
||||
Some(t(locale, "engine.loadingPosts")),
|
||||
);
|
||||
if tm.is_cancelled(tid) {
|
||||
return Err("operation cancelled".into());
|
||||
}
|
||||
if let Some(source) =
|
||||
engine::generation::load_published_post_source(&data_dir, post)
|
||||
.map_err(|error| error.to_string())?
|
||||
{
|
||||
sources.push(source);
|
||||
}
|
||||
}
|
||||
tm.report_progress(
|
||||
tid,
|
||||
Some(0.45),
|
||||
Some(t(locale, "engine.preparingGeneration")),
|
||||
);
|
||||
let prepared = Arc::new(
|
||||
engine::generation::prepare_site_generation(
|
||||
db.conn(),
|
||||
&data_dir,
|
||||
&project_id,
|
||||
&metadata,
|
||||
&sources,
|
||||
)
|
||||
.map_err(|error| error.to_string())?,
|
||||
);
|
||||
if tm.is_cancelled(tid) {
|
||||
return Err("operation cancelled".into());
|
||||
}
|
||||
let sections = task_validation.as_ref().map_or_else(
|
||||
|| engine::generation::GenerationSection::ALL.to_vec(),
|
||||
|report| engine::generation::sections_from_validation_report(report, &metadata),
|
||||
);
|
||||
let counts = sections
|
||||
.into_iter()
|
||||
.map(|section| {
|
||||
(
|
||||
section,
|
||||
engine::generation::prepared_section_page_count(
|
||||
&prepared,
|
||||
task_validation.as_ref(),
|
||||
section,
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
tm.report_progress(tid, Some(1.0), Some(t(locale, "engine.generationPrepared")));
|
||||
Ok((prepared, counts))
|
||||
},
|
||||
move |task_id, result| Message::SiteGenerationPrepared {
|
||||
task_id,
|
||||
validation: validation.clone(),
|
||||
force,
|
||||
result,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn queue_prepared_site_generation(
|
||||
&mut self,
|
||||
validation: Option<engine::validate_site::SiteValidationReport>,
|
||||
force: bool,
|
||||
prepared: Arc<engine::generation::PreparedSiteGeneration>,
|
||||
page_estimates: HashMap<engine::generation::GenerationSection, usize>,
|
||||
) -> Task<Message> {
|
||||
let kind = if validation.is_some() {
|
||||
SiteGenerationKind::Validation
|
||||
} else if force {
|
||||
SiteGenerationKind::Forced
|
||||
} else {
|
||||
SiteGenerationKind::Full
|
||||
};
|
||||
let (Some(project), Some(data_dir)) = (&self.active_project, &self.data_dir) else {
|
||||
return Task::none();
|
||||
};
|
||||
let sections = page_estimates.keys().copied().collect::<Vec<_>>();
|
||||
let calendar_needed = kind == SiteGenerationKind::Validation
|
||||
&& sections.iter().any(|section| {
|
||||
matches!(
|
||||
section,
|
||||
engine::generation::GenerationSection::Core
|
||||
| engine::generation::GenerationSection::Date
|
||||
)
|
||||
});
|
||||
let project_id = project.id.clone();
|
||||
let db_path = self.db_path.clone();
|
||||
let data_dir = data_dir.clone();
|
||||
let published_posts = match self.db.as_ref().and_then(|db| {
|
||||
bds_core::db::queries::post::list_posts_by_project(db.conn(), &project_id).ok()
|
||||
}) {
|
||||
Some(posts) => posts
|
||||
.into_iter()
|
||||
.filter(engine::generation::has_published_snapshot)
|
||||
.collect::<Vec<_>>(),
|
||||
None => return Task::none(),
|
||||
};
|
||||
let page_estimates = match bds_core::render::estimate_site_render_pages(
|
||||
&data_dir,
|
||||
&metadata,
|
||||
&published_posts,
|
||||
) {
|
||||
Ok(estimates) => estimates,
|
||||
Err(error) => {
|
||||
self.notify(ToastLevel::Error, &error.to_string());
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
let group_id = format!("site-generation:{}", Uuid::new_v4());
|
||||
let group_name = t(
|
||||
self.ui_locale,
|
||||
@@ -150,7 +250,10 @@ impl BdsApp {
|
||||
let mut render_task_ids = Vec::new();
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
for section in sections {
|
||||
for section in engine::generation::GenerationSection::ALL
|
||||
.into_iter()
|
||||
.filter(|section| sections.contains(section))
|
||||
{
|
||||
let label = t(self.ui_locale, generation_section_label_key(section));
|
||||
self.add_output(&label);
|
||||
let page_work = page_estimates[§ion];
|
||||
@@ -173,14 +276,20 @@ impl BdsApp {
|
||||
let task_data_dir = data_dir.clone();
|
||||
let task_group_id = group_id.clone();
|
||||
let task_validation = validation.clone();
|
||||
let task_prepared = Arc::clone(&prepared);
|
||||
let locale = self.ui_locale;
|
||||
tasks.push(Task::perform(
|
||||
async move {
|
||||
let Some(worker) = task_manager.admit(task_id).await else {
|
||||
return Err("cancelled".to_string());
|
||||
};
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _worker = worker;
|
||||
run_site_generation_section(
|
||||
task_db_path,
|
||||
task_project_id,
|
||||
task_data_dir,
|
||||
task_prepared,
|
||||
task_manager,
|
||||
task_id,
|
||||
section,
|
||||
@@ -210,6 +319,8 @@ impl BdsApp {
|
||||
data_dir,
|
||||
group_name,
|
||||
render_task_ids,
|
||||
calendar_needed,
|
||||
calendar_task_id: None,
|
||||
index_task_id: None,
|
||||
report: engine::generation::GenerationReport::default(),
|
||||
},
|
||||
@@ -218,6 +329,75 @@ impl BdsApp {
|
||||
Task::batch(tasks)
|
||||
}
|
||||
|
||||
pub(super) fn queue_site_calendar(&mut self, group_id: &str) -> Task<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", ¤t.to_string()),
|
||||
("total", &total.to_string()),
|
||||
("name", name),
|
||||
],
|
||||
)),
|
||||
);
|
||||
!task_manager.is_cancelled(task_id)
|
||||
},
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
task_manager.report_progress(
|
||||
task_id,
|
||||
Some(1.0),
|
||||
Some(t(locale, "engine.writingCalendar")),
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
|
||||
},
|
||||
move |result| Message::SiteGenerationCalendarDone {
|
||||
group_id: task_group_id.clone(),
|
||||
task_id,
|
||||
result,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn queue_site_search_index(&mut self, group_id: &str) -> Task<Message> {
|
||||
let Some(workflow) = self.site_generation_workflows.get(group_id).cloned() else {
|
||||
return Task::none();
|
||||
@@ -237,10 +417,11 @@ impl BdsApp {
|
||||
let task_group_id = group_id.to_string();
|
||||
Task::perform(
|
||||
async move {
|
||||
let Some(worker) = task_manager.admit(task_id).await else {
|
||||
return Err("cancelled".to_string());
|
||||
};
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if !task_manager.wait_until_runnable(task_id) {
|
||||
return Err("cancelled".to_string());
|
||||
}
|
||||
let _worker = worker;
|
||||
let db =
|
||||
Database::open(&workflow.db_path).map_err(|error| error.to_string())?;
|
||||
let metadata = engine::meta::read_project_json(&workflow.data_dir)
|
||||
@@ -312,7 +493,7 @@ impl BdsApp {
|
||||
if workflow.kind == SiteGenerationKind::Validation {
|
||||
self.site_validation_state.is_applying = false;
|
||||
}
|
||||
self.add_output(&t(self.ui_locale, "engine.generationCancelled"));
|
||||
self.add_output(&t(self.ui_locale, "engine.generationCancellationRequested"));
|
||||
self.refresh_task_snapshots();
|
||||
true
|
||||
}
|
||||
@@ -337,7 +518,10 @@ impl BdsApp {
|
||||
let task_id = self.task_manager.submit(&label);
|
||||
self.search_index_rebuild_running = true;
|
||||
self.search_index_rebuild_task_id = Some(task_id);
|
||||
self.active_modal = Some(modal::ModalState::SearchIndexRebuilding);
|
||||
self.active_modal = Some(modal::ModalState::SearchIndexRebuilding {
|
||||
task_id,
|
||||
cancellation_requested: false,
|
||||
});
|
||||
self.refresh_task_snapshots();
|
||||
self.sync_menu_state();
|
||||
|
||||
@@ -346,10 +530,11 @@ impl BdsApp {
|
||||
let task_manager = Arc::clone(&self.task_manager);
|
||||
Task::perform(
|
||||
async move {
|
||||
let Some(worker) = task_manager.admit(task_id).await else {
|
||||
return Err("cancelled".to_string());
|
||||
};
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if !task_manager.wait_until_runnable(task_id) {
|
||||
return Err("cancelled".to_string());
|
||||
}
|
||||
let _worker = worker;
|
||||
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
||||
let progress_manager = Arc::clone(&task_manager);
|
||||
let on_item: engine::search::ItemProgressFn =
|
||||
@@ -373,6 +558,7 @@ impl BdsApp {
|
||||
Some(progress),
|
||||
Some(message),
|
||||
);
|
||||
!progress_manager.is_cancelled(task_id)
|
||||
});
|
||||
let report = engine::search::rebuild_search_index(db.conn(), Some(on_item))
|
||||
.map_err(|error| error.to_string())?;
|
||||
@@ -403,6 +589,49 @@ impl BdsApp {
|
||||
self.spawn_engine_task_in_group(label_key, None, work)
|
||||
}
|
||||
|
||||
pub(super) fn spawn_result_task<T, F, M>(
|
||||
&mut self,
|
||||
label_key: &'static str,
|
||||
work: F,
|
||||
message: M,
|
||||
) -> Task<Message>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce(PathBuf, String, PathBuf, Arc<TaskManager>, TaskId) -> Result<T, String>
|
||||
+ Send
|
||||
+ 'static,
|
||||
M: Fn(TaskId, Result<T, String>) -> Message + Send + Sync + 'static,
|
||||
{
|
||||
let (Some(project_id), Some(data_dir)) = (
|
||||
self.active_project
|
||||
.as_ref()
|
||||
.map(|project| project.id.clone()),
|
||||
self.data_dir.clone(),
|
||||
) else {
|
||||
return Task::none();
|
||||
};
|
||||
let label = t(self.ui_locale, label_key);
|
||||
self.add_output(&label);
|
||||
let task_id = self.task_manager.submit(&label);
|
||||
self.refresh_task_snapshots();
|
||||
let task_manager = Arc::clone(&self.task_manager);
|
||||
let db_path = self.db_path.clone();
|
||||
Task::perform(
|
||||
async move {
|
||||
let Some(worker) = task_manager.admit(task_id).await else {
|
||||
return Err("cancelled".to_string());
|
||||
};
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _worker = worker;
|
||||
work(db_path, project_id, data_dir, task_manager, task_id)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
|
||||
},
|
||||
move |result| message(task_id, result),
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn spawn_grouped_engine_task<F>(
|
||||
&mut self,
|
||||
label_key: &'static str,
|
||||
@@ -453,10 +682,11 @@ impl BdsApp {
|
||||
|
||||
Task::perform(
|
||||
async move {
|
||||
let Some(worker) = tm.admit(task_id).await else {
|
||||
return Err("cancelled".to_string());
|
||||
};
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if !tm.wait_until_runnable(task_id) {
|
||||
return Err("cancelled".to_string());
|
||||
}
|
||||
let _worker = worker;
|
||||
work(db_path, project_id, data_dir, tm, task_id)
|
||||
})
|
||||
.await
|
||||
@@ -490,6 +720,7 @@ fn run_site_generation_section(
|
||||
db_path: PathBuf,
|
||||
project_id: String,
|
||||
data_dir: PathBuf,
|
||||
prepared: Arc<engine::generation::PreparedSiteGeneration>,
|
||||
task_manager: Arc<TaskManager>,
|
||||
task_id: TaskId,
|
||||
section: engine::generation::GenerationSection,
|
||||
@@ -498,9 +729,6 @@ fn run_site_generation_section(
|
||||
locale: UiLocale,
|
||||
expected_pages: usize,
|
||||
) -> Result<engine::generation::GenerationReport, String> {
|
||||
if !task_manager.wait_until_runnable(task_id) {
|
||||
return Err("cancelled".to_string());
|
||||
}
|
||||
task_manager.report_progress(
|
||||
task_id,
|
||||
Some(if expected_pages == 0 { 1.0 } else { 0.0 }),
|
||||
@@ -511,26 +739,6 @@ fn run_site_generation_section(
|
||||
)),
|
||||
);
|
||||
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
|
||||
let metadata = engine::meta::read_project_json(&data_dir).map_err(|error| error.to_string())?;
|
||||
let posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &project_id)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let sources = posts
|
||||
.into_iter()
|
||||
.filter(engine::generation::has_published_snapshot)
|
||||
.map(|post| engine::generation::load_published_post_source(&data_dir, post))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| error.to_string())?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>();
|
||||
let prepared = engine::generation::prepare_site_generation(
|
||||
db.conn(),
|
||||
&data_dir,
|
||||
&project_id,
|
||||
&metadata,
|
||||
&sources,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let output_dir = data_dir.join("html");
|
||||
std::fs::create_dir_all(&output_dir).map_err(|error| error.to_string())?;
|
||||
let render_manager = Arc::clone(&task_manager);
|
||||
@@ -565,7 +773,7 @@ fn run_site_generation_section(
|
||||
db.conn(),
|
||||
&output_dir,
|
||||
&project_id,
|
||||
&prepared,
|
||||
prepared.as_ref(),
|
||||
&validation,
|
||||
section,
|
||||
on_page,
|
||||
@@ -576,7 +784,7 @@ fn run_site_generation_section(
|
||||
db.conn(),
|
||||
&output_dir,
|
||||
&project_id,
|
||||
&prepared,
|
||||
prepared.as_ref(),
|
||||
section,
|
||||
force,
|
||||
&on_rendered,
|
||||
|
||||
@@ -107,15 +107,23 @@ impl PanelTab {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TaskSnapshot {
|
||||
pub id: u64,
|
||||
pub source: TaskSource,
|
||||
pub label: String,
|
||||
pub group_id: Option<String>,
|
||||
pub group_name: Option<String>,
|
||||
pub status: String,
|
||||
pub status: bds_core::engine::task::TaskStatus,
|
||||
pub progress: Option<f32>,
|
||||
pub message: Option<String>,
|
||||
pub cancellation_requested: bool,
|
||||
pub is_cancellable: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TaskSource {
|
||||
Local,
|
||||
Remote,
|
||||
}
|
||||
|
||||
/// A single line of output shown in the panel.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OutputEntry {
|
||||
|
||||
@@ -386,7 +386,10 @@ fn network_output(run: &GitNetworkRunState, locale: UiLocale) -> Element<'static
|
||||
)
|
||||
.padding(6),
|
||||
button(text(t(locale, "common.cancel")).size(11))
|
||||
.on_press(Message::CancelTask(run.task_id))
|
||||
.on_press(Message::CancelTask(
|
||||
crate::state::navigation::TaskSource::Local,
|
||||
run.task_id,
|
||||
))
|
||||
.padding([4, 7])
|
||||
.style(inputs::secondary_button),
|
||||
]
|
||||
|
||||
@@ -101,19 +101,33 @@ pub fn view<'a>(state: &'a MetadataDiffState, locale: UiLocale) -> Element<'a, M
|
||||
));
|
||||
}
|
||||
if !report.orphans.is_empty() {
|
||||
let rows = report
|
||||
.orphans
|
||||
.iter()
|
||||
.fold(column!().spacing(4), |column, orphan| {
|
||||
column.push(
|
||||
let rows = report.orphans.iter().enumerate().fold(
|
||||
column!().spacing(8),
|
||||
|column, (index, orphan)| {
|
||||
let mut item = row![
|
||||
text(tw(
|
||||
locale,
|
||||
"metadataDiff.orphan",
|
||||
&[("reason", &orphan.reason), ("path", &orphan.file_path)],
|
||||
))
|
||||
.size(12),
|
||||
)
|
||||
});
|
||||
Space::with_width(Length::Fill),
|
||||
]
|
||||
.align_y(iced::Alignment::Center);
|
||||
if orphan.reason == "file_without_db_entry" {
|
||||
item = item.push(
|
||||
button(text(t(locale, "metadataDiff.importOrphan")).size(12))
|
||||
.on_press_maybe(
|
||||
(!state.is_repairing)
|
||||
.then_some(Message::ImportMetadataOrphan(index)),
|
||||
)
|
||||
.style(inputs::secondary_button)
|
||||
.padding([5, 10]),
|
||||
);
|
||||
}
|
||||
column.push(item)
|
||||
},
|
||||
);
|
||||
content = content.push(inputs::card(
|
||||
column![text(t(locale, "metadataDiff.orphans")).size(16), rows].spacing(8),
|
||||
));
|
||||
|
||||
@@ -64,7 +64,10 @@ pub enum ModalState {
|
||||
on_confirm: ConfirmAction,
|
||||
},
|
||||
SearchIndexRepair,
|
||||
SearchIndexRebuilding,
|
||||
SearchIndexRebuilding {
|
||||
task_id: bds_core::engine::task::TaskId,
|
||||
cancellation_requested: bool,
|
||||
},
|
||||
FindReplace {
|
||||
query: String,
|
||||
replacement: String,
|
||||
@@ -552,8 +555,11 @@ pub fn view(
|
||||
.into()
|
||||
}
|
||||
|
||||
ModalState::SearchIndexRebuilding => container(
|
||||
column![
|
||||
ModalState::SearchIndexRebuilding {
|
||||
task_id,
|
||||
cancellation_requested,
|
||||
} => {
|
||||
let mut content = column![
|
||||
text(t(locale, "searchIndexRepair.rebuildingTitle"))
|
||||
.size(16)
|
||||
.shaping(Shaping::Advanced)
|
||||
@@ -563,12 +569,31 @@ pub fn view(
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.80, 0.80, 0.85)),
|
||||
]
|
||||
.padding(20),
|
||||
)
|
||||
.width(Length::Fixed(420.0))
|
||||
.style(modal_box_style)
|
||||
.into(),
|
||||
];
|
||||
content = if cancellation_requested {
|
||||
content.push(Space::with_height(16.0)).push(
|
||||
text(t(locale, "tasks.status.cancelling"))
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.80, 0.80, 0.85)),
|
||||
)
|
||||
} else {
|
||||
content.push(Space::with_height(16.0)).push(row![
|
||||
Space::with_width(Length::Fill),
|
||||
button(text(t(locale, "tasks.cancelTask")).size(13))
|
||||
.on_press(Message::CancelTask(
|
||||
crate::state::navigation::TaskSource::Local,
|
||||
task_id,
|
||||
))
|
||||
.padding([6, 16])
|
||||
.style(cancel_button_style),
|
||||
])
|
||||
};
|
||||
container(content.padding(20))
|
||||
.width(Length::Fixed(420.0))
|
||||
.style(modal_box_style)
|
||||
.into()
|
||||
}
|
||||
|
||||
ModalState::PostInsertLink {
|
||||
post_id: _post_id,
|
||||
|
||||
@@ -3,17 +3,28 @@ use iced::widget::{Space, button, column, container, row, scrollable, text};
|
||||
use iced::{Alignment, Background, Border, Color, Element, Length, Theme};
|
||||
|
||||
use bds_core::engine::git::GitCommit;
|
||||
use bds_core::engine::task::TaskStatus;
|
||||
use bds_core::i18n::UiLocale;
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::components::inputs;
|
||||
use crate::i18n::t;
|
||||
use crate::i18n::{t, tw};
|
||||
use crate::state::navigation::{OutputEntry, PanelTab, TaskSnapshot};
|
||||
use crate::state::tabs::{Tab, TabType};
|
||||
use crate::views::post_editor::ResolvedPostLink;
|
||||
use std::collections::HashSet;
|
||||
|
||||
fn task_row(snapshot: &TaskSnapshot, locale: UiLocale) -> Element<'static, Message> {
|
||||
let status = match &snapshot.status {
|
||||
TaskStatus::Pending => t(locale, "tasks.statusPending"),
|
||||
TaskStatus::Running if snapshot.cancellation_requested => {
|
||||
t(locale, "tasks.statusCancelling")
|
||||
}
|
||||
TaskStatus::Running => t(locale, "tasks.statusRunning"),
|
||||
TaskStatus::Completed => t(locale, "tasks.statusCompleted"),
|
||||
TaskStatus::Failed(error) => tw(locale, "tasks.statusFailed", &[("error", error)]),
|
||||
TaskStatus::Cancelled => t(locale, "tasks.statusCancelled"),
|
||||
};
|
||||
let progress = snapshot
|
||||
.progress
|
||||
.map(|value| format!(" ({:.0}%)", value * 100.0))
|
||||
@@ -25,7 +36,7 @@ fn task_row(snapshot: &TaskSnapshot, locale: UiLocale) -> Element<'static, Messa
|
||||
.unwrap_or_default();
|
||||
let label = text(format!(
|
||||
"{} — {}{}{}",
|
||||
snapshot.label, snapshot.status, progress, phase
|
||||
snapshot.label, status, progress, phase
|
||||
))
|
||||
.size(11)
|
||||
.shaping(Shaping::Advanced)
|
||||
@@ -34,7 +45,7 @@ fn task_row(snapshot: &TaskSnapshot, locale: UiLocale) -> Element<'static, Messa
|
||||
if snapshot.is_cancellable {
|
||||
content = content.push(Space::with_width(Length::Fill)).push(
|
||||
button(text(t(locale, "tasks.cancelTask")).size(11))
|
||||
.on_press(Message::CancelTask(snapshot.id))
|
||||
.on_press(Message::CancelTask(snapshot.source, snapshot.id))
|
||||
.padding([3, 8])
|
||||
.style(inputs::secondary_button),
|
||||
);
|
||||
@@ -46,12 +57,9 @@ fn group_progress(members: &[&TaskSnapshot]) -> Option<f32> {
|
||||
(!members.is_empty()).then(|| {
|
||||
members
|
||||
.iter()
|
||||
.map(|member| {
|
||||
if member.is_cancellable {
|
||||
member.progress.unwrap_or(0.0)
|
||||
} else {
|
||||
1.0
|
||||
}
|
||||
.map(|member| match member.status {
|
||||
TaskStatus::Completed | TaskStatus::Failed(_) | TaskStatus::Cancelled => 1.0,
|
||||
TaskStatus::Pending | TaskStatus::Running => member.progress.unwrap_or(0.0),
|
||||
})
|
||||
.sum::<f32>()
|
||||
/ members.len() as f32
|
||||
@@ -219,7 +227,7 @@ pub fn view(
|
||||
.padding(8)
|
||||
.into()
|
||||
} else {
|
||||
let visible = task_snapshots.iter().rev().take(10).collect::<Vec<_>>();
|
||||
let visible = task_snapshots.iter().collect::<Vec<_>>();
|
||||
let mut rendered_groups = HashSet::new();
|
||||
let mut items: Vec<Element<'static, Message>> = Vec::new();
|
||||
for snapshot in &visible {
|
||||
@@ -441,16 +449,19 @@ mod progress_tests {
|
||||
fn group_progress_includes_pending_tasks_as_zero_like_bds2() {
|
||||
let complete = TaskSnapshot {
|
||||
id: 1,
|
||||
source: crate::state::navigation::TaskSource::Local,
|
||||
label: String::new(),
|
||||
group_id: None,
|
||||
group_name: None,
|
||||
status: String::new(),
|
||||
status: TaskStatus::Completed,
|
||||
progress: Some(1.0),
|
||||
message: None,
|
||||
cancellation_requested: false,
|
||||
is_cancellable: false,
|
||||
};
|
||||
let pending = TaskSnapshot {
|
||||
id: 2,
|
||||
status: TaskStatus::Pending,
|
||||
progress: None,
|
||||
is_cancellable: true,
|
||||
..complete.clone()
|
||||
|
||||
@@ -2,10 +2,11 @@ use iced::widget::text::Shaping;
|
||||
use iced::widget::{Space, button, container, row, text};
|
||||
use iced::{Alignment, Background, Border, Color, Element, Length, Theme};
|
||||
|
||||
use bds_core::engine::task::TaskStatus;
|
||||
use bds_core::i18n::UiLocale;
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::i18n::tw;
|
||||
use crate::i18n::{t, tw};
|
||||
use crate::state::navigation::TaskSnapshot;
|
||||
use crate::views::project_selector;
|
||||
|
||||
@@ -131,23 +132,38 @@ pub fn view(
|
||||
let project_name = active_project_name.unwrap_or("\u{2014}");
|
||||
let project_trigger = project_selector::trigger_button(project_name);
|
||||
|
||||
let running: Vec<&TaskSnapshot> = task_snapshots
|
||||
let active: Vec<&TaskSnapshot> = task_snapshots
|
||||
.iter()
|
||||
.filter(|t| t.status == "running")
|
||||
.filter(|task| matches!(task.status, TaskStatus::Pending | TaskStatus::Running))
|
||||
.collect();
|
||||
let task_indicator: Element<'static, Message> = if !running.is_empty() {
|
||||
let first = &running[0];
|
||||
let first = active
|
||||
.iter()
|
||||
.find(|task| task.status == TaskStatus::Running)
|
||||
.or_else(|| active.first());
|
||||
let task_indicator: Element<'static, Message> = if let Some(first) = first {
|
||||
let progress_str = first
|
||||
.progress
|
||||
.map(|p| format!(" {:.0}%", p * 100.0))
|
||||
.unwrap_or_default();
|
||||
let phase_str = first.message.as_deref().unwrap_or("");
|
||||
let extra = if running.len() > 1 {
|
||||
format!(" (+{})", running.len() - 1)
|
||||
let extra = if active.len() > 1 {
|
||||
format!(" (+{})", active.len() - 1)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let display = if phase_str.is_empty() {
|
||||
let display = if first.cancellation_requested {
|
||||
format!(
|
||||
"{}: {}{progress_str}{extra}",
|
||||
t(locale, "tasks.statusCancelling"),
|
||||
first.label
|
||||
)
|
||||
} else if first.status == TaskStatus::Pending {
|
||||
format!(
|
||||
"{}: {}{extra}",
|
||||
t(locale, "tasks.statusPending"),
|
||||
first.label
|
||||
)
|
||||
} else if phase_str.is_empty() {
|
||||
format!("{}{progress_str}{extra}", first.label)
|
||||
} else {
|
||||
format!("{phase_str}{progress_str}{extra}")
|
||||
|
||||
Reference in New Issue
Block a user