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