fix: more work on background tasks
This commit is contained in:
@@ -8,7 +8,7 @@ The project is under active development. Core blogging workflows are broadly ava
|
||||
|
||||
## Available Features
|
||||
|
||||
- Native Iced desktop workspace with localized menus, tabs, anchored editor popovers, automatically paged post/media sidebars with locale-aware post dates, calendar months, and relative-dated entity lists, row deletion, dialogs, tasks, embedded Wry previews, a live Pico CSS theme editor, and per-project restart restoration of the active activity, shell visibility, and open editor tabs.
|
||||
- Native Iced desktop workspace with localized menus, tabs, anchored editor popovers, automatically paged post/media sidebars with locale-aware post dates, calendar months, and relative-dated entity lists, row deletion, dialogs, embedded Wry previews, a live Pico CSS theme editor, and per-project restart restoration of the active activity, shell visibility, and open editor tabs. Its shared task UI keeps queued work off blocking workers, shows every active local or remote task plus recent history, and supports cooperative cancellation without oversubscribing background work.
|
||||
- Post and translation authoring with change-aware draft/published/archive lifecycle, file-backed change discard, canonical draft reopening after manual translation edits, non-disruptive automatic translation, desktop archive/unarchive actions, in-place published-frontmatter updates, metadata, tags, categories, live link/backlink graphs, media, and batch gallery-image import.
|
||||
- Media import including HEIC/HEIF decoding, q80 WebP thumbnails (plus q85 AI JPEGs), metadata translations, filters, validation, post assignment, and sequential drag-and-drop insertion into post editors.
|
||||
- WordPress WXR migration with saved analyses, HTML-to-Markdown and shortcode conversion, conflict/taxonomy review, recoverable 500-item execution batches, media-parent linking, progress reporting, and optional AI-assisted taxonomy mapping.
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -3687,6 +3687,10 @@ local result = bds.publish.upload_site({ host = "example.com", username = "autho
|
||||
|
||||
```lua
|
||||
{
|
||||
cancellable = true,
|
||||
cancellation_requested = true,
|
||||
group_id = "example-id",
|
||||
group_name = "example",
|
||||
id = "example-id",
|
||||
message = "Working",
|
||||
name = "Example",
|
||||
@@ -4705,6 +4709,10 @@ local result = bds.tasks.get("example-id")
|
||||
|
||||
```lua
|
||||
{
|
||||
cancellable = true,
|
||||
cancellation_requested = true,
|
||||
group_id = "example-id",
|
||||
group_name = "example",
|
||||
id = "example-id",
|
||||
message = "Working",
|
||||
name = "Example",
|
||||
@@ -4746,6 +4754,10 @@ local result = bds.tasks.status_snapshot()
|
||||
running_count = 1,
|
||||
tasks = {
|
||||
{
|
||||
cancellable = true,
|
||||
cancellation_requested = true,
|
||||
group_id = "example-id",
|
||||
group_name = "example",
|
||||
id = "example-id",
|
||||
message = "Working",
|
||||
name = "Example",
|
||||
@@ -4817,6 +4829,10 @@ local result = bds.tasks.get_all()
|
||||
```lua
|
||||
{
|
||||
{
|
||||
cancellable = true,
|
||||
cancellation_requested = true,
|
||||
group_id = "example-id",
|
||||
group_name = "example",
|
||||
id = "example-id",
|
||||
message = "Working",
|
||||
name = "Example",
|
||||
@@ -4855,6 +4871,10 @@ local result = bds.tasks.get_running()
|
||||
```lua
|
||||
{
|
||||
{
|
||||
cancellable = true,
|
||||
cancellation_requested = true,
|
||||
group_id = "example-id",
|
||||
group_name = "example",
|
||||
id = "example-id",
|
||||
message = "Working",
|
||||
name = "Example",
|
||||
|
||||
@@ -269,6 +269,10 @@ Public task snapshot returned by the task manager.
|
||||
|
||||
```lua
|
||||
{
|
||||
cancellable = true,
|
||||
cancellation_requested = true,
|
||||
group_id = "example-id",
|
||||
group_name = "example",
|
||||
id = "example-id",
|
||||
message = "Working",
|
||||
name = "Example",
|
||||
@@ -279,6 +283,10 @@ Public task snapshot returned by the task manager.
|
||||
|
||||
| Field | Type | Required | Meaning |
|
||||
| --- | --- | --- | --- |
|
||||
| `cancellable` | `boolean` | Yes | Whether a cancellation request can still be submitted. |
|
||||
| `cancellation_requested` | `boolean` | Yes | Whether cooperative cancellation is in progress. |
|
||||
| `group_id` | `string \| nil` | No | Identifier shared by tasks in one workflow. |
|
||||
| `group_name` | `string \| nil` | No | Human-readable workflow name. |
|
||||
| `id` | `string` | Yes | Stable record identifier. |
|
||||
| `message` | `string \| nil` | No | Latest user-facing task message. |
|
||||
| `name` | `string` | Yes | Human-readable name. |
|
||||
@@ -298,6 +306,10 @@ Aggregate task status snapshot.
|
||||
running_count = 1,
|
||||
tasks = {
|
||||
{
|
||||
cancellable = true,
|
||||
cancellation_requested = true,
|
||||
group_id = "example-id",
|
||||
group_name = "example",
|
||||
id = "example-id",
|
||||
message = "Working",
|
||||
name = "Example",
|
||||
|
||||
@@ -1833,6 +1833,10 @@
|
||||
{
|
||||
"description": "Public task snapshot returned by the task manager.",
|
||||
"fields": {
|
||||
"cancellable": "boolean",
|
||||
"cancellation_requested": "boolean",
|
||||
"group_id": "string | nil",
|
||||
"group_name": "string | nil",
|
||||
"id": "string",
|
||||
"message": "string | nil",
|
||||
"name": "string",
|
||||
|
||||
@@ -33,6 +33,7 @@ editor-matchingTags = Passende Tags
|
||||
editor-createTag = Tag erstellen: { $name }
|
||||
duplicates-title = Doppelte Beiträge finden
|
||||
duplicates-searching = Duplikate werden gesucht…
|
||||
duplicates-complete = Duplikatsuche abgeschlossen
|
||||
duplicates-count = { $count } mögliche Paare
|
||||
duplicates-disabled = Semantische Ähnlichkeit ist für dieses Projekt nicht aktiviert.
|
||||
duplicates-empty = Keine möglichen doppelten Beiträge gefunden.
|
||||
@@ -187,6 +188,7 @@ tasks-noActive = Keine aktiven Aufgaben
|
||||
tasks-cancelTask = Aufgabe abbrechen
|
||||
tasks-statusPending = Ausstehend
|
||||
tasks-statusRunning = Laufend
|
||||
tasks-statusCancelling = Wird abgebrochen…
|
||||
tasks-statusCompleted = Abgeschlossen
|
||||
tasks-statusFailed = Fehlgeschlagen: { $error }
|
||||
tasks-statusCancelled = Abgebrochen
|
||||
@@ -238,6 +240,7 @@ siteValidation-apply = Validierung anwenden
|
||||
siteValidation-applying = Wird angewendet...
|
||||
siteValidation-idle = Starte die Website-Validierung, um generierte Dateien mit dem erwarteten Output zu vergleichen.
|
||||
siteValidation-running = Validierung läuft...
|
||||
siteValidation-complete = Validierung abgeschlossen
|
||||
siteValidation-clean = Es wurden keine fehlenden, zusätzlichen oder veralteten generierten Dateien gefunden.
|
||||
siteValidation-error = Validierung fehlgeschlagen
|
||||
siteValidation-missing = Fehlende Dateien
|
||||
@@ -569,6 +572,8 @@ settings-rebuildMedia = Medien neu aufbauen
|
||||
settings-rebuildScripts = Skripte neu aufbauen
|
||||
settings-rebuildTemplates = Vorlagen neu aufbauen
|
||||
settings-rebuildLinks = Links neu aufbauen
|
||||
settings-rebuildPostLinks = Beitragslinks werden neu aufgebaut…
|
||||
settings-rebuildMediaLinks = Medienlinks werden neu aufgebaut…
|
||||
settings-regenerateThumbnails = Vorschaubilder regenerieren
|
||||
settings-openDataFolder = Datenordner öffnen
|
||||
settings-installCli = CLI installieren
|
||||
@@ -641,6 +646,10 @@ metadataDiff-dbToFile = Datenbank verwenden
|
||||
metadataDiff-orphans = Verwaiste Dateien
|
||||
metadataDiff-errors = Fehler
|
||||
metadataDiff-repaired = Metadatenabweichung repariert
|
||||
metadataDiff-repair = Metadaten werden repariert…
|
||||
metadataDiff-complete = Metadatenvergleich abgeschlossen
|
||||
metadataDiff-importOrphan = Datei importieren
|
||||
metadataDiff-orphanImported = Verwaiste Datei importiert
|
||||
metadataDiff-field = { $field }: DB = { $database } · Datei = { $file }
|
||||
metadataDiff-entity = { $entity } · { $path }
|
||||
metadataDiff-orphan = { $reason } · { $path }
|
||||
@@ -659,6 +668,7 @@ engine-progress-mediaItem = Medien: { $current }/{ $total } — { $name }
|
||||
engine-progress-rebuildingTemplates = Vorlagen werden neu aufgebaut…
|
||||
engine-progress-rebuildingScripts = Skripte werden neu aufgebaut…
|
||||
engine-progress-importingTags = Schlagwörter werden importiert…
|
||||
engine-progress-rebuildingThumbnails = Fehlende Vorschaubilder werden neu aufgebaut…
|
||||
engine-progress-refreshingSemanticIndex = Semantischer Index wird aktualisiert…
|
||||
engine-progress-rebuildComplete = Neuaufbau abgeschlossen
|
||||
translationValidation-run = Prüfen
|
||||
@@ -683,6 +693,9 @@ engine-renderingPages = Seiten werden gerendert ({ $current }/{ $total })
|
||||
engine-generatedPage = Erstellt { $url } ({ $current }/{ $total })
|
||||
engine-rewrotePage = Neu geschrieben { $url } ({ $current }/{ $total })
|
||||
engine-renderSiteGroup = Website rendern
|
||||
engine-prepareGeneration = Website-Generierung vorbereiten
|
||||
engine-preparingGeneration = Routen und Vorlagen werden vorbereitet…
|
||||
engine-generationPrepared = Website-Generierung vorbereitet
|
||||
engine-forceRenderSiteGroup = Website vollständig neu generieren
|
||||
engine-applyValidationGroup = Website-Validierung anwenden
|
||||
engine-renderSiteCore = Website-Kern rendern
|
||||
@@ -692,7 +705,7 @@ engine-renderTagArchives = Schlagwortarchive rendern
|
||||
engine-renderDateArchives = Datumsarchive rendern
|
||||
engine-buildSearchIndex = Suchindex erstellen
|
||||
engine-builtSearchFile = { $path } erstellt ({ $current }/{ $total })
|
||||
engine-generationCancelled = Website-Erstellung abgebrochen
|
||||
engine-generationCancellationRequested = Website-Erstellung wird abgebrochen…
|
||||
engine-generationSummary = Website-Erstellung abgeschlossen: geschrieben={ $written }, übersprungen={ $skipped }, gelöscht={ $deleted }, Ausgabe={ $output }
|
||||
engine-publicUrlRequired = Vor der Website-Erstellung ist eine öffentliche URL erforderlich
|
||||
settings-imageImportConcurrency = Gleichzeitige Bildimporte (1–8)
|
||||
@@ -845,6 +858,7 @@ tui-categoryEditing = Kategorien bearbeiten
|
||||
tui-categoryEditingGuiOnly = Kategorien können weiterhin in der Desktop-Anwendung hinzugefügt, bearbeitet und entfernt werden
|
||||
tui-openItemDeleted = Der geöffnete Eintrag wurde von einem anderen Client gelöscht.
|
||||
tui-taskRunning = { $label } — läuft
|
||||
tui-taskCancelling = { $label } — wird abgebrochen…
|
||||
tui-taskProgress = { $label } — { $percent } %
|
||||
tui-taskComplete = { $label } — abgeschlossen
|
||||
tui-taskFailed = { $label } — { $error }
|
||||
|
||||
@@ -33,6 +33,7 @@ editor-matchingTags = Matching tags
|
||||
editor-createTag = Create tag: { $name }
|
||||
duplicates-title = Find Duplicate Posts
|
||||
duplicates-searching = Searching for duplicates…
|
||||
duplicates-complete = Duplicate search complete
|
||||
duplicates-count = { $count } potential pairs
|
||||
duplicates-disabled = Semantic similarity is not enabled for this project.
|
||||
duplicates-empty = No potential duplicate posts found.
|
||||
@@ -187,6 +188,7 @@ tasks-noActive = No active tasks
|
||||
tasks-cancelTask = Cancel task
|
||||
tasks-statusPending = Pending
|
||||
tasks-statusRunning = Running
|
||||
tasks-statusCancelling = Cancelling…
|
||||
tasks-statusCompleted = Completed
|
||||
tasks-statusFailed = Failed: { $error }
|
||||
tasks-statusCancelled = Cancelled
|
||||
@@ -238,6 +240,7 @@ siteValidation-apply = Apply Validation
|
||||
siteValidation-applying = Applying...
|
||||
siteValidation-idle = Run site validation to compare generated files against expected output.
|
||||
siteValidation-running = Validation in progress...
|
||||
siteValidation-complete = Validation complete
|
||||
siteValidation-clean = No missing, extra, or stale generated files were found.
|
||||
siteValidation-error = Validation failed
|
||||
siteValidation-missing = Missing Files
|
||||
@@ -569,6 +572,8 @@ settings-rebuildMedia = Rebuild Media
|
||||
settings-rebuildScripts = Rebuild Scripts
|
||||
settings-rebuildTemplates = Rebuild Templates
|
||||
settings-rebuildLinks = Rebuild Links
|
||||
settings-rebuildPostLinks = Rebuilding post links…
|
||||
settings-rebuildMediaLinks = Rebuilding media links…
|
||||
settings-regenerateThumbnails = Regenerate Thumbnails
|
||||
settings-openDataFolder = Open Data Folder
|
||||
settings-installCli = Install CLI
|
||||
@@ -641,6 +646,10 @@ metadataDiff-dbToFile = Use Database
|
||||
metadataDiff-orphans = Orphan files
|
||||
metadataDiff-errors = Errors
|
||||
metadataDiff-repaired = Metadata difference repaired
|
||||
metadataDiff-repair = Repairing metadata…
|
||||
metadataDiff-complete = Metadata comparison complete
|
||||
metadataDiff-importOrphan = Import File
|
||||
metadataDiff-orphanImported = Orphan file imported
|
||||
metadataDiff-field = { $field }: DB = { $database } · File = { $file }
|
||||
metadataDiff-entity = { $entity } · { $path }
|
||||
metadataDiff-orphan = { $reason } · { $path }
|
||||
@@ -659,6 +668,7 @@ engine-progress-mediaItem = Media: { $current }/{ $total } — { $name }
|
||||
engine-progress-rebuildingTemplates = Rebuilding templates…
|
||||
engine-progress-rebuildingScripts = Rebuilding scripts…
|
||||
engine-progress-importingTags = Importing tags…
|
||||
engine-progress-rebuildingThumbnails = Rebuilding missing thumbnails…
|
||||
engine-progress-refreshingSemanticIndex = Refreshing semantic index…
|
||||
engine-progress-rebuildComplete = Rebuild complete
|
||||
translationValidation-run = Validate
|
||||
@@ -683,6 +693,9 @@ engine-renderingPages = Rendering pages ({ $current }/{ $total })
|
||||
engine-generatedPage = Generated { $url } ({ $current }/{ $total })
|
||||
engine-rewrotePage = Rewrote { $url } ({ $current }/{ $total })
|
||||
engine-renderSiteGroup = Render Site
|
||||
engine-prepareGeneration = Prepare site generation
|
||||
engine-preparingGeneration = Preparing routes and templates…
|
||||
engine-generationPrepared = Site generation prepared
|
||||
engine-forceRenderSiteGroup = Force Render Site
|
||||
engine-applyValidationGroup = Apply Site Validation
|
||||
engine-renderSiteCore = Render Site Core
|
||||
@@ -692,7 +705,7 @@ engine-renderTagArchives = Render Tag Archives
|
||||
engine-renderDateArchives = Render Date Archives
|
||||
engine-buildSearchIndex = Build Search Index
|
||||
engine-builtSearchFile = Built { $path } ({ $current }/{ $total })
|
||||
engine-generationCancelled = Site generation cancelled
|
||||
engine-generationCancellationRequested = Cancelling site generation…
|
||||
engine-generationSummary = Site generation complete: written={ $written }, skipped={ $skipped }, deleted={ $deleted }, output={ $output }
|
||||
engine-publicUrlRequired = Public URL is required before generating the site
|
||||
settings-imageImportConcurrency = Image Import Concurrency (1–8)
|
||||
@@ -845,6 +858,7 @@ tui-categoryEditing = Category Editing
|
||||
tui-categoryEditingGuiOnly = Category add, edit, and removal remain in the desktop application
|
||||
tui-openItemDeleted = The open item was deleted by another client.
|
||||
tui-taskRunning = { $label } — running
|
||||
tui-taskCancelling = { $label } — cancelling…
|
||||
tui-taskProgress = { $label } — { $percent }%
|
||||
tui-taskComplete = { $label } — complete
|
||||
tui-taskFailed = { $label } — { $error }
|
||||
|
||||
@@ -33,6 +33,7 @@ editor-matchingTags = Etiquetas coincidentes
|
||||
editor-createTag = Crear etiqueta: { $name }
|
||||
duplicates-title = Buscar publicaciones duplicadas
|
||||
duplicates-searching = Buscando duplicados…
|
||||
duplicates-complete = Búsqueda de duplicados completada
|
||||
duplicates-count = { $count } pares potenciales
|
||||
duplicates-disabled = La similitud semántica no está activada para este proyecto.
|
||||
duplicates-empty = No se encontraron publicaciones posiblemente duplicadas.
|
||||
@@ -187,6 +188,7 @@ tasks-noActive = No hay tareas activas
|
||||
tasks-cancelTask = Cancelar tarea
|
||||
tasks-statusPending = Pendiente
|
||||
tasks-statusRunning = En ejecución
|
||||
tasks-statusCancelling = Cancelando…
|
||||
tasks-statusCompleted = Completada
|
||||
tasks-statusFailed = Error: { $error }
|
||||
tasks-statusCancelled = Cancelada
|
||||
@@ -238,6 +240,7 @@ siteValidation-apply = Aplicar validación
|
||||
siteValidation-applying = Aplicando...
|
||||
siteValidation-idle = Ejecuta la validación del sitio para comparar los archivos generados con la salida esperada.
|
||||
siteValidation-running = Validación en curso...
|
||||
siteValidation-complete = Validación completada
|
||||
siteValidation-clean = No se encontraron archivos generados faltantes, extra o desactualizados.
|
||||
siteValidation-error = La validación falló
|
||||
siteValidation-missing = Archivos faltantes
|
||||
@@ -569,6 +572,8 @@ settings-rebuildMedia = Reconstruir medios
|
||||
settings-rebuildScripts = Reconstruir scripts
|
||||
settings-rebuildTemplates = Reconstruir plantillas
|
||||
settings-rebuildLinks = Reconstruir enlaces
|
||||
settings-rebuildPostLinks = Reconstruyendo enlaces de entradas…
|
||||
settings-rebuildMediaLinks = Reconstruyendo enlaces de medios…
|
||||
settings-regenerateThumbnails = Regenerar miniaturas
|
||||
settings-openDataFolder = Abrir carpeta de datos
|
||||
settings-installCli = Instalar CLI
|
||||
@@ -641,6 +646,10 @@ metadataDiff-dbToFile = Usar base de datos
|
||||
metadataDiff-orphans = Archivos huérfanos
|
||||
metadataDiff-errors = Errores
|
||||
metadataDiff-repaired = Diferencia de metadatos reparada
|
||||
metadataDiff-repair = Reparando metadatos…
|
||||
metadataDiff-complete = Comparación de metadatos completada
|
||||
metadataDiff-importOrphan = Importar archivo
|
||||
metadataDiff-orphanImported = Archivo huérfano importado
|
||||
metadataDiff-field = { $field }: BD = { $database } · Archivo = { $file }
|
||||
metadataDiff-entity = { $entity } · { $path }
|
||||
metadataDiff-orphan = { $reason } · { $path }
|
||||
@@ -659,6 +668,7 @@ engine-progress-mediaItem = Medios: { $current }/{ $total } — { $name }
|
||||
engine-progress-rebuildingTemplates = Reconstruyendo las plantillas…
|
||||
engine-progress-rebuildingScripts = Reconstruyendo los scripts…
|
||||
engine-progress-importingTags = Importando las etiquetas…
|
||||
engine-progress-rebuildingThumbnails = Reconstruyendo miniaturas faltantes…
|
||||
engine-progress-refreshingSemanticIndex = Actualizando el índice semántico…
|
||||
engine-progress-rebuildComplete = Reconstrucción completada
|
||||
translationValidation-run = Validar
|
||||
@@ -683,6 +693,9 @@ engine-renderingPages = Renderizando páginas ({ $current }/{ $total })
|
||||
engine-generatedPage = Generado { $url } ({ $current }/{ $total })
|
||||
engine-rewrotePage = Reescrito { $url } ({ $current }/{ $total })
|
||||
engine-renderSiteGroup = Renderizar sitio
|
||||
engine-prepareGeneration = Preparar la generación del sitio
|
||||
engine-preparingGeneration = Preparando rutas y plantillas…
|
||||
engine-generationPrepared = Generación del sitio preparada
|
||||
engine-forceRenderSiteGroup = Forzar la regeneración del sitio
|
||||
engine-applyValidationGroup = Aplicar validación del sitio
|
||||
engine-renderSiteCore = Renderizar núcleo del sitio
|
||||
@@ -692,7 +705,7 @@ engine-renderTagArchives = Renderizar archivos de etiquetas
|
||||
engine-renderDateArchives = Renderizar archivos por fecha
|
||||
engine-buildSearchIndex = Crear índice de búsqueda
|
||||
engine-builtSearchFile = Creado { $path } ({ $current }/{ $total })
|
||||
engine-generationCancelled = Generación del sitio cancelada
|
||||
engine-generationCancellationRequested = Cancelando la generación del sitio…
|
||||
engine-generationSummary = Generación completada: escritos={ $written }, omitidos={ $skipped }, eliminados={ $deleted }, salida={ $output }
|
||||
engine-publicUrlRequired = Se requiere una URL pública antes de generar el sitio
|
||||
settings-imageImportConcurrency = Importaciones de imágenes simultáneas (1–8)
|
||||
@@ -845,6 +858,7 @@ tui-categoryEditing = Edición de categorías
|
||||
tui-categoryEditingGuiOnly = La creación, edición y eliminación de categorías permanecen en la aplicación de escritorio
|
||||
tui-openItemDeleted = Otro cliente eliminó el elemento abierto.
|
||||
tui-taskRunning = { $label } — en curso
|
||||
tui-taskCancelling = { $label } — cancelando…
|
||||
tui-taskProgress = { $label } — { $percent } %
|
||||
tui-taskComplete = { $label } — completado
|
||||
tui-taskFailed = { $label } — { $error }
|
||||
|
||||
@@ -33,6 +33,7 @@ editor-matchingTags = Étiquettes correspondantes
|
||||
editor-createTag = Créer l’étiquette : { $name }
|
||||
duplicates-title = Rechercher les articles en double
|
||||
duplicates-searching = Recherche des doublons…
|
||||
duplicates-complete = Recherche des doublons terminée
|
||||
duplicates-count = { $count } paires potentielles
|
||||
duplicates-disabled = La similarité sémantique n’est pas activée pour ce projet.
|
||||
duplicates-empty = Aucun doublon potentiel trouvé.
|
||||
@@ -187,6 +188,7 @@ tasks-noActive = Aucune tâche active
|
||||
tasks-cancelTask = Annuler la tâche
|
||||
tasks-statusPending = En attente
|
||||
tasks-statusRunning = En cours
|
||||
tasks-statusCancelling = Annulation…
|
||||
tasks-statusCompleted = Terminée
|
||||
tasks-statusFailed = Échec : { $error }
|
||||
tasks-statusCancelled = Annulée
|
||||
@@ -238,6 +240,7 @@ siteValidation-apply = Appliquer la validation
|
||||
siteValidation-applying = Application en cours...
|
||||
siteValidation-idle = Lancez la validation du site pour comparer les fichiers générés à la sortie attendue.
|
||||
siteValidation-running = Validation en cours...
|
||||
siteValidation-complete = Validation terminée
|
||||
siteValidation-clean = Aucun fichier généré manquant, supplémentaire ou obsolète n'a été trouvé.
|
||||
siteValidation-error = La validation a échoué
|
||||
siteValidation-missing = Fichiers manquants
|
||||
@@ -569,6 +572,8 @@ settings-rebuildMedia = Reconstruire les médias
|
||||
settings-rebuildScripts = Reconstruire les scripts
|
||||
settings-rebuildTemplates = Reconstruire les modèles
|
||||
settings-rebuildLinks = Reconstruire les liens
|
||||
settings-rebuildPostLinks = Reconstruction des liens d’articles…
|
||||
settings-rebuildMediaLinks = Reconstruction des liens de médias…
|
||||
settings-regenerateThumbnails = Régénérer les miniatures
|
||||
settings-openDataFolder = Ouvrir le dossier de données
|
||||
settings-installCli = Installer la CLI
|
||||
@@ -641,6 +646,10 @@ metadataDiff-dbToFile = Utiliser la base
|
||||
metadataDiff-orphans = Fichiers orphelins
|
||||
metadataDiff-errors = Erreurs
|
||||
metadataDiff-repaired = Différence de métadonnées réparée
|
||||
metadataDiff-repair = Réparation des métadonnées…
|
||||
metadataDiff-complete = Comparaison des métadonnées terminée
|
||||
metadataDiff-importOrphan = Importer le fichier
|
||||
metadataDiff-orphanImported = Fichier orphelin importé
|
||||
metadataDiff-field = { $field } : base = { $database } · Fichier = { $file }
|
||||
metadataDiff-entity = { $entity } · { $path }
|
||||
metadataDiff-orphan = { $reason } · { $path }
|
||||
@@ -659,6 +668,7 @@ engine-progress-mediaItem = Médias : { $current }/{ $total } — { $name }
|
||||
engine-progress-rebuildingTemplates = Reconstruction des modèles…
|
||||
engine-progress-rebuildingScripts = Reconstruction des scripts…
|
||||
engine-progress-importingTags = Importation des étiquettes…
|
||||
engine-progress-rebuildingThumbnails = Reconstruction des miniatures manquantes…
|
||||
engine-progress-refreshingSemanticIndex = Actualisation de l’index sémantique…
|
||||
engine-progress-rebuildComplete = Reconstruction terminée
|
||||
translationValidation-run = Valider
|
||||
@@ -683,6 +693,9 @@ engine-renderingPages = Rendu des pages ({ $current }/{ $total })
|
||||
engine-generatedPage = Généré { $url } ({ $current }/{ $total })
|
||||
engine-rewrotePage = Réécrit { $url } ({ $current }/{ $total })
|
||||
engine-renderSiteGroup = Générer le site
|
||||
engine-prepareGeneration = Préparer la génération du site
|
||||
engine-preparingGeneration = Préparation des routes et des modèles…
|
||||
engine-generationPrepared = Génération du site préparée
|
||||
engine-forceRenderSiteGroup = Forcer la régénération du site
|
||||
engine-applyValidationGroup = Appliquer la validation du site
|
||||
engine-renderSiteCore = Générer le cœur du site
|
||||
@@ -692,7 +705,7 @@ engine-renderTagArchives = Générer les archives de mots-clés
|
||||
engine-renderDateArchives = Générer les archives chronologiques
|
||||
engine-buildSearchIndex = Construire l’index de recherche
|
||||
engine-builtSearchFile = Construit { $path } ({ $current }/{ $total })
|
||||
engine-generationCancelled = Génération du site annulée
|
||||
engine-generationCancellationRequested = Annulation de la génération du site…
|
||||
engine-generationSummary = Génération terminée : écrits={ $written }, ignorés={ $skipped }, supprimés={ $deleted }, sortie={ $output }
|
||||
engine-publicUrlRequired = Une URL publique est requise avant de générer le site
|
||||
settings-imageImportConcurrency = Imports d’images simultanés (1–8)
|
||||
@@ -845,6 +858,7 @@ tui-categoryEditing = Modification des catégories
|
||||
tui-categoryEditingGuiOnly = L’ajout, la modification et la suppression de catégories restent dans l’application de bureau
|
||||
tui-openItemDeleted = L’élément ouvert a été supprimé par un autre client.
|
||||
tui-taskRunning = { $label } — en cours
|
||||
tui-taskCancelling = { $label } — annulation…
|
||||
tui-taskProgress = { $label } — { $percent } %
|
||||
tui-taskComplete = { $label } — terminé
|
||||
tui-taskFailed = { $label } — { $error }
|
||||
|
||||
@@ -33,6 +33,7 @@ editor-matchingTags = Tag corrispondenti
|
||||
editor-createTag = Crea tag: { $name }
|
||||
duplicates-title = Trova post duplicati
|
||||
duplicates-searching = Ricerca duplicati…
|
||||
duplicates-complete = Ricerca duplicati completata
|
||||
duplicates-count = { $count } coppie potenziali
|
||||
duplicates-disabled = La somiglianza semantica non è abilitata per questo progetto.
|
||||
duplicates-empty = Nessun possibile post duplicato trovato.
|
||||
@@ -187,6 +188,7 @@ tasks-noActive = Nessuna attività attiva
|
||||
tasks-cancelTask = Annulla attività
|
||||
tasks-statusPending = In attesa
|
||||
tasks-statusRunning = In esecuzione
|
||||
tasks-statusCancelling = Annullamento…
|
||||
tasks-statusCompleted = Completata
|
||||
tasks-statusFailed = Non riuscita: { $error }
|
||||
tasks-statusCancelled = Annullata
|
||||
@@ -238,6 +240,7 @@ siteValidation-apply = Applica validazione
|
||||
siteValidation-applying = Applicazione in corso...
|
||||
siteValidation-idle = Esegui la validazione del sito per confrontare i file generati con l'output previsto.
|
||||
siteValidation-running = Validazione in corso...
|
||||
siteValidation-complete = Validazione completata
|
||||
siteValidation-clean = Non sono stati trovati file generati mancanti, aggiuntivi o obsoleti.
|
||||
siteValidation-error = Validazione non riuscita
|
||||
siteValidation-missing = File mancanti
|
||||
@@ -569,6 +572,8 @@ settings-rebuildMedia = Ricostruisci media
|
||||
settings-rebuildScripts = Ricostruisci script
|
||||
settings-rebuildTemplates = Ricostruisci modelli
|
||||
settings-rebuildLinks = Ricostruisci link
|
||||
settings-rebuildPostLinks = Ricostruzione dei link degli articoli…
|
||||
settings-rebuildMediaLinks = Ricostruzione dei link multimediali…
|
||||
settings-regenerateThumbnails = Rigenera miniature
|
||||
settings-openDataFolder = Apri cartella dati
|
||||
settings-installCli = Installa CLI
|
||||
@@ -641,6 +646,10 @@ metadataDiff-dbToFile = Usa database
|
||||
metadataDiff-orphans = File orfani
|
||||
metadataDiff-errors = Errori
|
||||
metadataDiff-repaired = Differenza nei metadati riparata
|
||||
metadataDiff-repair = Riparazione dei metadati…
|
||||
metadataDiff-complete = Confronto dei metadati completato
|
||||
metadataDiff-importOrphan = Importa file
|
||||
metadataDiff-orphanImported = File orfano importato
|
||||
metadataDiff-field = { $field }: DB = { $database } · File = { $file }
|
||||
metadataDiff-entity = { $entity } · { $path }
|
||||
metadataDiff-orphan = { $reason } · { $path }
|
||||
@@ -659,6 +668,7 @@ engine-progress-mediaItem = Media: { $current }/{ $total } — { $name }
|
||||
engine-progress-rebuildingTemplates = Ricostruzione dei modelli…
|
||||
engine-progress-rebuildingScripts = Ricostruzione degli script…
|
||||
engine-progress-importingTags = Importazione dei tag…
|
||||
engine-progress-rebuildingThumbnails = Ricostruzione delle miniature mancanti…
|
||||
engine-progress-refreshingSemanticIndex = Aggiornamento dell’indice semantico…
|
||||
engine-progress-rebuildComplete = Ricostruzione completata
|
||||
translationValidation-run = Verifica
|
||||
@@ -683,6 +693,9 @@ engine-renderingPages = Rendering delle pagine ({ $current }/{ $total })
|
||||
engine-generatedPage = Generato { $url } ({ $current }/{ $total })
|
||||
engine-rewrotePage = Riscritto { $url } ({ $current }/{ $total })
|
||||
engine-renderSiteGroup = Genera sito
|
||||
engine-prepareGeneration = Prepara la generazione del sito
|
||||
engine-preparingGeneration = Preparazione di percorsi e modelli…
|
||||
engine-generationPrepared = Generazione del sito preparata
|
||||
engine-forceRenderSiteGroup = Forza la rigenerazione del sito
|
||||
engine-applyValidationGroup = Applica convalida del sito
|
||||
engine-renderSiteCore = Genera nucleo del sito
|
||||
@@ -692,7 +705,7 @@ engine-renderTagArchives = Genera archivi dei tag
|
||||
engine-renderDateArchives = Genera archivi per data
|
||||
engine-buildSearchIndex = Crea indice di ricerca
|
||||
engine-builtSearchFile = Creato { $path } ({ $current }/{ $total })
|
||||
engine-generationCancelled = Generazione del sito annullata
|
||||
engine-generationCancellationRequested = Annullamento della generazione del sito…
|
||||
engine-generationSummary = Generazione completata: scritti={ $written }, ignorati={ $skipped }, eliminati={ $deleted }, output={ $output }
|
||||
engine-publicUrlRequired = È richiesto un URL pubblico prima di generare il sito
|
||||
settings-imageImportConcurrency = Importazioni immagini simultanee (1–8)
|
||||
@@ -845,6 +858,7 @@ tui-categoryEditing = Modifica delle categorie
|
||||
tui-categoryEditingGuiOnly = L’aggiunta, la modifica e la rimozione delle categorie restano nell’applicazione desktop
|
||||
tui-openItemDeleted = L’elemento aperto è stato eliminato da un altro client.
|
||||
tui-taskRunning = { $label } — in esecuzione
|
||||
tui-taskCancelling = { $label } — annullamento…
|
||||
tui-taskProgress = { $label } — { $percent }%
|
||||
tui-taskComplete = { $label } — completato
|
||||
tui-taskFailed = { $label } — { $error }
|
||||
|
||||
@@ -44,9 +44,10 @@ surface GenerationRuntimeSurface {
|
||||
-- "Generated" for a full render and "Rewrote" for a validation apply —
|
||||
-- and advances the bar by the number of URLs written.
|
||||
--
|
||||
-- Before section work begins, generation counts its planned URLs. The
|
||||
-- task reports 0/total immediately and advances once per rendered URL,
|
||||
-- matching bDS2's route-based generation progress.
|
||||
-- Route preparation and counting happen in background work before
|
||||
-- section rendering. Validation totals count only prepared URLs
|
||||
-- selected by the validation report, not the whole section. The task
|
||||
-- reports 0/total immediately and advances once per rendered URL.
|
||||
--
|
||||
-- Full generation and validation apply share the same structuring: a
|
||||
-- task group containing one task per section (Render Site Core, Render
|
||||
@@ -310,6 +311,9 @@ rule ApplyValidation {
|
||||
-- unchanged post route refreshes its tracked generation time so the
|
||||
-- automatic follow-up validation does not report it stale again.
|
||||
ensures: GenerateSiteRequested(plan_generation(project_id, sections))
|
||||
-- Observable phases are prepare, affected render sections, optional
|
||||
-- calendar regeneration, search indexing, and final validation. Failure or
|
||||
-- cancellation stops every dependent phase.
|
||||
}
|
||||
|
||||
-- Day-block grouping for archives
|
||||
|
||||
@@ -262,7 +262,9 @@ invariant PanelTabFallback {
|
||||
-- git_log unavailable when neither post nor media tab is active
|
||||
}
|
||||
|
||||
-- Tasks tab: last 10 tasks, newest first, with progress/cancel.
|
||||
-- Tasks tab: every active task plus the 10 newest finished tasks, with
|
||||
-- progress/cancel. Active groups always include every child needed to show a
|
||||
-- complete group even when this exceeds the finished-history limit.
|
||||
-- Tasks with shared group_id are collapsible groups showing bDS2-style
|
||||
-- average child progress; pending children contribute zero.
|
||||
-- Output tab: log entries with copy-all button.
|
||||
@@ -287,6 +289,12 @@ value StatusBarLeft {
|
||||
running_task_overflow: Integer? -- "+N more" count when multiple running
|
||||
}
|
||||
|
||||
invariant StatusBarTaskSelection {
|
||||
-- The indicator selects the first running task, otherwise the first
|
||||
-- pending task. Overflow counts every other running or pending task.
|
||||
-- Status remains typed until its display label is localized.
|
||||
}
|
||||
|
||||
value StatusBarRight {
|
||||
-- In display order (left to right):
|
||||
post_status: String? -- draft|published|archived dot, when post tab active
|
||||
|
||||
@@ -135,6 +135,11 @@ surface ScriptRuntimeSurface {
|
||||
-- sync_from_posts.
|
||||
-- bds.tasks: get, status_snapshot, cancel, get_all, get_running,
|
||||
-- clear_completed.
|
||||
-- get and get_all inspect every retained task rather than the
|
||||
-- status-snapshot history cap; get_all is newest first. get_running
|
||||
-- contains only running tasks. cancel succeeds only for a pending or
|
||||
-- running task. clear_completed removes completed tasks while keeping
|
||||
-- failed and cancelled results.
|
||||
-- bds.sync: check_availability, get_repo_state, get_status,
|
||||
-- get_history, get_remote_state, fetch, pull, push, commit_all.
|
||||
-- bds.publish: upload_site.
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
entity Task {
|
||||
name: String
|
||||
status: pending | running | completed | failed | cancelled
|
||||
source: local | remote
|
||||
cancellable: Boolean
|
||||
cancellation_requested: Boolean
|
||||
progress: Decimal? -- 0.0..1.0
|
||||
message: String?
|
||||
group_id: String? -- Optional task grouping
|
||||
@@ -28,6 +31,7 @@ surface TaskControlSurface {
|
||||
SubmitTaskRequested(name, work)
|
||||
CancelTaskRequested(task)
|
||||
RegisterExternalTaskRequested(name)
|
||||
TaskStateRequested()
|
||||
}
|
||||
|
||||
surface TaskRuntimeSurface {
|
||||
@@ -38,6 +42,8 @@ surface TaskRuntimeSurface {
|
||||
TaskWorkFailed(task, error_message)
|
||||
ProgressReported(task, value, message)
|
||||
FinishedTaskEvictionDue()
|
||||
WorkerCapacityAvailable()
|
||||
TaskWorkStopped(task)
|
||||
}
|
||||
|
||||
surface TaskSurface {
|
||||
@@ -46,6 +52,7 @@ surface TaskSurface {
|
||||
exposes:
|
||||
task.name
|
||||
task.status
|
||||
task.source
|
||||
task.progress when task.progress != null
|
||||
task.message when task.message != null
|
||||
task.group_id when task.group_id != null
|
||||
@@ -74,13 +81,23 @@ invariant FifoQueue {
|
||||
|
||||
rule SubmitTask {
|
||||
when: SubmitTaskRequested(name, work)
|
||||
let running_tasks = Tasks where status = running
|
||||
ensures:
|
||||
let task = Task.created(name: name, status: pending)
|
||||
let task = Task.created(
|
||||
name: name,
|
||||
status: pending,
|
||||
source: local,
|
||||
cancellable: true,
|
||||
cancellation_requested: false
|
||||
)
|
||||
task.status = pending
|
||||
if running_tasks.count < config.max_concurrent:
|
||||
task.status = running
|
||||
TaskStarted(task, work)
|
||||
}
|
||||
|
||||
rule StartNextTask {
|
||||
when: WorkerCapacityAvailable()
|
||||
let pending_tasks = Tasks where status = pending
|
||||
requires: pending_tasks.count > 0
|
||||
ensures: pending_tasks.first.status = running
|
||||
ensures: TaskStarted(pending_tasks.first)
|
||||
}
|
||||
|
||||
rule CompleteTask {
|
||||
@@ -99,8 +116,28 @@ rule FailTask {
|
||||
|
||||
rule CancelTask {
|
||||
when: CancelTaskRequested(task)
|
||||
requires: task.status = running or task.status = pending
|
||||
-- Cancellation uses a runtime-specific cancellation mechanism
|
||||
requires: task.status = pending
|
||||
ensures: task.status = cancelled
|
||||
ensures: NextQueuedTaskStarted()
|
||||
}
|
||||
|
||||
rule RequestRunningTaskCancellation {
|
||||
when: CancelTaskRequested(task)
|
||||
requires: task.status = running
|
||||
requires: task.cancellable
|
||||
ensures: task.cancellation_requested = true
|
||||
ensures: StopTaskWorkRequested(task)
|
||||
@guidance
|
||||
-- Long operations observe the request at phase or item boundaries.
|
||||
-- Owned child processes are terminated when the platform permits it.
|
||||
-- The task remains running and occupies its worker slot until work
|
||||
-- actually stops; status surfaces expose cancellation_requested.
|
||||
}
|
||||
|
||||
rule FinishRunningTaskCancellation {
|
||||
when: TaskWorkStopped(task)
|
||||
requires: task.status = running
|
||||
requires: task.cancellation_requested
|
||||
ensures: task.status = cancelled
|
||||
ensures: NextQueuedTaskStarted()
|
||||
}
|
||||
@@ -110,6 +147,9 @@ rule ReportProgress {
|
||||
-- Progress events throttled to 250ms
|
||||
ensures: task.progress = value
|
||||
ensures: task.message = message
|
||||
@guidance
|
||||
-- A report whose value is 1.0 is always accepted so the terminal
|
||||
-- localized phase text cannot be dropped by throttling.
|
||||
}
|
||||
|
||||
invariant ProgressThrottled {
|
||||
@@ -139,11 +179,24 @@ rule EvictFinishedTasks {
|
||||
ensures: not exists task
|
||||
}
|
||||
|
||||
rule PruneFinishedTasksOnAccess {
|
||||
when: TaskStateRequested()
|
||||
for task in Tasks where status in {completed, failed, cancelled}:
|
||||
if now - task.finished_at >= config.finished_task_ttl:
|
||||
ensures: not exists task
|
||||
}
|
||||
|
||||
-- External tasks: lifecycle controlled by caller (e.g., renderer-side scripts)
|
||||
rule RegisterExternalTask {
|
||||
when: RegisterExternalTaskRequested(name)
|
||||
ensures:
|
||||
let task = Task.created(name: name, status: running)
|
||||
let task = Task.created(
|
||||
name: name,
|
||||
status: running,
|
||||
source: remote,
|
||||
cancellable: true,
|
||||
cancellation_requested: false
|
||||
)
|
||||
task.status = running
|
||||
@guidance
|
||||
-- External tasks are not managed by the queue
|
||||
|
||||
Reference in New Issue
Block a user