Browse / github.com/storytold/lightcraft / patch
LR-BEHAV-BG-TASKS: every other job says how it ended (stacked on #3150)
LR-BEHAV-BG-TASKS: every other job says how it ended (stacked on #3150) Stacked on #3150, which is stacked on #1711: waits for #1711, then #3150, then adds the app's other jobs. Own commit: `ae7fa0ffda` only. This part was ported across #3103's new module layout. ## Problem Synchronize Folder, Photo Merge, Lightroom catalog inspect and import, Find Missing Photos, the preview-only recheck and Reload from Disk leave nothing once their toast has gone: a Synchronize that could not do some files logs them and says "Failed 3"; which files and why was only in the log. Every other background task the app runs through `tasks::spawn` also left no record of how it went.
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- nledford (imported from pull request #3151, open)
- Base
662df66f1c08bdb1b1f1d0c1c638c4cef936d0b0- Size
- +1686 −222 in 67 files
- Review
- pending
- Shared
- 2026-10-28
- Id
6292ecbfe96d542236d0ceca
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/activity.rs crates/engine/src/cmd/activity.rs crates/engine/src/cmd/browse.rs crates/engine/src/cmd/geocode.rs crates/engine/src/cmd/previews.rs crates/engine/src/face_download.rs crates/engine/src/model_download.rs crates/engine/src/segment/download.rs crates/engine/src/tests.rs crates/engine/src/tests/folders.rs crates/ui-egui/locales/cs-formats.json crates/ui-egui/locales/cs.json crates/ui-egui/locales/de-formats.json crates/ui-egui/locales/de.json crates/ui-egui/locales/es-formats.json crates/ui-egui/locales/es.json crates/ui-egui/locales/fi-formats.json crates/ui-egui/locales/fi.json crates/ui-egui/locales/fr-formats.json crates/ui-egui/locales/fr.json crates/ui-egui/locales/id-formats.json crates/ui-egui/locales/id.json crates/ui-egui/locales/it-formats.json crates/ui-egui/locales/it.json crates/ui-egui/locales/ja-formats.json crates/ui-egui/locales/ja.json crates/ui-egui/locales/ka-formats.json crates/ui-egui/locales/ka.json crates/ui-egui/locales/pl-formats.json crates/ui-egui/locales/pl.json crates/ui-egui/locales/pt-br-formats.json crates/ui-egui/locales/pt-br.json crates/ui-egui/locales/ru-formats.json crates/ui-egui/locales/ru.json crates/ui-egui/locales/sv-formats.json crates/ui-egui/locales/sv.json crates/ui-egui/locales/uk-formats.json crates/ui-egui/locales/uk.json crates/ui-egui/locales/vi-formats.json crates/ui-egui/locales/vi.json crates/ui-egui/locales/zh-hans-formats.json crates/ui-egui/locales/zh-hans.json crates/ui-egui/locales/zh-hant-formats.json crates/ui-egui/locales/zh-hant.json crates/ui-egui/src/app/frame.rs crates/ui-egui/src/control.rs crates/ui-egui/src/dialogs/import.rs crates/ui-egui/src/dialogs/import/scan.rs crates/ui-egui/src/dialogs/import/task.rs crates/ui-egui/src/dialogs/merge.rs crates/ui-egui/src/dialogs/sam_model.rs crates/ui-egui/src/export_task.rs crates/ui-egui/src/lightroom_import.rs crates/ui-egui/src/menus/menubar/tests.rs crates/ui-egui/src/menus/run.rs crates/ui-egui/src/panels/library_problem.rs crates/ui-egui/src/recheck.rs crates/ui-egui/src/reload.rs crates/ui-egui/src/sync.rs crates/ui-egui/src/tasks.rs crates/ui-egui/src/tests/activity.rs crates/ui-egui/src/tests/print.rs crates/ui-egui/src/tests/recheck.rs crates/ui-egui/src/tests/sync.rs docs/background-tasks.md docs/control-protocol.md docs/parity-checklist.md
@@ -7,6 +7,11 @@ //! check between files or photos: the guard adopts it ([`Cancel::Flag`]), so the stack's ✕, `activity.cancel` and the //! job's own Cancel all set the same atomic. Numbers are worded by the frontend from `done`, `total` and [`Unit`]; a //! detail set here is a name (a file, a model), never a sentence, so the frontend can translate everything else.+//!+//! A job that says how it ended ([`TaskGuard::finish`]) leaves a row behind when there is something to come back+//! to: it ran long enough to walk away from, or it did not go well. That row stays until it is dismissed+//! (`activity.finished`, `activity.dismiss`), for this session. A guard dropped by a panic leaves "stopped+//! unexpectedly" instead of nothing. use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, MutexGuard, PoisonError};@@ -15,6 +20,19 @@ use serde::Serialize; /// The longest label or detail kept, in characters (a file name can be anything). const MAX_TEXT: usize = 200;+/// The longest sentence kept of how a task ended, or of why a file failed.+const MAX_SENTENCE: usize = 400;+/// A task that ended well leaves a row once it has run this long ([`Activity::keep_after`]); a shorter one was+/// watched to its end.+pub const KEEP_AFTER_MS: u64 = 10_000;+/// The most tasks that ended well kept: the oldest give way.+const MAX_DONE: usize = 50;+/// The most ended tasks kept in all. Those that did not go well are kept past [`MAX_DONE`], up to here.+const MAX_FINISHED: usize = 200;+/// The most files a task keeps the reason of ([`Finished::failed`] still counts them all).+pub const MAX_FAILURES: usize = 500;+/// The longest path kept of where a task's results are.+const MAX_PATH: usize = 4096; /// The background tasks in flight. Cheap to clone (one `Arc`), so worker threads can hold it. #[derive(Clone, Default)]@@ -22,13 +40,118 @@ pub struct Activity { inner: Arc<Inner>, } -#[derive(Default)] struct Inner { /// Oldest first. tasks: Mutex<Vec<Arc<Entry>>>,+ /// The tasks that ended and left a row, oldest first.+ finished: Mutex<Vec<Kept>>,+ keep_after_ms: AtomicU64, next: AtomicU64, } +impl Default for Inner {+ fn default() -> Self {+ Inner { tasks: Mutex::default(), finished: Mutex::default(), keep_after_ms: AtomicU64::new(KEEP_AFTER_MS), next: AtomicU64::new(0) }+ }+}++/// How a task ended.+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)]+#[serde(rename_all = "camelCase")]+pub enum Ending {+ /// It did all it was asked to.+ #[default]+ Done,+ /// It ran to its end, but not for every file or photo.+ Problems,+ /// It could not do its work.+ Failed,+ /// It was stopped: ✕, `activity.cancel`, quitting.+ Stopped,+ /// Its worker died (a bug): the guard was dropped by a panic.+ Died,+}++impl Ending {+ /// Whether the person should be told: everything but a clean end.+ pub fn needs_attention(self) -> bool {+ self != Ending::Done+ }+}++/// A file or photo a task could not do, and why.+#[derive(Clone, Debug, PartialEq, Eq, Serialize)]+pub struct Failure {+ pub name: String,+ pub reason: String,+}++/// What a job says of itself when it ends ([`TaskGuard::finish`]).+#[derive(Clone, Debug, Default)]+pub struct Outcome {+ pub ending: Ending,+ /// What happened, as the frontend shows it ("Exported 24 of 25 photos"). Frontend code passes it translated;+ /// engine code leaves it empty, and a frontend words the ending from the task's label.+ pub summary: String,+ /// What else needs reading: what was skipped, or the reason it failed.+ pub detail: String,+ /// The files or photos it could not do. The first [`MAX_FAILURES`] are kept.+ pub failures: Vec<Failure>,+ /// How many it could not do, for a job that counts them without naming them (`failures` counts when it holds+ /// more).+ pub failed: usize,+ /// One of the files it made (or the folder they are in), for showing where they are in the file manager.+ pub path: Option<String>,+}++impl Outcome {+ /// An end that says nothing more than how it went (the frontend words it from the task's name).+ pub fn ended(ending: Ending) -> Self {+ Outcome { ending, ..Default::default() }+ }++ /// The job could not do its work, and this is why.+ pub fn failed(why: impl Into<String>) -> Self {+ Outcome { ending: Ending::Failed, detail: why.into(), ..Default::default() }+ }+}++/// A task that ended, as the frontends see it (`activity.finished`), until it is dismissed.+#[derive(Clone, Debug, PartialEq, Serialize)]+#[serde(rename_all = "camelCase")]+pub struct Finished {+ /// The id it had while it ran.+ pub id: u64,+ pub kind: &'static str,+ /// English; the frontend translates it.+ pub label: String,+ pub ending: Ending,+ pub summary: String,+ pub detail: String,+ pub failures: Vec<Failure>,+ /// How many files or photos failed (`failures` may hold fewer, or none when the job only counted them).+ pub failed: usize,+ /// One of the files it made (or their folder), if it made any.+ pub path: Option<String>,+ /// How long it ran.+ pub ran_ms: u64,+ /// How long ago it ended.+ pub age_ms: u64,+}++struct Kept {+ row: Finished,+ ended: web_time::Instant,+}++fn millis(d: std::time::Duration) -> u64 {+ u64::try_from(d.as_millis()).unwrap_or(u64::MAX)+}++fn sentence(text: &str) -> String {+ text.chars().take(MAX_SENTENCE).collect()+}+ /// How a task can be cancelled. pub enum Cancel { /// Not at all (no ✕): the face scan, AI Denoise.@@ -162,7 +285,7 @@ impl Entry { detail: lock(&self.detail).clone(), cancellable: self.is_cancellable(), cancelling: self.is_cancelled(),- age_ms: u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX),+ age_ms: millis(self.started.elapsed()), } } }@@ -191,7 +314,48 @@ impl Activity { gate: Mutex::new(()), }); lock(&self.inner.tasks).push(entry.clone());- TaskGuard { activity: self.clone(), entry }+ TaskGuard { activity: self.clone(), entry, reported: false }+ }++ /// The tasks that ended and left a row, newest first.+ pub fn finished(&self) -> Vec<Finished> {+ lock(&self.inner.finished).iter().rev().map(|k| Finished { age_ms: millis(k.ended.elapsed()), ..k.row.clone() }).collect()+ }++ /// Let go of the row of the ended task `id`. An unknown id (it may have been dismissed already) is an error.+ pub fn dismiss(&self, id: u64) -> Result<(), String> {+ let mut finished = lock(&self.inner.finished);+ let at = finished.iter().position(|k| k.row.id == id).ok_or_else(|| format!("no finished task {id}"))?;+ finished.remove(at);+ Ok(())+ }++ /// Let go of every ended task's row; returns how many there were.+ pub fn dismiss_all(&self) -> usize {+ let mut finished = lock(&self.inner.finished);+ let n = finished.len();+ finished.clear();+ n+ }++ /// How long a task has to run for a clean end to leave a row ([`KEEP_AFTER_MS`] unless set).+ pub fn keep_after(&self, ms: u64) {+ self.inner.keep_after_ms.store(ms, Ordering::Relaxed);+ }++ /// Keep the row of a task that ended, and make room: of those that ended well the oldest give way first.+ fn keep(&self, row: Finished) {+ let mut finished = lock(&self.inner.finished);+ finished.push(Kept { row, ended: web_time::Instant::now() });+ while finished.iter().filter(|k| k.row.ending == Ending::Done).count() > MAX_DONE {+ let Some(at) = finished.iter().position(|k| k.row.ending == Ending::Done) else { break };+ finished.remove(at);+ }+ while finished.len() > MAX_FINISHED {+ // (a clean one goes before one that did not go well, as long as there is one)+ let at = finished.iter().position(|k| k.row.ending == Ending::Done).unwrap_or(0);+ finished.remove(at);+ } } /// Every task in flight, oldest first.@@ -223,6 +387,8 @@ impl Activity { pub struct TaskGuard { activity: Activity, entry: Arc<Entry>,+ /// The job said how it ended ([`TaskGuard::finish`]).+ reported: bool, } impl TaskGuard {@@ -261,12 +427,61 @@ impl TaskGuard { pub fn handle(&self) -> TaskHandle { TaskHandle { entry: self.entry.clone() } }++ /// The job ended, and this is how. Its row among the running tasks goes; a row among the finished ones takes+ /// its place when there is something to come back to: the task ran for [`KEEP_AFTER_MS`] or more, or it did+ /// not end well. Returns whether that row was kept. A frontend still says at once how the job went (a toast):+ /// the row is what stays, not the announcement.+ pub fn finish(mut self, outcome: Outcome) -> bool {+ self.report(outcome)+ }++ /// Say how the job ended, as [`TaskGuard::finish`] does, but keep its running row until the guard is dropped: for a+ /// job that marks itself finished in between, so that whoever sees the row go finds it finished, and whoever finds+ /// it finished finds the row. Only the first report counts.+ pub fn report(&mut self, outcome: Outcome) -> bool {+ if std::mem::replace(&mut self.reported, true) {+ return false;+ }+ self.leave(outcome)+ }++ fn leave(&self, outcome: Outcome) -> bool {+ let ran_ms = millis(self.entry.started.elapsed());+ if !outcome.ending.needs_attention() && ran_ms < self.activity.inner.keep_after_ms.load(Ordering::Relaxed) {+ return false;+ }+ let failed = outcome.failed.max(outcome.failures.len());+ self.activity.keep(Finished {+ id: self.entry.id,+ kind: self.entry.kind,+ label: self.entry.label.clone(),+ ending: outcome.ending,+ summary: sentence(&outcome.summary),+ detail: sentence(&outcome.detail),+ failures: outcome+ .failures+ .into_iter()+ .take(MAX_FAILURES)+ .map(|f| Failure { name: capped(&f.name), reason: sentence(&f.reason) })+ .collect(),+ failed,+ path: outcome.path.map(|path| path.chars().take(MAX_PATH).collect()),+ ran_ms,+ age_ms: 0,+ });+ true+ } } impl Drop for TaskGuard { fn drop(&mut self) { let id = self.entry.id; lock(&self.activity.inner.tasks).retain(|e| e.id != id);+ // a worker that died never said how it ended: say that it stopped, rather than nothing+ if !self.reported && std::thread::panicking() {+ self.leave(Outcome { ending: Ending::Died, ..Default::default() });+ } } } @@ -309,6 +524,11 @@ mod tests { use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; + /// How many ended tasks did not end well.+ fn problems(a: &Activity) -> usize {+ a.finished().iter().filter(|f| f.ending.needs_attention()).count()+ }+ #[test] fn guard_drop_removes_the_row() { let a = Activity::default();@@ -318,6 +538,127 @@ mod tests { assert!(a.list().is_empty()); } + #[test]+ fn a_long_task_that_ends_well_leaves_a_row_and_a_short_one_does_not() {+ let a = Activity::default();+ // Given a task that ran for less than it takes to walk away+ let quick = a.start("export", "Exporting", Cancel::Yes);+ // When it ends well, Then nothing is kept: it was watched to its end+ assert!(!quick.finish(Outcome { summary: "Exported 2 photos".into(), ..Default::default() }));+ assert!(a.finished().is_empty() && a.list().is_empty());+ // Given a task that ran long enough (here: any time at all)+ a.keep_after(0);+ let long = a.start("export", "Exporting", Cancel::Yes);+ let id = long.id();+ assert!(long.finish(Outcome { summary: "Exported 5,000 photos".into(), path: Some("/out/a.jpg".into()), ..Default::default() }));+ // Then its row among the running tasks is gone, and one among the finished took its place+ assert!(a.list().is_empty());+ let rows = a.finished();+ assert_eq!(rows.len(), 1);+ assert_eq!((rows[0].id, rows[0].kind, rows[0].ending, rows[0].summary.as_str()), (id, "export", Ending::Done, "Exported 5,000 photos"));+ assert_eq!(rows[0].path.as_deref(), Some("/out/a.jpg"));+ assert_eq!(a.finished().first().map(|f| (f.id, f.summary.clone())), Some((id, "Exported 5,000 photos".to_string())));+ // And a clean end asks for nobody's attention+ assert_eq!(problems(&a), 0);+ }++ #[test]+ fn a_task_that_did_not_end_well_always_leaves_a_row_and_asks_for_attention_until_dismissed() {+ let a = Activity::default();+ for (ending, n) in [(Ending::Problems, 1), (Ending::Failed, 2), (Ending::Stopped, 3)] {+ let g = a.start("import", "Importing", Cancel::Yes);+ assert!(g.finish(Outcome { ending, ..Default::default() }), "{ending:?} is kept however short the task");+ assert_eq!(problems(&a), n);+ }+ // newest first+ let rows = a.finished();+ assert_eq!(rows.iter().map(|f| f.ending).collect::<Vec<_>>(), vec![Ending::Stopped, Ending::Failed, Ending::Problems]);+ // each asks until it is dismissed, not until it has been looked at+ a.dismiss(rows[1].id).unwrap();+ assert_eq!(problems(&a), 2);+ a.dismiss_all();+ assert_eq!(problems(&a), 0);+ }++ #[test]+ fn finished_rows_are_dismissed_one_by_one_or_all_at_once() {+ let a = Activity::default();+ let ids: Vec<u64> = (0..3)+ .map(|_| {+ let g = a.start("export", "Exporting", Cancel::Yes);+ let id = g.id();+ g.finish(Outcome { ending: Ending::Failed, ..Default::default() });+ id+ })+ .collect();+ a.dismiss(ids[1]).unwrap();+ assert_eq!(a.finished().iter().map(|f| f.id).collect::<Vec<_>>(), vec![ids[2], ids[0]]);+ assert!(a.dismiss(ids[1]).is_err(), "an error, not a panic, for a row already gone");+ assert_eq!(a.dismiss_all(), 2);+ assert!(a.finished().is_empty());+ assert_eq!(a.dismiss_all(), 0);+ }++ #[test]+ fn what_is_kept_of_an_ending_is_bounded() {+ let a = Activity::default();+ a.keep_after(0);+ // a job with more failures than are kept, long names and long reasons+ let g = a.start("export", "Exporting", Cancel::Yes);+ let failures: Vec<Failure> =+ (0..MAX_FAILURES + 40).map(|i| Failure { name: format!("{i}{}", "é".repeat(300)), reason: "x".repeat(1000) }).collect();+ g.finish(Outcome {+ ending: Ending::Problems,+ summary: "s".repeat(5000),+ detail: "d".repeat(5000),+ failures,+ path: Some("p".repeat(9000)),+ ..Default::default()+ });+ let row = &a.finished()[0];+ assert_eq!((row.failed, row.failures.len()), (MAX_FAILURES + 40, MAX_FAILURES), "all counted, the first kept");+ assert_eq!((row.summary.chars().count(), row.detail.chars().count()), (MAX_SENTENCE, MAX_SENTENCE));+ assert_eq!(row.path.as_ref().map(|p| p.chars().count()), Some(MAX_PATH));+ // a job that only counts what failed (an import) is believed+ let counted = a.start("import", "Importing", Cancel::Yes);+ counted.finish(Outcome { ending: Ending::Problems, failed: 3, ..Default::default() });+ assert_eq!((a.finished()[0].failed, a.finished()[0].failures.len()), (3, 0));+ a.dismiss(a.finished()[0].id).unwrap();+ assert!(row.failures.iter().all(|f| f.name.chars().count() == MAX_TEXT && f.reason.chars().count() == MAX_SENTENCE));+ // many clean endings: the oldest give way, the one that did not go well stays+ for i in 0..MAX_DONE + 10 {+ a.start("export", &format!("Task {i}"), Cancel::Yes).finish(Outcome::default());+ }+ let rows = a.finished();+ assert_eq!(rows.iter().filter(|f| f.ending == Ending::Done).count(), MAX_DONE);+ assert_eq!(rows.first().map(|f| f.label.as_str()), Some(format!("Task {}", MAX_DONE + 9).as_str()), "the newest is kept");+ assert!(rows.iter().any(|f| f.ending == Ending::Problems), "a row that needs looking at is not pushed out by clean ones");+ // and never without end: rows that did not go well are bounded too+ for _ in 0..MAX_FINISHED + 20 {+ a.start("import", "Importing", Cancel::Yes).finish(Outcome { ending: Ending::Failed, ..Default::default() });+ }+ assert_eq!(a.finished().len(), MAX_FINISHED);+ }++ #[test]+ fn a_worker_that_dies_leaves_a_row_saying_so() {+ let a = Activity::default();+ let a2 = a.clone();+ let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {+ let _g = a2.start("previews", "Building previews", Cancel::Yes);+ panic!("synthetic worker panic");+ }));+ assert!(r.is_err());+ assert!(a.list().is_empty(), "no stuck bar");+ let rows = a.finished();+ assert_eq!(rows.len(), 1);+ assert_eq!((rows[0].kind, rows[0].label.as_str(), rows[0].ending), ("previews", "Building previews", Ending::Died));+ assert_eq!(problems(&a), 1);+ // a guard that is simply dropped (a job that does not report yet) leaves nothing, as before+ drop(a.start("faces", "Finding faces", Cancel::No));+ assert_eq!(a.finished().len(), 1);+ }+ #[test] fn guard_drop_on_panic_unwind_removes_the_row() { let a = Activity::default();@@ -1,4 +1,5 @@-//! `activity.list` / `activity.cancel`: the background tasks in flight (see [`crate::activity`]).+//! `activity.list` / `activity.cancel`: the background tasks in flight; `activity.finished` / `activity.dismiss`:+//! the ones that ended and left a row (see [`crate::activity`]). use serde_json::{Value, json}; @@ -32,6 +33,32 @@ pub fn specs() -> Vec<CommandSpec> { Ok(json!({"cancelled": 1})) } ),+ cmd!(+ query "activity.finished",+ "Finished Background Tasks",+ [],+ None,+ "{} → {finished: [{id, kind, label, ending: done|problems|failed|stopped|died, summary, detail, failures: [{name, reason}], failed, path, ranMs, ageMs}]} — the tasks that ended and left a row, newest first: those that ran 10 s or more, and every one that did not end well (problems = not for every file, stopped = cancelled, died = its worker crashed). `failed` counts the files that failed; `failures` holds the first 500 with their reasons (none for a job that only counts them); `path` is one of the files it made. `summary` and `detail` are as the app shows them, in its language, and `summary` is empty for a job that says nothing of itself. At most 50 clean endings and 200 rows in all are kept. They stay until dismissed, for this session",+ always,+ |s, _| Ok(json!({"finished": s.activity.finished()}))+ ),+ cmd!(+ "activity.dismiss",+ "Dismiss Finished Task",+ [],+ None,+ "{id} | {all: true} → {dismissed: n} — let go of a finished task's row (or of all of them). An unknown id (it may have been dismissed already) is an error",+ always,+ |s, p| {+ const C: &str = "activity.dismiss";+ if p.get("all").and_then(Value::as_bool) == Some(true) {+ return Ok(json!({"dismissed": s.activity.dismiss_all()}));+ }+ let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| bad(C, "id or all"))?;+ s.activity.dismiss(id).map_err(|e| bad(C, e))?;+ Ok(json!({"dismissed": 1}))+ }+ ), ] } @@ -54,6 +81,30 @@ mod tests { assert!(g.is_cancelled()); } + #[test]+ fn finished_and_dismiss_through_execute() {+ use crate::activity::{Ending, Failure, Outcome};+ let mut s = Session::new();+ let g = s.activity.start("export", "Exporting", Cancel::Yes);+ let id = g.id();+ g.finish(Outcome {+ ending: Ending::Problems,+ summary: "Exported 1 of 2 photos".into(),+ failures: vec![Failure { name: "IMG_2.jpg".into(), reason: "The disk is full".into() }],+ ..Default::default()+ });+ let r = s.execute("activity.finished", &json!({})).unwrap();+ assert_eq!(r["finished"][0]["id"], id);+ assert_eq!(r["finished"][0]["ending"], "problems");+ assert_eq!(r["finished"][0]["failures"][0], json!({"name": "IMG_2.jpg", "reason": "The disk is full"}));+ assert_eq!(r["finished"][0]["failed"], 1);+ assert!(s.execute("activity.dismiss", &json!({})).is_err());+ assert!(s.execute("activity.dismiss", &json!({"id": 999})).is_err());+ assert_eq!(s.execute("activity.dismiss", &json!({"id": id})).unwrap()["dismissed"], 1);+ assert_eq!(s.execute("activity.dismiss", &json!({"all": true})).unwrap()["dismissed"], 0);+ assert_eq!(s.execute("activity.finished", &json!({})).unwrap()["finished"], json!([]));+ }+ #[test] fn cancel_all_and_errors() { let mut s = Session::new();@@ -471,9 +471,14 @@ fn move_to_folder(s: &mut Session, p: &Value) -> Result<Value> { return Ok(json!({"moved": 0, "folder": folder})); } let plural = |k: usize| if k == 1 { "" } else { "s" };- let _task = s.activity.start("move", &format!("Moving {n} photo{}", plural(n)), crate::activity::Cancel::No);+ let task = s.activity.start("move", &format!("Moving {n} photo{}", plural(n)), crate::activity::Cancel::No); let name = dir.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| folder.to_string());- s.apply_file_plans(&crate::rename::RealFs, &plans, &|k| format!("Move {k} Photo{} to “{name}”", plural(k)))?;+ let moved = s.apply_file_plans(&crate::rename::RealFs, &plans, &|k| format!("Move {k} Photo{} to “{name}”", plural(k)));+ task.finish(match &moved {+ Ok(_) => crate::activity::Outcome::default(),+ Err(e) => crate::activity::Outcome::failed(e.to_string()),+ });+ moved?; Ok(json!({"moved": n, "folder": folder})) } @@ -149,6 +149,9 @@ fn reverse(s: &mut Session, p: &Value) -> Result<Value> { for (n, (id, at)) in todo.iter().enumerate() { if let Some(t) = &task { if t.cancel_flag().is_some_and(|f| f.load(std::sync::atomic::Ordering::Relaxed)) {+ if let Some(t) = task {+ t.finish(crate::activity::Outcome::ended(crate::activity::Ending::Stopped));+ } return Err(bad(C, "cancelled")); } t.progress(n as u64, todo.len() as u64);@@ -172,11 +175,15 @@ fn reverse(s: &mut Session, p: &Value) -> Result<Value> { } found.push(entry); }- drop(task); let changed = ops.len();- if !bool_or(p, "dryRun", false) && changed > 0 {- s.commit("Reverse Geocode", Op::Batch { ops })?;+ let committed = if !bool_or(p, "dryRun", false) && changed > 0 { s.commit("Reverse Geocode", Op::Batch { ops }) } else { Ok(()) };+ if let Some(t) = task {+ t.finish(match &committed {+ Ok(_) => crate::activity::Outcome::default(),+ Err(e) => crate::activity::Outcome::failed(e.to_string()),+ }); }+ committed?; Ok(json!({"looked": todo.len(), "found": found.len(), "changed": changed, "photos": found, "attribution": GEONAMES_ATTRIBUTION})) } @@ -261,9 +268,9 @@ mod download { let thread_dir = dir.clone(); let thread_shared = shared.clone(); let spawned = std::thread::Builder::new().name("place-names-download".into()).spawn(move || {- // the activity row goes when the thread ends, however it ends- let _task = task;+ // the activity row says how it ended when the thread ends, however it ends let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&base, &thread_dir, &cancel, &handle)));+ let panicked = outcome.is_err(); let outcome = match outcome { Ok(Ok(n)) => DownloadOutcome::Done(n), _ if cancel.load(std::sync::atomic::Ordering::Relaxed) => DownloadOutcome::Cancelled,@@ -273,10 +280,19 @@ mod download { if let DownloadOutcome::Failed(e) = &outcome { log::warn!("{e}"); }+ let ended = match &outcome {+ _ if panicked => crate::activity::Outcome::ended(crate::activity::Ending::Died),+ DownloadOutcome::Done(_) => crate::activity::Outcome::default(),+ DownloadOutcome::Cancelled => crate::activity::Outcome::ended(crate::activity::Ending::Stopped),+ DownloadOutcome::Failed(why) => crate::activity::Outcome::failed(why.clone()),+ }; let mut sh = thread_shared.lock().unwrap_or_else(PoisonError::into_inner); sh.running = false; sh.last = Some(outcome.clone()); sh.unreported = Some(outcome);+ drop(sh);+ // last: whoever sees the row go finds the download over+ task.finish(ended); }); if let Err(e) = spawned { shared.lock().unwrap_or_else(PoisonError::into_inner).running = false;@@ -9,7 +9,7 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use serde_json::{Value, json}; use super::{CommandSpec, always, bad, cmd, str_param};-use crate::activity::{Cancel, TaskGuard};+use crate::activity::{Cancel, Ending, Outcome, TaskGuard}; use crate::media::{RenderJob, THUMB_SIZES}; use crate::{Result, Session}; @@ -43,7 +43,7 @@ impl PreviewBuild { "total": self.total, "done": self.done.load(Ordering::Relaxed), "failed": self.failed.load(Ordering::Relaxed),- "running": !self.finished.load(Ordering::Relaxed),+ "running": !self.finished.load(Ordering::Acquire), "cancelled": self.cancel.load(Ordering::Relaxed), "what": if self.what.is_empty() { "previews" } else { self.what }, "repaired": self.repaired.load(Ordering::Relaxed),@@ -54,6 +54,30 @@ impl PreviewBuild { pub fn error(&self) -> Option<String> { self.error.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone() }++ /// How the build ended, for its row: it failed as a whole, was stopped, or could not make some photos' previews.+ fn outcome(&self) -> Outcome {+ let failed = self.failed.load(Ordering::Relaxed);+ if let Some(why) = self.error() {+ return Outcome { failed, ..Outcome::failed(why) };+ }+ let ending = if self.cancel.load(Ordering::Relaxed) {+ Ending::Stopped+ } else if failed > 0 {+ Ending::Problems+ } else {+ Ending::Done+ };+ Outcome { ending, failed, ..Default::default() }+ }++ /// The build is over: the row of how it went is kept, the build reads as finished, and only then does its running+ /// row go (whoever waits for `finished` finds the row; whoever sees the running row go finds the build finished).+ fn end(&self, mut task: TaskGuard) {+ task.report(self.outcome());+ self.finished.store(true, Ordering::Release);+ drop(task);+ } } /// The jobs that build `id`'s previews: the largest grid thumbnail and the view render at `edge`@@ -226,7 +250,7 @@ pub(crate) mod test_gate { } } -fn run_all(jobs: Vec<Vec<RenderJob>>, state: &PreviewBuild, task: &TaskGuard, lanes: usize) {+fn run_all(jobs: Vec<Vec<RenderJob>>, state: &PreviewBuild, task: TaskGuard, lanes: usize) { let total = jobs.len(); let queue = std::sync::Mutex::new(jobs.into_iter()); let work = || {@@ -269,7 +293,7 @@ fn run_all(jobs: Vec<Vec<RenderJob>>, state: &PreviewBuild, task: &TaskGuard, la let _ = lanes; work(); }- state.finished.store(true, Ordering::Relaxed);+ state.end(task); } fn build(s: &mut Session, p: &Value) -> Result<Value> {@@ -282,7 +306,7 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> { "full" | "1:1" => None, other => return Err(bad(C, format!("unknown size `{other}` (standard|full)"))), };- if s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Relaxed)) {+ if s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Acquire)) { return Err(bad(C, "a preview build is already running")); } // explicit ids, else the selection, else everything in view@@ -297,7 +321,7 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> { let lanes = lanes(jobs.len(), edge.is_none()); let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false) || cfg!(target_arch = "wasm32"); if wait {- run_all(jobs, &state, &task, lanes);+ run_all(jobs, &state, task, lanes); return Ok(state.json()); } #[cfg(not(target_arch = "wasm32"))]@@ -306,7 +330,7 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> { let st = state.clone(); std::thread::Builder::new() .name("lc-build-previews".into())- .spawn(move || run_all(jobs, &st, &task, lanes))+ .spawn(move || run_all(jobs, &st, task, lanes)) .map_err(|e| bad(C, format!("could not start: {e}")))?; } Ok(state.json())@@ -471,7 +495,7 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> { let dir = s.media.smart_dir.clone().ok_or_else(|| bad(C, "smart previews need a library on disk"))?; let discard = p.get("discard").and_then(Value::as_bool).unwrap_or(false); let background = p.get("background").and_then(Value::as_bool).unwrap_or(false);- if background && s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Relaxed)) {+ if background && s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Acquire)) { return Err(bad(C, "a preview build is already running")); } let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p)? } else { s.visible_cloned() };@@ -499,7 +523,7 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> { log::warn!("{C}: {e}"); *st.error.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(e); }- st.finished.store(true, Ordering::Relaxed);+ st.end(task); }) .map_err(|e| bad(C, format!("could not start: {e}")))?; return Ok(state.json());@@ -15,7 +15,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; -use crate::activity::{Activity, Cancel, TaskHandle, Unit};+use crate::activity::{Activity, Cancel, Ending, Outcome, TaskHandle, Unit}; use lightcraft_faces::known::Download; @@ -94,12 +94,12 @@ impl Downloads { std::thread::Builder::new() .name("face-model-download".into()) .spawn(move || {- // the row goes when the thread ends, however it ends- let _task = task;+ // the row says how it ended when the thread ends, however it ends let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&spec, spec.base(), &staging, &worker))); if outcome.is_err() { worker.set(State::Failed("the download stopped unexpectedly".into())); }+ task.finish(if outcome.is_err() { Outcome::ended(Ending::Died) } else { ended(&worker.get()) }); }) .map_err(|e| format!("could not start the download: {e}"))?; self.jobs.insert(id, shared);@@ -222,6 +222,17 @@ fn fetch(_: &Download, _: &str, _: &Path, _: &Shared) -> Result<(), String> { Err("the web build downloads no models".into()) } +/// How a download that ran ended, for its row.+fn ended(state: &State) -> Outcome {+ match state {+ State::Done { .. } | State::Installed => Outcome::default(),+ State::Failed(why) => Outcome::failed(why.clone()),+ State::Cancelled => Outcome::ended(Ending::Stopped),+ // (the thread ended without saying how)+ State::Running { .. } => Outcome::ended(Ending::Died),+ }+}+ /// The whole job: reuse a verified earlier copy, else fetch, check and stage. Always ends in a final [`State`]. fn run(spec: &Download, mirror: &str, staging: &Path, shared: &Shared) { let target = staging.join(&spec.file_name);@@ -355,6 +366,23 @@ mod tests { wait("the download never stopped", || !d.running()); assert_eq!(d.jobs["test-model"].get(), State::Cancelled); wait("the row stayed", || activity.list().is_empty());+ assert_eq!(activity.finished().first().map(|f| f.ending), Some(crate::activity::Ending::Stopped), "a stopped download says so");+ let _ = std::fs::remove_dir_all(&dir);+ }++ #[test]+ fn a_download_that_failed_leaves_a_row_with_the_reason() {+ use crate::activity::{Activity, Ending};+ let dir = scratch("failed-row");+ let activity = Activity::default();+ let mut d = Downloads::default();+ let mut spec = spec_for(b"abc");+ spec.url = format!("{}/{}", serve("404 Not Found", b"not found".to_vec()), spec.file_name);+ d.start(spec, &dir, &activity, "Test Model").unwrap();+ wait("the failed download's row never ended", || activity.list().is_empty() && !activity.finished().is_empty());+ let row = activity.finished().remove(0);+ assert_eq!((row.kind, row.ending), ("download", Ending::Failed));+ assert!(!row.detail.is_empty(), "the reason is kept"); let _ = std::fs::remove_dir_all(&dir); } @@ -15,7 +15,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; -use crate::activity::{Activity, Cancel, TaskHandle, Unit};+use crate::activity::{Activity, Cancel, Ending, Outcome, TaskHandle, Unit}; use lightcraft_denoise::known::Download; @@ -95,12 +95,12 @@ impl Downloads { std::thread::Builder::new() .name("denoise-model-download".into()) .spawn(move || {- // the row goes when the thread ends, however it ends- let _task = task;+ // the row says how it ended when the thread ends, however it ends let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&spec, &spec.url, &staging, &worker))); if outcome.is_err() { worker.set(State::Failed("the download stopped unexpectedly".into())); }+ task.finish(if outcome.is_err() { Outcome::ended(Ending::Died) } else { ended(&worker.get()) }); }) .map_err(|e| format!("could not start the download: {e}"))?; self.jobs.insert(id, shared);@@ -203,6 +203,17 @@ fn fetch(_: &Download, _: &str, _: &Path, _: &Shared) -> Result<(), String> { Err("the web build downloads no models".into()) } +/// How a download that ran ended, for its row.+fn ended(state: &State) -> Outcome {+ match state {+ State::Done { .. } | State::Installing | State::Installed => Outcome::default(),+ State::Failed(why) => Outcome::failed(why.clone()),+ State::Cancelled => Outcome::ended(Ending::Stopped),+ // (the thread ended without saying how)+ State::Running { .. } => Outcome::ended(Ending::Died),+ }+}+ /// The whole job: reuse a verified earlier copy, else fetch, check and stage. Always ends in a final [`State`]. fn run(spec: &Download, mirror: &str, staging: &Path, shared: &Shared) { let target = staging.join(&spec.file_name);@@ -323,6 +334,32 @@ mod tests { wait("the download never stopped", || !d.running()); assert_eq!(d.jobs["test-model"].get(), State::Cancelled); wait("the row stayed", || activity.list().is_empty());+ assert_eq!(activity.finished().first().map(|f| f.ending), Some(crate::activity::Ending::Stopped), "a stopped download says so");+ let _ = std::fs::remove_dir_all(&dir);+ }++ #[test]+ fn a_download_that_ended_leaves_a_row_saying_how() {+ use crate::activity::{Activity, Ending};+ let dir = scratch("ended");+ let activity = Activity::default();+ activity.keep_after(0);+ let mut d = Downloads::default();+ // a server that refuses: the row keeps the reason+ let mut spec = spec_for(b"abc");+ spec.url = format!("{}/{}", serve("404 Not Found", b"not found".to_vec()), spec.file_name);+ d.start(spec, &dir, &activity, "Test Model").unwrap();+ wait("the failed download's row never ended", || activity.list().is_empty() && !activity.finished().is_empty());+ let failed = activity.finished().remove(0);+ assert_eq!((failed.kind, failed.ending), ("download", Ending::Failed));+ assert!(failed.detail.contains("internet connection"), "{}", failed.detail);+ // one that arrives+ let bytes: Vec<u8> = (0..20_000u32).map(|i| (i % 251) as u8).collect();+ let mut spec = spec_for(&bytes);+ spec.url = format!("{}/{}", serve("200 OK", bytes), spec.file_name);+ d.start(spec, &dir, &activity, "Test Model").unwrap();+ wait("the download's row never ended", || activity.finished().len() == 2);+ assert_eq!(activity.finished()[0].ending, Ending::Done); let _ = std::fs::remove_dir_all(&dir); } @@ -8,7 +8,7 @@ use std::sync::{Arc, Mutex}; use lightcraft_segment::fetch::{self, Options, Progress}; -use crate::activity::{Activity, Cancel, Unit};+use crate::activity::{Activity, Cancel, Ending, Outcome, Unit}; /// What the session sees of a download. #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)]@@ -141,14 +141,15 @@ impl Downloader { let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| fetch::download(files, &mirrors, &dir, &opts, &s.cancel, &mut on_progress))); let cancelled = matches!(r, Ok(Err(fetch::DownloadError::Cancelled)));- let error = match r {+ let (error, outcome) = match r { Ok(Ok(())) => { s.finished.store(true, Ordering::SeqCst); log::info!("SAM 3 model downloaded to {}", dir.display());- None+ (None, Outcome::default()) }- Ok(Err(e)) => Some(e.to_string()),- Err(_) => Some("the download failed unexpectedly".to_string()),+ Ok(Err(e)) if s.cancel.load(Ordering::SeqCst) => (Some(e.to_string()), Outcome::ended(Ending::Stopped)),+ Ok(Err(e)) => (Some(e.to_string()), Outcome::failed(e.to_string())),+ Err(_) => (Some("the download failed unexpectedly".to_string()), Outcome::ended(Ending::Died)), }; if let Some(e) = &error { log::warn!("SAM 3 download: {e}");@@ -158,6 +159,8 @@ impl Downloader { text.error = error; drop(text); drop(guard);+ // last: whoever sees the row go finds the download over+ task.finish(outcome); }); match spawned { Ok(_) => Ok(true),@@ -1224,6 +1224,8 @@ fn activity_cancel_stops_a_preview_build() { assert_eq!(p["cancelled"], true, "{p}"); assert_eq!(p["done"], json!(0), "no photo rendered after the accepted cancel: {p}"); assert_eq!(p["running"], false, "{p}");+ let row = s.activity.finished().into_iter().next().expect("a stopped build leaves a row");+ assert_eq!((row.kind, row.ending), ("previews", crate::activity::Ending::Stopped)); } /// The gated fixture is real work: without a cancel, both photos render and are counted.@@ -1247,6 +1249,31 @@ fn preview_build_at_the_test_gate_completes_without_cancel() { assert_eq!(p["failed"], json!(0), "{p}"); } +/// A preview build that ran to its end leaves a row saying so (when it ran long enough to be worth one).+#[test]+fn a_finished_preview_build_leaves_a_row_saying_it_was_done() {+ let mut s = demo();+ s.activity.keep_after(0);+ let ids: Vec<u64> = s.catalog.photos().take(2).map(|p| p.id.0).collect();+ s.execute("library.buildPreviews", &json!({"ids": ids, "edge": 256, "wait": true})).unwrap();+ let row = s.activity.finished().into_iter().next().expect("a row for the build");+ assert_eq!((row.kind, row.label.as_str(), row.ending, row.failed), ("previews", "Building previews", crate::activity::Ending::Done, 0));+}++/// A photo whose preview can't be made (its file is gone) leaves the build's row marked, with how many failed.+#[test]+fn a_preview_build_that_could_not_render_a_photo_says_how_many() {+ use lightcraft_catalog::{Op, Photo, Source};+ let mut s = Session::new();+ let id = s.catalog.alloc_photo_id();+ let gone = std::env::temp_dir().join(format!("lc-no-such-photo-{}.jpg", std::process::id()));+ let p = Photo::new(id, Source::File { path: gone.to_string_lossy().to_string() }, "gone.jpg", "JPEG", 600, 400, "2026-01-01T10:00:00");+ s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();+ s.execute("library.buildPreviews", &json!({"ids": [id.0], "edge": 256, "wait": true})).unwrap();+ let row = s.activity.finished().into_iter().next().expect("a build that did not go well always leaves a row");+ assert_eq!((row.ending, row.failed), (crate::activity::Ending::Problems, 1));+}+ /// Colour range: a click samples the colour under it, so the mask selects that colour (white /// in the mask view) and not a different one; ⇧ adds samples, up to five. #[test]@@ -650,6 +650,21 @@ fn photos_move_to_another_folder_with_their_sidecars_and_back_on_undo() { assert_eq!(file_of(&s, b), dir.path("out/b.jpg")); } +/// A move is a task: when it ran long enough to be worth a row, the row says it was done.+#[test]+fn a_move_leaves_a_row_saying_it_was_done() {+ let dir = Scratch::new("move-row");+ std::fs::create_dir_all(dir.0.join("in")).unwrap();+ std::fs::create_dir_all(dir.0.join("out")).unwrap();+ let mut s = Session::new();+ s.activity.keep_after(0);+ let a = add_file(&mut s, &dir.path("in/a.jpg"), "A");+ let b = add_file(&mut s, &dir.path("in/b.jpg"), "B");+ s.execute("photo.moveToFolder", &json!({"ids": [a.0, b.0], "folder": dir.path("out")})).unwrap();+ let row = s.activity.finished().into_iter().next().expect("a row for the move");+ assert_eq!((row.kind, row.label.as_str(), row.ending), ("move", "Moving 2 photos", crate::activity::Ending::Done));+}+ #[test] fn a_name_taken_in_the_destination_refuses_the_move_and_overwrites_nothing() { let dir = Scratch::new("move-clash");@@ -65,6 +65,8 @@ "Exported {n} keyword{}": "Exportovaná klíčová slova: {n}{:.0}", "Exported {n} photo{}": "Exportováno fotografií: {n}{:.0}", "Exported {ok} of {total} photo{}": "Exportováno fotografií: {ok} z {total}{:.0}",+ "Exported contact sheet: {photos} photos, {pages} pages": "Exportován přehled snímků (fotografií: {photos}, stran: {pages})",+ "Printed to PDF: {photos} photos, {pages} pages": "Vytištěno do PDF (fotografií: {photos}, stran: {pages})", "Exported {} point curve preset{}": "Exportovaná přednastavení bodové křivky: {}{:.0}", "Exported {} preset{}": "Exportovaná přednastavení: {}{:.0}", "Exporting {} of {total}": "Exportování {} z {total}",@@ -237,6 +239,7 @@ "{} photos · exposures {} EV": "Fotografií: {} · expozice {} EV", "{} photos · {} · {:.0}° × {:.0}°": "Fotografií: {} · {} · {:.0}° × {:.0}°", "{} photo{} not readable": "Nečitelné fotografie: {}{:.0}",+ "{} import warning{}": "Varování importu: {}{:.0}", "{} sample{}": "Vzorků: {}{:.0}", "{} sec": "{} s", "{} spot(s) on this photo": "Retušované oblasti na této fotografii: {}",@@ -2147,6 +2147,7 @@ "none (CPU rendering)": "žádné (vykreslování na CPU)", "none of": "žádné z", "not added to Apple Photos (cancelled)": "nepřidáno do Apple Photos (zrušeno)",+ "cancelled": "zrušeno", "not available here": "zde nedostupné", "of the following rules:": "z následujících pravidel:", "or": "nebo",@@ -51,6 +51,8 @@ "Example: {example}": "Beispiel: {example}", "Exported {n} photo{}": "{n} Foto{} exportiert", "Exported {ok} of {total} photo{}": "{ok} von {total} Foto{} exportiert",+ "Exported contact sheet: {photos} photos, {pages} pages": "Kontaktabzug exportiert (Fotos: {photos}, Seiten: {pages})",+ "Printed to PDF: {photos} photos, {pages} pages": "Als PDF gedruckt (Fotos: {photos}, Seiten: {pages})", "Exported {} point curve preset{}": "Exportierte Punktkurvenvorgaben: {}{:.0}", "Exported {} preset{}": "Exportierte Vorgaben: {}{:.0}", "Exporting {} of {total}": "{} von {total} wird exportiert",@@ -185,6 +187,7 @@ "{} photos · exposures {} EV": "{} Fotos · Belichtungen {} EV", "{} photos · {} · {:.0}° × {:.0}°": "{} Fotos · {} · {:.0}° × {:.0}°", "{} photo{} not readable": "{} Foto{} nicht lesbar",+ "{} import warning{}": "Importwarnungen: {}{:.0}", "{} sample{}": "Farbproben: {}{:.0}", "{} sec": "{} s", "{} spot(s) on this photo": "{} Retuschierstelle(n) in diesem Foto",@@ -2159,6 +2159,7 @@ "Add to Apple Photos": "Zu Apple Fotos hinzufügen", "Adding to Apple Photos…": "Wird zu Apple Fotos hinzugefügt…", "not added to Apple Photos (cancelled)": "nicht zu Apple Fotos hinzugefügt (abgebrochen)",+ "cancelled": "abgebrochen", "The first time, macOS asks whether LightCraft may control Photos.": "Beim ersten Mal fragt macOS, ob LightCraft Fotos steuern darf.", "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt", "Add Face Model…": "Gesichtsmodell hinzufügen…",@@ -51,6 +51,8 @@ "Example: {example}": "Ejemplo: {example}", "Exported {n} photo{}": "{n} foto{0} exportada{0}", "Exported {ok} of {total} photo{}": "Exportadas {ok} de {total} foto{}",+ "Exported contact sheet: {photos} photos, {pages} pages": "Hoja de contactos exportada (fotos: {photos}, páginas: {pages})",+ "Printed to PDF: {photos} photos, {pages} pages": "Impreso en PDF (fotos: {photos}, páginas: {pages})", "Exported {} point curve preset{}": "{0} ajuste{1} preestablecido{1} de curva de puntos exportado{1}", "Exported {} preset{}": "{0} ajuste{1} preestablecido{1} exportado{1}", "Exporting {} of {total}": "Exportando {} de {total}",@@ -185,6 +187,7 @@ "{} photos · exposures {} EV": "{} fotos · exposiciones {} EV", "{} photos · {} · {:.0}° × {:.0}°": "{} fotos · {} · {:.0}° × {:.0}°", "{} photo{} not readable": "{0} foto{1} ilegible{1}",+ "{} import warning{}": "{0} advertencia{1} de importación", "{} sample{}": "{} muestra{}", "{} sec": "{} s", "{} spot(s) on this photo": "{} mancha(s) en esta foto",@@ -2184,6 +2184,7 @@ "Add to Apple Photos": "Añadir a Fotos de Apple", "Adding to Apple Photos…": "Añadiendo a Fotos de Apple…", "not added to Apple Photos (cancelled)": "no se añadieron a Fotos de Apple (cancelado)",+ "cancelled": "cancelado", "The first time, macOS asks whether LightCraft may control Photos.": "La primera vez, macOS pregunta si LightCraft puede controlar Fotos.", "Adding to Apple Photos": "Añadiendo a Fotos de Apple", "\"saved in memory but not written to disk\", the top bar shows a warning, and LightCraft keeps retrying until the": "«guardado en memoria pero no escrito en el disco», la barra superior muestra un aviso y LightCraft lo sigue intentando hasta que el",@@ -305,5 +305,8 @@ "Drop to import {n} preset{}": "Pudota tuodaksesi esiasetuksia: {n}{:.0}", "Drop to review {n} item{} for import": "Pudota tarkistaaksesi ennen tuontia (kohteita: {n}){:.0}", "Where should the Lightroom catalog “{catalog}” go?": "Minne Lightroom-luettelo ”{catalog}” tuodaan?",- "Merge into the open library “{library}” ({count} photo{})": "Yhdistä avoimeen kirjastoon ”{library}” (kuvia: {count}{:.0})"+ "Merge into the open library “{library}” ({count} photo{})": "Yhdistä avoimeen kirjastoon ”{library}” (kuvia: {count}{:.0})",+ "Exported contact sheet: {photos} photos, {pages} pages": "Kontaktivedos viety (kuvia: {photos}, sivuja: {pages})",+ "Printed to PDF: {photos} photos, {pages} pages": "Tulostettu PDF-tiedostoon (kuvia: {photos}, sivuja: {pages})",+ "{} import warning{}": "Tuontivaroituksia: {}{:.0}" }@@ -2327,5 +2327,6 @@ "Drop to open the face model": "Pudota avataksesi kasvomallin", "Dropped Files": "Pudotetut tiedostot", "Finish Lightroom catalog import before adding photos": "Viimeistele Lightroom-luettelon tuonti ennen kuvien lisäämistä",- "Import dropped files without review": "Tuo pudotetut tiedostot ilman tarkistusta"+ "Import dropped files without review": "Tuo pudotetut tiedostot ilman tarkistusta",+ "cancelled": "peruttu" }@@ -52,6 +52,8 @@ "Exported {n} photo{}": "{n} photo{} exportée(s)", "Exported {ok} of {total} photo{}": "{ok} photo{} exportée(s) sur {total}", "Exported {} point curve preset{}": "{} préréglage{} de courbe à points exporté(s)",+ "Exported contact sheet: {photos} photos, {pages} pages": "Planche-contact exportée (photos : {photos}, pages : {pages})",+ "Printed to PDF: {photos} photos, {pages} pages": "Imprimé en PDF (photos : {photos}, pages : {pages})", "Exported {} preset{}": "{} préréglage{} exporté(s)", "Exporting {} of {total}": "Exportation : {} sur {total}", "Filter bar — {active} active filter{}": "Barre de filtres — {active} filtre{} actif(s)",@@ -185,6 +187,7 @@ "{} photos · exposures {} EV": "{} photos · expositions {} EV", "{} photos · {} · {:.0}° × {:.0}°": "{} photos · {} · {:.0}° × {:.0}°", "{} photo{} not readable": "{} photo{} illisible(s)",+ "{} import warning{}": "{} avertissement{} d’importation", "{} sample{}": "{} échantillon{}", "{} sec": "{} s", "{} spot(s) on this photo": "{} tache(s) sur cette photo",@@ -2000,6 +2000,7 @@ "Add to Apple Photos": "Ajouter à Apple Photos", "Adding to Apple Photos…": "Ajout à Apple Photos…", "not added to Apple Photos (cancelled)": "non ajouté à Apple Photos (annulé)",+ "cancelled": "annulé", "The first time, macOS asks whether LightCraft may control Photos.": "La première fois, macOS demande si LightCraft peut contrôler Photos.", "Adding to Apple Photos": "Ajout à Apple Photos", "\"saved in memory but not written to disk\", the top bar shows a warning, and LightCraft keeps retrying until the": "« enregistré en mémoire mais pas écrit sur le disque », la barre supérieure affiche un avertissement et LightCraft réessaie jusqu'à ce que",@@ -305,5 +305,8 @@ "Drop to review {n} item{} for import": "Jatuhkan untuk meninjau {n} item sebelum impor{:.0}", "Inside: {names}": "Di dalam: {names}", "Where should the Lightroom catalog “{catalog}” go?": "Ke mana katalog Lightroom “{catalog}” diimpor?",- "Merge into the open library “{library}” ({count} photo{})": "Gabungkan ke pustaka yang terbuka “{library}” ({count} foto{:.0})"+ "Merge into the open library “{library}” ({count} photo{})": "Gabungkan ke pustaka yang terbuka “{library}” ({count} foto{:.0})",+ "Exported contact sheet: {photos} photos, {pages} pages": "Lembar kontak diekspor (foto: {photos}, halaman: {pages})",+ "Printed to PDF: {photos} photos, {pages} pages": "Dicetak ke PDF (foto: {photos}, halaman: {pages})",+ "{} import warning{}": "Peringatan impor: {}{:.0}" }@@ -2339,5 +2339,6 @@ "Drop to open the face model": "Jatuhkan untuk membuka model wajah", "Dropped Files": "File yang dijatuhkan", "Finish Lightroom catalog import before adding photos": "Selesaikan impor katalog Lightroom sebelum menambahkan foto",- "Import dropped files without review": "Impor file yang dijatuhkan tanpa tinjauan"+ "Import dropped files without review": "Impor file yang dijatuhkan tanpa tinjauan",+ "cancelled": "dibatalkan" }@@ -50,6 +50,8 @@ "Example: {example}": "Esempio: {example}", "Exported {n} photo{}": "Foto esportate: {n}{:.0}", "Exported {ok} of {total} photo{}": "Foto esportate: {ok} di {total}{:.0}",+ "Exported contact sheet: {photos} photos, {pages} pages": "Foglio provini esportato (foto: {photos}, pagine: {pages})",+ "Printed to PDF: {photos} photos, {pages} pages": "Stampato in PDF (foto: {photos}, pagine: {pages})", "Exported {} point curve preset{}": "Preset di curva a punti esportati: {}{:.0}", "Exported {} preset{}": "Preset esportati: {}{:.0}", "Exporting {} of {total}": "Esportazione {} di {total}",@@ -182,6 +184,7 @@ "{} photos · exposures {} EV": "{} foto · esposizioni {} EV", "{} photos · {} · {:.0}° × {:.0}°": "{} foto · {} · {:.0}° × {:.0}°", "{} photo{} not readable": "Foto illeggibili: {}{:.0}",+ "{} import warning{}": "Avvisi di importazione: {}{:.0}", "{} sample{}": "Campioni: {}{:.0}", "{} sec": "{} s", "{} spot(s) on this photo": "Macchie in questa foto: {}",@@ -2112,6 +2112,7 @@ "Add to Apple Photos": "Aggiungi a Foto di Apple", "Adding to Apple Photos…": "Aggiunta a Foto di Apple…", "not added to Apple Photos (cancelled)": "non aggiunte a Foto di Apple (annullato)",+ "cancelled": "annullato", "The first time, macOS asks whether LightCraft may control Photos.": "La prima volta, macOS chiede se LightCraft può controllare Foto.", "Adding to Apple Photos": "Aggiunta a Foto di Apple", "Add Face Model…": "Aggiungi modello dei volti…",@@ -28,6 +28,8 @@ "Example: {example}": "例:{example}", "Exported {n} photo{}": "{n}枚を書き出しました{:.0}", "Exported {ok} of {total} photo{}": "{total}枚中{ok}枚を書き出しました{:.0}",+ "Exported contact sheet: {photos} photos, {pages} pages": "コンタクトシートを書き出しました:{photos}枚、{pages}ページ",+ "Printed to PDF: {photos} photos, {pages} pages": "PDFに印刷しました:{photos}枚、{pages}ページ", "Exported {} point curve preset{}": "{}件のポイントカーブを書き出しました{:.0}", "Exported {} preset{}": "{}件のプリセットを書き出しました{:.0}", "Exporting {} of {total}": "書き出し中:{total}枚中{}枚",@@ -110,6 +112,7 @@ "{} of 4 guides — drag along lines that should be vertical or horizontal.": "ガイド4本中{}本:垂直または水平にする線に沿ってドラッグ", "{} photos": "{}枚", "{} photo{} not readable": "{}枚を読み込めません{:.0}",+ "{} import warning{}": "読み込みの警告: {}件{:.0}", "{} spot(s) on this photo": "この写真に{}か所のスポット", "· {dups} already in the library": "・{dups}枚は読み込み済み", "· {n} photo{}": "・{n}枚{:.0}",@@ -2004,6 +2004,7 @@ "Add to Apple Photos": "Appleの写真に追加", "Adding to Apple Photos…": "Appleの写真に追加中…", "not added to Apple Photos (cancelled)": "Appleの写真には追加されませんでした(キャンセル)",+ "cancelled": "キャンセル", "The first time, macOS asks whether LightCraft may control Photos.": "初回は、LightCraftに写真の操作を許可するかmacOSが確認します。", "Adding to Apple Photos": "Appleの写真に追加中", "\"saved in memory but not written to disk\", the top bar shows a warning, and LightCraft keeps retrying until the": "「メモリーには保存されたがディスクに書き込まれていない」と報告し、上部バーに警告を表示して、LightCraft は",@@ -65,6 +65,8 @@ "Exported {n} keyword{}": "ექსპორტირებულია საკვანძო სიტყვა: {n}{:.0}", "Exported {n} photo{}": "ექსპორტირებულია ფოტო: {n}{:.0}", "Exported {ok} of {total} photo{}": "ექსპორტირებულია ფოტო: {ok} / {total}{:.0}",+ "Exported contact sheet: {photos} photos, {pages} pages": "საკონტაქტო ფურცელი ექსპორტირებულია: {photos} ფოტო, {pages} გვერდი",+ "Printed to PDF: {photos} photos, {pages} pages": "დაიბეჭდა PDF-ში: {photos} ფოტო, {pages} გვერდი", "Exported {} point curve preset{}": "ექსპორტირებულია წერტილოვანი მრუდის პრესეტი: {}{:.0}", "Exported {} preset{}": "ექსპორტირებულია პრესეტი: {}{:.0}", "Exporting {} of {total}": "ექსპორტი: {} / {total}",@@ -236,6 +238,7 @@ "{} photos · exposures {} EV": "ფოტო: {} · ექსპოზიციები {} EV", "{} photos · {} · {:.0}° × {:.0}°": "ფოტო: {} · {} · {:.0}° × {:.0}°", "{} photo{} not readable": "ვერ იკითხება ფოტო: {}{:.0}",+ "{} import warning{}": "იმპორტის გაფრთხილებები: {}{:.0}", "{} sample{}": "ნიმუში: {}{:.0}", "{} sec": "{} წმ", "{} spot(s) on this photo": "ლაქა ამ ფოტოზე: {}",@@ -2566,6 +2566,7 @@ "none (CPU rendering)": "არცერთი (CPU რენდერირება)", "none of": "არცერთი", "not added to Apple Photos (cancelled)": "Apple Photos-ში არ დაემატა (გაუქმდა)",+ "cancelled": "გაუქმდა", "not available here": "აქ ხელმისაწვდომი არ არის", "noticeably duller.": "შესამჩნევად მკრთალი ჩანდა.", "now develop from the raw data instead of the camera's embedded JPEG, so a B&W or other picture style set in the": "ახლა მუშავდება RAW მონაცემებიდან კამერის ჩაშენებული JPEG-ის ნაცვლად, ასე რომ შავ-თეთრი ან სხვა გამოსახულების სტილი, რომელიც დაყენებულია",@@ -50,6 +50,8 @@ "Example: {example}": "Przykład: {example}", "Exported {n} photo{}": "Wyeksportowane zdjęcia: {n}{:.0}", "Exported {ok} of {total} photo{}": "Wyeksportowane zdjęcia: {ok} z {total}{:.0}",+ "Exported contact sheet: {photos} photos, {pages} pages": "Wyeksportowano arkusz stykowy (zdjęcia: {photos}, strony: {pages})",+ "Printed to PDF: {photos} photos, {pages} pages": "Wydrukowano do PDF (zdjęcia: {photos}, strony: {pages})", "Exported {} point curve preset{}": "Wyeksportowane ustawienia krzywej punktowej: {}{:.0}", "Exported {} preset{}": "Wyeksportowane ustawienia wstępne: {}{:.0}", "Exporting {} of {total}": "Eksportowanie {} z {total}",@@ -174,6 +176,7 @@ "{} photos · exposures {} EV": "Zdjęcia: {} · ekspozycje {} EV", "{} photos · {} · {:.0}° × {:.0}°": "Zdjęcia: {} · {} · {:.0}° × {:.0}°", "{} photo{} not readable": "Nieczytelne zdjęcia: {}{:.0}",+ "{} import warning{}": "Ostrzeżenia importu: {}{:.0}", "{} sample{}": "Próbki: {}{:.0}", "{} sec": "{} s", "{} spot(s) on this photo": "Plamki na tym zdjęciu: {}",@@ -1610,6 +1610,7 @@ "none": "brak", "none (CPU rendering)": "Brak (renderowanie przez CPU)", "none of": "Żaden z",+ "cancelled": "anulowano", "not available here": "Niedostępne tutaj", "of the following rules:": "Następujących reguł:", "or": "lub",@@ -37,6 +37,8 @@ "Example: {example}": "Exemplo: {example}", "Exported {n} photo{}": "{n} foto{0} exportada{0}", "Exported {ok} of {total} photo{}": "Fotos exportadas: {ok} de {total}{:.0}",+ "Exported contact sheet: {photos} photos, {pages} pages": "Folha de contato exportada (fotos: {photos}, páginas: {pages})",+ "Printed to PDF: {photos} photos, {pages} pages": "Impresso em PDF (fotos: {photos}, páginas: {pages})", "Exported {} point curve preset{}": "{} predefinição(ões) de curva de pontos exportada(s){:.0}", "Exported {} preset{}": "{} predefinição(ões) exportada(s){:.0}", "Exporting {} of {total}": "Exportando {} de {total}",@@ -138,6 +140,7 @@ "{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} de 4 guias — arraste ao longo das linhas que devem ficar verticais ou horizontais.", "{} photos": "{} fotos", "{} photo{} not readable": "{} foto(s) não legível(is){:.0}",+ "{} import warning{}": "{} aviso{} de importação", "{} spot(s) on this photo": "{} ponto(s) nesta foto", "· {dups} already in the library": "· {dups} já na biblioteca", "· {n} photo{}": "· {n} foto{}",@@ -2002,6 +2002,7 @@ "Add to Apple Photos": "Adicionar ao Fotos da Apple", "Adding to Apple Photos…": "Adicionando ao Fotos da Apple…", "not added to Apple Photos (cancelled)": "não adicionadas ao Fotos da Apple (cancelado)",+ "cancelled": "cancelado", "The first time, macOS asks whether LightCraft may control Photos.": "Na primeira vez, o macOS pergunta se o LightCraft pode controlar o Fotos.", "Adding to Apple Photos": "Adicionando ao Fotos da Apple", "Sort Albums A–Z": "Ordenar álbuns de A a Z",@@ -53,6 +53,8 @@ "Example: {example}": "Пример: {example}", "Exported {n} photo{}": "Экспортировано фото: {n}{:.0}", "Exported {ok} of {total} photo{}": "Экспортировано фото: {ok} из {total}{:.0}",+ "Exported contact sheet: {photos} photos, {pages} pages": "Контактный лист экспортирован (фото: {photos}, страниц: {pages})",+ "Printed to PDF: {photos} photos, {pages} pages": "Напечатано в PDF (фото: {photos}, страниц: {pages})", "Exported {} point curve preset{}": "Экспортировано пресетов точечной кривой: {}{:.0}", "Exported {} preset{}": "Экспортировано пресетов: {}{:.0}", "Exporting {} of {total}": "Экспорт: {} из {total}",@@ -192,6 +194,7 @@ "{} photos · exposures {} EV": "Фото: {} · экспозиции {} EV", "{} photos · {} · {:.0}° × {:.0}°": "Фото: {} · {} · {:.0}° × {:.0}°", "{} photo{} not readable": "Не читается фото: {}{:.0}",+ "{} import warning{}": "Предупреждений при импорте: {}{:.0}", "{} sample{}": "Образцов: {}{:.0}", "{} sec": "{} с", "{} spot(s) on this photo": "Пятен на этом фото: {}",@@ -2194,6 +2194,7 @@ "Add to Apple Photos": "Добавить в Apple Фото", "Adding to Apple Photos…": "Добавление в Apple Фото…", "not added to Apple Photos (cancelled)": "не добавлено в Apple Фото (отменено)",+ "cancelled": "отменено", "The first time, macOS asks whether LightCraft may control Photos.": "В первый раз macOS спросит, может ли LightCraft управлять приложением «Фото».", "Adding to Apple Photos": "Добавление в Apple Фото", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графический процессор не найден: LightCraft выполняет рендеринг на CPU, а это намного медленнее. Подробности: Справка → Сведения о системе.",@@ -305,5 +305,8 @@ "Drop to review {n} item{} for import": "Släpp för att granska {n} objekt före import{:.0}", "Inside: {names}": "Inuti: {names}", "Where should the Lightroom catalog “{catalog}” go?": "Vart ska Lightroom-katalogen ”{catalog}” importeras?",- "Merge into the open library “{library}” ({count} photo{})": "Slå samman med det öppna biblioteket ”{library}” ({count} foto{:.0})"+ "Merge into the open library “{library}” ({count} photo{})": "Slå samman med det öppna biblioteket ”{library}” ({count} foto{:.0})",+ "Exported contact sheet: {photos} photos, {pages} pages": "Kontaktark exporterat (foton: {photos}, sidor: {pages})",+ "Printed to PDF: {photos} photos, {pages} pages": "Utskrivet till PDF (foton: {photos}, sidor: {pages})",+ "{} import warning{}": "Importvarningar: {}{:.0}" }@@ -2429,5 +2429,6 @@ "Drop to open the face model": "Släpp för att öppna ansiktsmodellen", "Dropped Files": "Släppta filer", "Finish Lightroom catalog import before adding photos": "Slutför importen av Lightroom-katalogen innan du lägger till foton",- "Import dropped files without review": "Importera släppta filer utan granskning"+ "Import dropped files without review": "Importera släppta filer utan granskning",+ "cancelled": "avbruten" }@@ -51,6 +51,8 @@ "Example: {example}": "Приклад: {example}", "Exported {n} photo{}": "Експортовано фото: {n}{:.0}", "Exported {ok} of {total} photo{}": "Експортовано фото: {ok} із {total}{:.0}",+ "Exported contact sheet: {photos} photos, {pages} pages": "Аркуш мініатюр експортовано (фото: {photos}, сторінок: {pages})",+ "Printed to PDF: {photos} photos, {pages} pages": "Надруковано в PDF (фото: {photos}, сторінок: {pages})", "Exported {} point curve preset{}": "Експортовано наборів налаштувань точкової кривої: {}{:.0}", "Exported {} preset{}": "Експортовано наборів налаштувань: {}{:.0}", "Exporting {} of {total}": "Експорт: {} із {total}",@@ -185,6 +187,7 @@ "{} photos · exposures {} EV": "Фото: {} · експозиції {} EV", "{} photos · {} · {:.0}° × {:.0}°": "Фото: {} · {} · {:.0}° × {:.0}°", "{} photo{} not readable": "Не читається фото: {}{:.0}",+ "{} import warning{}": "Попереджень під час імпорту: {}{:.0}", "{} sample{}": "Зразків: {}{:.0}", "{} sec": "{} с", "{} spot(s) on this photo": "Ділянок на цьому фото: {}",@@ -2194,6 +2194,7 @@ "Add to Apple Photos": "Додати до Apple Фото", "Adding to Apple Photos…": "Додавання до Apple Фото…", "not added to Apple Photos (cancelled)": "не додано до Apple Фото (скасовано)",+ "cancelled": "скасовано", "The first time, macOS asks whether LightCraft may control Photos.": "Першого разу macOS запитає, чи може LightCraft керувати програмою «Фото».", "Adding to Apple Photos": "Додавання до Apple Фото", "Canon EOS R5 Mark II C-RAW files that opened as \"preview only\" (more than half of them, depending on the": "Файли Canon EOS R5 Mark II C-RAW, які відкривалися «лише як перегляд» (більше половини, залежно від",@@ -305,5 +305,8 @@ "Drop to import {n} preset{}": "Thả để nhập {n} thiết lập sẵn{:.0}", "Drop to review {n} item{} for import": "Thả để xem lại {n} mục trước khi nhập{:.0}", "Where should the Lightroom catalog “{catalog}” go?": "Nhập danh mục Lightroom «{catalog}» vào đâu?",- "Merge into the open library “{library}” ({count} photo{})": "Gộp vào thư viện đang mở «{library}» ({count} ảnh{:.0})"+ "Merge into the open library “{library}” ({count} photo{})": "Gộp vào thư viện đang mở «{library}» ({count} ảnh{:.0})",+ "Exported contact sheet: {photos} photos, {pages} pages": "Đã xuất tờ tiếp xúc (ảnh: {photos}, trang: {pages})",+ "Printed to PDF: {photos} photos, {pages} pages": "Đã in ra PDF (ảnh: {photos}, trang: {pages})",+ "{} import warning{}": "Cảnh báo khi nhập: {}{:.0}" }@@ -2235,5 +2235,6 @@ "Drop to open the face model": "Thả để mở mô hình khuôn mặt", "Dropped Files": "Tệp đã thả", "Finish Lightroom catalog import before adding photos": "Hoàn tất nhập danh mục Lightroom trước khi thêm ảnh",- "Import dropped files without review": "Nhập các tệp đã thả mà không xem lại"+ "Import dropped files without review": "Nhập các tệp đã thả mà không xem lại",+ "cancelled": "đã hủy" }@@ -28,6 +28,8 @@ "Example: {example}": "示例:{example}", "Exported {n} photo{}": "已导出 {n} 张照片{0:.0}", "Exported {ok} of {total} photo{}": "已导出 {total} 张中的 {ok} 张照片{0:.0}",+ "Exported contact sheet: {photos} photos, {pages} pages": "已导出联系表:{photos} 张照片,{pages} 页",+ "Printed to PDF: {photos} photos, {pages} pages": "已打印为 PDF:{photos} 张照片,{pages} 页", "Exported {} point curve preset{}": "已导出 {0} 个点曲线预设{1:.0}", "Exported {} preset{}": "已导出 {0} 个预设{1:.0}", "Exporting {} of {total}": "正在导出 {total} 张中的第 {0} 张",@@ -110,6 +112,7 @@ "{} of 4 guides — drag along lines that should be vertical or horizontal.": "已画 {0} 条参考线(共 4 条)— 沿应为垂直或水平的线拖动。", "{} photos": "{0} 张照片", "{} photo{} not readable": "{0} 张照片无法读取{1:.0}",+ "{} import warning{}": "{0} 条导入警告{1:.0}", "{} spot(s) on this photo": "此照片上有 {0} 处污点", "· {dups} already in the library": "· {dups} 张已在图库中", "· {n} photo{}": "· {n} 张照片{0:.0}",@@ -2034,6 +2034,7 @@ "Add to Apple Photos": "添加到 Apple 照片", "Adding to Apple Photos…": "正在添加到 Apple 照片…", "not added to Apple Photos (cancelled)": "未添加到 Apple 照片(已取消)",+ "cancelled": "已取消", "The first time, macOS asks whether LightCraft may control Photos.": "首次使用时,macOS 会询问是否允许 LightCraft 控制“照片”。", "Adding to Apple Photos": "正在添加到 Apple 照片", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "未找到图形处理器:LightCraft 改用 CPU 渲染,速度会慢很多。详情见“帮助 → 系统信息”。",@@ -34,6 +34,8 @@ "Example: {example}": "範例:{example}", "Exported {n} photo{}": "已匯出 {n} 張照片{:.0}", "Exported {ok} of {total} photo{}": "已匯出 {ok}/{total} 張照片{:.0}",+ "Exported contact sheet: {photos} photos, {pages} pages": "已匯出縮圖目錄:{photos} 張照片,{pages} 頁",+ "Printed to PDF: {photos} photos, {pages} pages": "已列印為 PDF:{photos} 張照片,{pages} 頁", "Exported {} point curve preset{}": "已匯出 {} 個點曲線預設集{:.0}", "Exported {} preset{}": "已匯出 {} 個預設集{:.0}", "Exporting {} of {total}": "正在匯出第 {}/{total} 張",@@ -134,6 +136,7 @@ "{} of 4 guides — drag along lines that should be vertical or horizontal.": "已繪製 {}/4 條參考線 — 沿應為垂直或水平的線條拖曳。", "{} photos": "{} 張照片", "{} photo{} not readable": "{} 張照片無法讀取{:.0}",+ "{} import warning{}": "{0} 則匯入警告{1:.0}", "{} spot(s) on this photo": "此照片有 {} 個斑點", "· {dups} already in the library": "· {dups} 張已在照片庫中", "· {n} photo{}": "· {n} 張照片{:.0}",@@ -2002,6 +2002,7 @@ "Add to Apple Photos": "加入 Apple 照片", "Adding to Apple Photos…": "正在加入 Apple 照片…", "not added to Apple Photos (cancelled)": "未加入 Apple 照片(已取消)",+ "cancelled": "已取消", "The first time, macOS asks whether LightCraft may control Photos.": "第一次使用時,macOS 會詢問是否允許 LightCraft 控制「照片」。", "Adding to Apple Photos": "正在加入 Apple 照片", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "找不到圖形處理器:LightCraft 改用 CPU 算繪,速度會慢很多。詳情請見「說明 → 系統資訊」。",@@ -88,8 +88,9 @@ impl LightcraftApp { && !self.tasks.is_running(LABEL) { self.ui.auto_import_at = now;+ let watched = folder.clone(); let work = move || lightcraft_engine::cmd::library::list_auto_import_folder(&folder);- let done = |app: &mut LightcraftApp, _ctx: &egui::Context, listing: Result<Vec<(String, u64)>, String>| {+ let done = move |app: &mut LightcraftApp, _ctx: &egui::Context, listing: Result<Vec<(String, u64)>, String>| { let listing = match listing { Ok(l) => l, Err(e) => return log::debug!("auto import: {e}"),@@ -103,9 +104,13 @@ impl LightcraftApp { o.remove("paths"); } let undo0 = app.session.undo.len();- app.import = Some(import::ImportTask::new(paths, params, undo0, false).auto());+ app.import = Some(import::ImportTask::new(paths, params, undo0, false).named_below(vec![watched]).auto());+ };+ // listed every few seconds: quiet, no row in the activity stack (so nothing to say of how it ended)+ let done = move |app: &mut LightcraftApp, ctx: &egui::Context, listing| {+ done(app, ctx, listing);+ lightcraft_engine::activity::Outcome::default() };- // listed every few seconds: quiet, no row in the activity stack if let Err(e) = tasks::spawn(self, LABEL, None, work, done) { log::warn!("{e}"); }@@ -150,6 +150,8 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value { "tasks": app.tasks.labels(), // every long-running task in flight, as the activity stack shows them (`activity.list`) "activity": app.session.activity.list(),+ // the tasks that ended and left a row (`activity.finished`)+ "activityFinished": app.session.activity.finished(), // commands waiting on a native file dialog shown off the UI thread (`pick`) "fileDialogs": app.pending_picks.iter().map(|p| p.command.clone()).collect::<Vec<_>>(), "memory": memory(app),@@ -39,6 +39,7 @@ pub use scan::{ScanTask, browse, open, poll_scan, running}; pub(crate) use scan::{stall_scan, stall_scan_of}; pub(crate) use tags::{tag_field_width, tag_help, tag_toggle, unknown_tags_warning}; pub use task::{ImportTask, start, start_paths, tick};+pub(crate) use task::{failure_name, not_readable_text}; #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")]@@ -1,4 +1,4 @@-use super::task::ImportTask;+use super::task::{ImportTask, not_readable_text}; use super::*; /// A folder scan running on a worker thread (a network share can take minutes to read; the@@ -145,6 +145,7 @@ pub fn browse(app: &mut LightcraftApp, path: &str, subfolders: Option<bool>) -> /// Collect a finished scan and open the review (called every frame). pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {+ use lightcraft_engine::activity::{Ending, Failure, Outcome}; show_pending_review(app, ctx); let Some(task) = app.scan.as_ref() else { return }; ctx.request_repaint_after(std::time::Duration::from_millis(100));@@ -153,7 +154,9 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { Err(std::sync::mpsc::TryRecvError::Empty) => { if task.guard.is_cancelled() { // ✕: the worker stops at its next file; don't wait for it (a NAS read can take a while)- app.scan = None;+ if let Some(task) = app.scan.take() {+ task.guard.finish(Outcome::ended(Ending::Stopped));+ } return; } let (total, done) = (task.progress.total.load(Ordering::Relaxed), task.progress.done.load(Ordering::Relaxed));@@ -163,15 +166,52 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { return; } Err(_) => {- app.scan = None;+ // the worker died (a panic, logged)+ if let Some(task) = app.scan.take() {+ task.guard.finish(Outcome::ended(Ending::Died));+ } app.toast(ctx, crate::i18n::tr("Scan failed")); return; } }; let Some(task) = app.scan.take() else { return };+ // (a scan another one replaced was let go without a word; one here that was cancelled was stopped by its ✕) if task.progress.cancel.load(Ordering::Relaxed) {+ task.guard.finish(Outcome::ended(Ending::Stopped)); return; }+ // the Local view's row says which folder; a folder opened again replaces its last row instead of adding one+ let path = task.browse.then(|| app.session.browse.as_ref().map(|b| b.path.clone())).flatten();+ // files that could not be read are a problem the row says, by name (in the Local view, by where they are in its+ // folder: two subfolders can hold the same name): the Local view leaves them out, so nothing later would+ let named = |c: &ImportCandidate| {+ path.as_ref()+ .and_then(|root| std::path::Path::new(&c.path).strip_prefix(root).ok())+ .map_or_else(|| c.name.clone(), |rel| rel.to_string_lossy().into_owned())+ };+ let failed = out.candidates.iter().filter(|c| c.error.is_some()).count();+ let failures: Vec<Failure> = out+ .candidates+ .iter()+ .filter_map(|c| c.error.as_ref().map(|e| Failure { name: named(c), reason: e.clone() }))+ .take(lightcraft_engine::activity::MAX_FAILURES)+ .collect();+ let found_none = out.candidates.is_empty() && !task.browse;+ if let Some(path) = &path {+ for old in app.session.activity.finished() {+ if old.kind == "scan" && old.path.as_ref() == Some(path) {+ let _ = app.session.activity.dismiss(old.id);+ }+ }+ }+ task.guard.finish(Outcome {+ ending: if failed > 0 { Ending::Problems } else { Ending::Done },+ detail: if found_none { crate::i18n::tr("No photos found").to_string() } else { not_readable_text(failed) },+ failed,+ failures,+ path,+ ..Default::default()+ }); app.session.import_probes = out.probes; if task.browse { // the folder and the ones its photos are in count as browsed now, as `library.browse` records them, so@@ -193,7 +233,8 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { let undo0 = app.session.undo.len(); let total = queue.len(); let _ = total;- app.import = Some(ImportTask::new(queue, json!({"mode": "add", "local": true}), undo0, true));+ let root = app.session.browse.as_ref().map(|b| b.path.clone());+ app.import = Some(ImportTask::new(queue, json!({"mode": "add", "local": true}), undo0, true).named_below(root.into_iter().collect())); } return; }@@ -6,6 +6,42 @@ use super::*; #[cfg(target_arch = "wasm32")] const BATCH: usize = 8; +/// The worker's loop: ready `files` batch by batch with `prepare` and send each to `tx`. `ended` is set when the loop+/// ends by itself (every file, a stop, or an import that was dropped); a panic in `prepare` (logged) stops the loop+/// and leaves it unset, so the receiving side can tell a worker that died from one that finished.+#[cfg(not(target_arch = "wasm32"))]+fn feed_batches(+ files: Vec<String>,+ batch: usize,+ cancel: &std::sync::atomic::AtomicBool,+ tx: &std::sync::mpsc::Sender<lightcraft_engine::import::Prepared>,+ ctx: &egui::Context,+ ended: &std::sync::atomic::AtomicBool,+ mut prepare: impl FnMut(Vec<String>) -> lightcraft_engine::import::Prepared,+) {+ for chunk in files.chunks(batch.max(1)) {+ if cancel.load(Ordering::Relaxed) {+ break;+ }+ let prepared = match lightcraft_engine::guard::catch("import", || prepare(chunk.to_vec())) {+ Ok(p) => p,+ Err(_) => {+ // logged; the files readied so far are added, and the import ends as one that died+ ctx.request_repaint();+ return;+ }+ };+ if let Err(unsent) = tx.send(prepared) {+ // the import was dropped: nothing will add these, so take back what a Move placed+ unsent.0.rollback();+ break;+ }+ ctx.request_repaint();+ }+ ended.store(true, Ordering::Release);+ ctx.request_repaint();+}+ /// A running import (see the module docs). The file-system work — expanding folders, probing, /// reading sidecars, copying or placing files — runs on a worker thread /// ([`lightcraft_engine::import::ImportJob`]); each batch it readies is added to the catalog on the@@ -14,6 +50,8 @@ const BATCH: usize = 8; pub struct ImportTask { /// Files and folders to import (handed to the worker when the task starts). queue: Vec<String>,+ /// The files and folders the import was asked for: what the names of unreadable files are relative to.+ roots: Vec<String>, pub total: usize, pub done: usize, params: Value,@@ -24,6 +62,9 @@ pub struct ImportTask { /// The library photos the skipped duplicates match (shown when nothing new was added). existing: Vec<u64>, pub failed: usize,+ /// The files that could not be read, as (path, reason) the engine reported them: the first+ /// [`lightcraft_engine::activity::MAX_FAILURES`] (`failed` counts them all).+ failures: Vec<(String, String)>, /// Move: originals moved, and sources left in place (reported by the engine with a reason). pub moved: usize, pub kept: usize,@@ -36,6 +77,8 @@ pub struct ImportTask { pub(super) browse: bool, /// Cancel was pressed: no further files are started; batches already readied are added. pub cancelled: bool,+ /// The worker that readies the files died (a panic, logged) before it got to the last one.+ died: bool, /// Auto Import (the watched folder): the selection stays as it is, a short toast when done. auto: bool, /// Auto Import: the selection to keep.@@ -56,6 +99,13 @@ impl ImportTask { ImportTask { total: paths.len(), queue: paths, params, undo0, browse, ..Default::default() } } + /// Name the files this import cannot read by where they are below `folders` (the sources the files were+ /// found in, or the files and folders it was given), rather than by their file name alone.+ pub fn named_below(mut self, folders: Vec<String>) -> Self {+ self.roots = folders;+ self+ }+ /// An Auto Import (see [`ImportTask::auto`]). pub fn auto(mut self) -> Self { self.auto = true;@@ -71,12 +121,27 @@ impl ImportTask { /// Where an import's batches are readied: a worker thread, or (browser build: no threads, and no /// slow drives) inline, one batch per frame. enum Runner {+ /// The batches the worker readies, and whether it came to its end by itself (a stop, a dropped import or the+ /// last file) rather than by a panic: a channel that closes without it was a worker that died. #[cfg(not(target_arch = "wasm32"))]- Thread(std::sync::mpsc::Receiver<lightcraft_engine::import::Prepared>),+ Thread(std::sync::mpsc::Receiver<lightcraft_engine::import::Prepared>, std::sync::Arc<std::sync::atomic::AtomicBool>), #[cfg(target_arch = "wasm32")] Inline { job: Box<lightcraft_engine::import::ImportJob>, queue: Vec<String>, files: Option<std::collections::VecDeque<String>> }, } +impl Runner {+ /// The worker stopped without coming to its end: a panic (logged), so the files it did not get to were not+ /// read. Only meaningful once [`ImportRun::next_batches`] has said it is done.+ fn died(&self) -> bool {+ match self {+ #[cfg(not(target_arch = "wasm32"))]+ Runner::Thread(_, ended) => !ended.load(Ordering::Acquire),+ #[cfg(target_arch = "wasm32")]+ Runner::Inline { .. } => false,+ }+ }+}+ struct ImportRun { runner: Runner, cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,@@ -124,27 +189,20 @@ impl ImportRun { ctx: &egui::Context, ) -> Result<Runner, String> { let (tx, rx) = std::sync::mpsc::channel();+ let ended = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));+ let worker_ended = ended.clone(); let ctx = ctx.clone(); let work = move || { let files = job.expand(&queue);- for chunk in files.chunks(lightcraft_engine::import::batch_size()) {- if cancel.load(Ordering::Relaxed) {- break;- }- let prepared = match lightcraft_engine::guard::catch("import", || job.prepare_files(chunk.to_vec(), &cancel)) {- Ok(p) => p,- Err(_) => break, // logged; the files readied so far are added- };- if let Err(unsent) = tx.send(prepared) {- // the import was dropped: nothing will add these, so take back what a Move placed- unsent.0.rollback();- break;- }- ctx.request_repaint();- }- ctx.request_repaint();+ feed_batches(files, lightcraft_engine::import::batch_size(), &cancel, &tx, &ctx, &worker_ended, |chunk| {+ job.prepare_files(chunk, &cancel)+ }); };- std::thread::Builder::new().name("lc-import".into()).spawn(work).map(|_| Runner::Thread(rx)).map_err(|e| format!("could not start: {e}"))+ std::thread::Builder::new()+ .name("lc-import".into())+ .spawn(work)+ .map(|_| Runner::Thread(rx, ended))+ .map_err(|e| format!("could not start: {e}")) } #[cfg(target_arch = "wasm32")]@@ -161,7 +219,7 @@ impl ImportRun { fn next_batches(&mut self) -> Option<Vec<lightcraft_engine::import::Prepared>> { match &mut self.runner { #[cfg(not(target_arch = "wasm32"))]- Runner::Thread(rx) => {+ Runner::Thread(rx, _) => { let mut out = Vec::new(); while out.len() < 4 { match rx.try_recv() {@@ -248,7 +306,7 @@ pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result<Value, String> } } let total = queue.len();- app.import = Some(ImportTask::new(queue, params, undo0, false));+ app.import = Some(ImportTask::new(queue, params, undo0, false).named_below(d.sources.clone())); app.renderer.forget_imports(); Ok(json!({"importing": total})) }@@ -261,7 +319,7 @@ pub fn start_paths(app: &mut LightcraftApp, paths: Vec<String>) -> Result<Value, } let undo0 = app.session.undo.len(); let total = paths.len();- app.import = Some(ImportTask::new(paths, json!({"mode": "add"}), undo0, false));+ app.import = Some(ImportTask::new(paths.clone(), json!({"mode": "add"}), undo0, false).named_below(paths)); Ok(json!({"importing": total})) } @@ -296,6 +354,9 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { let Some(run) = task.run.as_mut() else { return }; let batches = run.next_batches(); let finished = batches.is_none();+ if finished {+ task.died = run.runner.died();+ } if task.left.as_ref().is_some_and(|(sel, source)| *sel != app.session.selection || *source != app.session.source) { task.moved_on = true; }@@ -350,6 +411,15 @@ fn commit_batch(app: &mut LightcraftApp, task: &mut ImportTask, prepared: lightc task.duplicates += len("duplicates"); task.existing.extend(v["duplicates"].as_array().into_iter().flatten().filter_map(|d| d["existing"].as_u64())); task.failed += len("failed");+ let room = lightcraft_engine::activity::MAX_FAILURES.saturating_sub(task.failures.len());+ task.failures.extend(+ v["failed"]+ .as_array()+ .into_iter()+ .flatten()+ .take(room)+ .filter_map(|f| Some((f.get(0)?.as_str()?.to_string(), f.get(1)?.as_str()?.to_string()))),+ ); task.moved += len("moved"); task.kept += len("kept"); task.warnings.extend(v["warnings"].as_array().into_iter().flatten().filter_map(|warning| serde_json::from_value(warning.clone()).ok()));@@ -424,7 +494,7 @@ fn xmp_warnings_text(warnings: &[ImportWarning]) -> String { /// The import is done (or cancelled): one undo step, show and select the first photo (unless the /// user chose another meanwhile), say what happened.-fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {+fn finish(app: &mut LightcraftApp, ctx: &egui::Context, mut task: ImportTask) { let steps = app.session.undo.len().saturating_sub(task.undo0); let plural = |n: usize| if n == 1 { "" } else { "s" }; let label = if task.imported == 0 && task.restored > 0 {@@ -436,6 +506,9 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { }; app.session.merge_undo(steps, &label); if task.auto {+ let summary = crate::i18n::tr_format!("Auto Import: added {} photo{}", task.imported, plural(task.imported));+ let detail = with_warnings(not_readable_text(task.failed), &task);+ report(&mut task, summary, detail); if !task.warnings.is_empty() { app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0); } else if task.imported > 0 {@@ -444,6 +517,9 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { return; } if task.browse {+ // (no summary: the row is worded from its name, "Reading folder", and says how many files could not be read)+ let detail = with_warnings(not_readable_text(task.failed), &task);+ report(&mut task, String::new(), detail); if !task.warnings.is_empty() { app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0); } else if task.failed > 0 {@@ -462,15 +538,13 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { let plural = |n: usize| if n == 1 { "" } else { "s" }; // nothing new, only files the library already has: show where they are (Recently Deleted is // easy to miss, and the side panel that lists it starts collapsed)- if task.imported == 0+ // (still reported to the activity stack below: the toast is the only part this takes the place of)+ let existing_shown = task.imported == 0 && task.restored == 0 && task.failed == 0 && task.warnings.is_empty() && !task.cancelled- && show_existing(app, ctx, &task.existing)- {- return;- }+ && show_existing(app, ctx, &task.existing); let mut msg = if task.cancelled { crate::i18n::tr_format!("Import cancelled · {} photo{} added", task.imported, plural(task.imported)) } else if task.imported == 0 && task.restored > 0 {@@ -478,6 +552,8 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { } else { crate::i18n::tr_format!("Imported {} photo{}", task.imported, plural(task.imported)) };+ // what was done is the first line of the row the import leaves; the rest is what needs reading under it+ let summary = msg.clone(); if task.restored > 0 && (task.imported > 0 || task.cancelled) { msg.push_str(&crate::i18n::tr_format!(" · {} restored from Recently Deleted", task.restored)); }@@ -497,6 +573,12 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { if task.failed > 0 { msg.push_str(&crate::i18n::tr_format!(" · {} not readable", task.failed)); }+ let detail = msg.get(summary.len()..).unwrap_or_default().trim_start_matches(" · ").to_string();+ let detail = with_warnings(detail, &task);+ report(&mut task, summary, detail);+ if existing_shown {+ return;+ } if task.warnings.is_empty() { app.toast(ctx, msg); } else {@@ -506,6 +588,63 @@ fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) { } } +/// How an import ended: stopped, failed to read every file, with problems, or well.+fn ending_of(task: &ImportTask) -> lightcraft_engine::activity::Ending {+ use lightcraft_engine::activity::Ending;+ if task.cancelled {+ Ending::Stopped+ } else if task.died {+ Ending::Died+ } else if task.failed > 0 && task.imported == 0 && task.restored == 0 {+ Ending::Failed+ } else if task.failed > 0 || !task.warnings.is_empty() {+ Ending::Problems+ } else {+ Ending::Done+ }+}++/// The unreadable files, as the second line of an import's row (nothing when all could be read).+pub(crate) fn not_readable_text(failed: usize) -> String {+ if failed == 0 { String::new() } else { crate::i18n::tr_format!("{} photo{} not readable", failed, if failed == 1 { "" } else { "s" }) }+}++/// `detail` with how many XMP warnings the import met (the toast that spells them out passes; the row stays).+fn with_warnings(mut detail: String, task: &ImportTask) -> String {+ let n = task.warnings.len();+ if n > 0 {+ if !detail.is_empty() {+ detail.push_str(" · ");+ }+ detail.push_str(&crate::i18n::tr_format!("{} import warning{}", n, if n == 1 { "" } else { "s" }));+ }+ detail+}++/// How a failed file is named in the import's row: by where it is below the folder it was found in (two subfolders+/// can hold the same name), else by its own name.+pub(crate) fn failure_name(path: &str, roots: &[String]) -> String {+ let p = std::path::Path::new(path);+ roots+ .iter()+ .filter_map(|root| p.strip_prefix(root).ok())+ .find(|rel| !rel.as_os_str().is_empty())+ .or_else(|| p.file_name().map(std::path::Path::new))+ .map_or_else(|| path.to_string(), |rel| rel.to_string_lossy().into_owned())+}++/// The import says how it ended to the activity stack, on every way out of [`finish`] (a task dropped without+/// saying is no row at all): one that ran long, was stopped or could not read every file stays in+/// `activity.finished`, naming the files it could not read and why.+fn report(task: &mut ImportTask, summary: String, detail: String) {+ use lightcraft_engine::activity::{Failure, Outcome};+ let ending = ending_of(task);+ let failures = task.failures.iter().map(|(path, reason)| Failure { name: failure_name(path, &task.roots), reason: reason.clone() }).collect();+ if let Some(guard) = task.guard.take() {+ guard.finish(Outcome { ending, summary, detail, failed: task.failed, failures, ..Default::default() });+ }+}+ /// The photos among `ids` that are in Recently Deleted (each once). fn deleted_of(app: &LightcraftApp, ids: &[u64]) -> Vec<u64> { let mut v: Vec<u64> =@@ -542,3 +681,105 @@ fn show_existing(app: &mut LightcraftApp, ctx: &egui::Context, existing: &[u64]) app.toast_for(ctx, msg, 6.0); true }++#[cfg(test)]+mod tests {+ use super::*;++ /// The worker's end is told from its death: a channel that closes after every batch was sent is a finished+ /// import, one that closes after a panic in readying a batch is an import that died (and ends as `Died`).+ #[cfg(not(target_arch = "wasm32"))]+ #[test]+ fn a_worker_that_panics_is_told_from_one_that_finished() {+ let ctx = egui::Context::default();+ let cancel = std::sync::atomic::AtomicBool::new(false);+ for (panics, died) in [(false, false), (true, true)] {+ let (tx, rx) = std::sync::mpsc::channel();+ let ended = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));+ let files: Vec<String> = (0..3).map(|i| format!("f{i}")).collect();+ let mut seen = 0;+ feed_batches(files, 1, &cancel, &tx, &ctx, &ended, |_| {+ seen += 1;+ if panics && seen == 2 {+ panic!("a bug in readying a batch");+ }+ lightcraft_engine::import::Prepared::default()+ });+ drop(tx);+ let sent = rx.try_iter().count();+ assert_eq!(sent, if panics { 1 } else { 3 }, "batches readied before the end");+ assert!(matches!(rx.try_recv(), Err(std::sync::mpsc::TryRecvError::Disconnected)), "the channel closed");+ let runner = Runner::Thread(rx, ended);+ assert_eq!(runner.died(), died, "panics: {panics}");+ }+ }++ #[test]+ fn an_import_whose_worker_died_ends_as_died() {+ use lightcraft_engine::activity::Ending;+ let mut task = ImportTask::new(vec!["a".into()], json!({}), 0, false);+ task.imported = 2;+ assert_eq!(ending_of(&task), Ending::Done);+ task.died = true;+ assert_eq!(ending_of(&task), Ending::Died);+ task.cancelled = true;+ assert_eq!(ending_of(&task), Ending::Stopped, "a stop the person asked for stays a stop");+ }++ /// Every way out of `finish` tells the activity stack how the import ended (a guard dropped without+ /// saying leaves no row): an Auto Import and a folder read that met unreadable files, and an import that+ /// only found duplicates it showed.+ #[test]+ fn every_way_an_import_ends_leaves_its_row_when_it_needs_one() {+ use lightcraft_engine::activity::{Cancel, Ending};+ let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() });+ let mut h = crate::headless::Headless::new(app, [1200.0, 800.0], 1.0);+ h.app.session.activity.keep_after(0);+ let ctx = egui::Context::default();+ let started = |h: &mut crate::headless::Headless| h.app.session.activity.start("import", "Importing", Cancel::Yes);+ // an Auto Import that could not read a file, though it was short+ let mut auto = ImportTask::new(vec![], json!({}), 0, false).auto();+ auto.failed = 1;+ auto.imported = 2;+ auto.guard = Some(started(&mut h));+ finish(&mut h.app, &ctx, auto);+ // a folder read for the Local view that could read none+ let mut browse = ImportTask::new(vec![], json!({}), 0, true);+ browse.failed = 3;+ browse.guard = Some(started(&mut h));+ finish(&mut h.app, &ctx, browse);+ // one that found only photos the library has, which it shows (and says so in a toast, as ever)+ let existing: Vec<u64> = h.app.session.catalog.photos().take(2).map(|p| p.id.0).collect();+ let mut quiet = ImportTask::new(vec![], json!({}), 0, false);+ quiet.existing = existing;+ quiet.guard = Some(started(&mut h));+ finish(&mut h.app, &ctx, quiet);+ let rows = h.app.session.activity.finished();+ assert!(h.app.session.activity.list().is_empty());+ let endings: Vec<(Ending, usize)> = rows.iter().rev().map(|r| (r.ending, r.failed)).collect();+ assert_eq!(endings, vec![(Ending::Problems, 1), (Ending::Failed, 3), (Ending::Done, 0)], "{rows:?}");+ }++ /// An import that met XMP warnings says so in its row, not only in the toast that passes.+ #[test]+ fn an_import_with_xmp_warnings_says_so_in_its_row() {+ use lightcraft_engine::activity::Cancel;+ let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() });+ let mut h = crate::headless::Headless::new(app, [1200.0, 800.0], 1.0);+ h.app.session.activity.keep_after(0);+ let ctx = egui::Context::default();+ let warning =+ || ImportWarning { path: "a.jpg".into(), code: ImportWarningCode::UnmappedXmp, profile: None, mode: None, fields: vec!["x".into()] };+ for (auto, browse) in [(true, false), (false, true), (false, false)] {+ let mut task = ImportTask::new(vec![], json!({}), 0, browse);+ task.auto = auto;+ task.imported = 1;+ task.warnings = vec![warning()];+ task.guard = Some(h.app.session.activity.start("import", "Importing", Cancel::Yes));+ finish(&mut h.app, &ctx, task);+ }+ let rows = h.app.session.activity.finished();+ assert_eq!(rows.len(), 3, "{rows:?}");+ assert!(rows.iter().all(|r| r.detail.contains("1 import warning")), "{rows:?}");+ }+}@@ -12,7 +12,7 @@ use std::sync::mpsc::{Receiver, channel}; use std::sync::{Arc, Mutex}; use lightcraft_catalog::PhotoId;-use lightcraft_engine::activity::{Cancel, TaskGuard, Unit};+use lightcraft_engine::activity::{Cancel, Ending, Outcome, TaskGuard, Unit}; use lightcraft_engine::merge::{MergeJob, MergeOutput}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json};@@ -307,22 +307,26 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { } else { "HDR" });- match r.map_err(lightcraft_engine::EngineError::Other).and_then(|out| app.session.finish_merge(&t.job, out)) {+ let (ending, said) = match r.map_err(lightcraft_engine::EngineError::Other).and_then(|out| app.session.finish_merge(&t.job, out)) { Ok(v) => { let message = added_message(what, &v); app.merge.last_result = Some(v);- app.toast(ctx, message);+ (Ending::Done, message) } Err(_) if t.cancel.load(Ordering::Relaxed) => { // cancelling one merge of a batch stops the batch app.merge.batch.clear();- app.toast(ctx, crate::i18n::tr_format!("{what} merge cancelled", what = what))+ (Ending::Stopped, crate::i18n::tr_format!("{what} merge cancelled", what = what)) } Err(e) => { app.ui.status = e.to_string();- app.toast(ctx, crate::i18n::tr_format!("{what} merge failed: {e}", e = e, what = what));+ (Ending::Failed, crate::i18n::tr_format!("{what} merge failed: {e}", e = e, what = what)) }+ };+ if let Some(guard) = t.guard {+ guard.finish(Outcome { ending, summary: said.clone(), ..Default::default() }); }+ app.toast(ctx, said); // a failed set doesn't stop the others while !app.merge.batch.is_empty() { match next_in_batch(app) {@@ -25,9 +25,11 @@ pub(crate) fn show_model_folder(app: &mut LightcraftApp) -> Result<serde_json::V "Show in File Manager", Some("openModelFolder"), move || open_folder(&folder),- |app, ctx, result| {- if let Err(e) = result {- app.toast_error(ctx, e);+ |app, ctx, result| match result {+ Ok(()) => lightcraft_engine::activity::Outcome::default(),+ Err(e) => {+ app.toast_error(ctx, e.clone());+ lightcraft_engine::activity::Outcome::failed(e) } }, )?;@@ -11,8 +11,8 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{Receiver, channel}; use std::sync::{Arc, Mutex}; -use lightcraft_engine::activity::{Cancel, TaskGuard};-use lightcraft_engine::export::{AfterExport, Destination, ExportOptions, PreparedExport, run_batch};+use lightcraft_engine::activity::{Cancel, Ending, Failure, Outcome, TaskGuard};+use lightcraft_engine::export::{AfterExport, Destination, DestinationMode, ExportOptions, PreparedExport, run_batch}; use serde_json::{Value, json}; use crate::LightcraftApp;@@ -27,6 +27,8 @@ pub struct ExportTask { rx: Receiver<Result<(Vec<Value>, Option<Value>, bool), String>>, /// The export's row in the activity stack (`activity.cancel` sets `cancel`). guard: TaskGuard,+ /// The folder the files go to, for the row the export leaves when it ends.+ folder: Option<String>, } impl ExportTask {@@ -37,6 +39,22 @@ impl ExportTask { } } +/// The folder a file is in, for the row of the contact sheet or print that writes it (none for a bare file name).+fn folder_of(path: &str) -> Option<String> {+ std::path::Path::new(path).parent().map(|d| d.to_string_lossy().into_owned()).filter(|d| !d.is_empty())+}++/// The folder the files of an export go to, for the row it leaves: the one an exact file path is in (the engine+/// uses it for a single photo only), none when each photo goes to its own folder (`dir` is not used then), else `dir`.+fn files_folder(to: &Destination, mode: DestinationMode, photos: usize) -> Option<String> {+ match (to.exact.as_ref().filter(|_| photos == 1), mode) {+ (Some(path), _) => folder_of(path),+ (None, DestinationMode::SameAsOriginal) => None,+ (None, _) => Some(to.dir.clone()),+ }+ .filter(|dir| !dir.is_empty())+}+ /// Start exporting `items` in the background, then do `after` with the files written (Add to Apple /// Photos). Errors per photo are collected, not fatal. pub fn start(app: &mut LightcraftApp, items: Vec<PreparedExport>, opts: ExportOptions, to: Destination, after: AfterExport) -> Result<Value, String> {@@ -49,6 +67,7 @@ pub fn start(app: &mut LightcraftApp, items: Vec<PreparedExport>, opts: ExportOp let cancel = Arc::new(AtomicBool::new(false)); let (tx, rx) = channel(); let (p, c) = (progress.clone(), cancel.clone());+ let folder = files_folder(&to, opts.destination_mode, total); let guard = app.session.activity.start("export", "Exporting", Cancel::Flag(cancel.clone())); guard.progress(0, total as u64); let row = guard.handle();@@ -81,7 +100,7 @@ pub fn start(app: &mut LightcraftApp, items: Vec<PreparedExport>, opts: ExportOp std::thread::Builder::new().name("export".into()).spawn(work).map_err(|e| e.to_string())?; #[cfg(target_arch = "wasm32")] work();- app.export = Some(ExportTask { total, progress, cancel, rx, guard });+ app.export = Some(ExportTask { total, progress, cancel, rx, guard, folder }); Ok(json!({"background": true, "total": total})) } @@ -100,6 +119,8 @@ pub fn start_contact_sheet(app: &mut LightcraftApp, params: &Value) -> Result<Va let cancel = Arc::new(AtomicBool::new(false)); let (p, c) = (progress.clone(), cancel.clone()); let (tx, rx) = channel();+ // (where the PDF goes, for the row the sheet leaves when it ends)+ let folder = folder_of(&path); let work = move || { let result = prepared .run(&mut |done, name| {@@ -128,7 +149,7 @@ pub fn start_contact_sheet(app: &mut LightcraftApp, params: &Value) -> Result<Va work(); let row = app.session.activity.start("export", "Exporting contact sheet", Cancel::Flag(cancel.clone())); row.progress(0, total as u64);- app.export = Some(ExportTask { total, progress, cancel, rx, guard: row });+ app.export = Some(ExportTask { total, progress, cancel, rx, guard: row, folder }); Ok(json!({"background": true, "total": total})) } @@ -153,6 +174,8 @@ pub fn start_print(app: &mut LightcraftApp, params: &Value) -> Result<Value, Str let (p, c) = (progress.clone(), cancel.clone()); let (tx, rx) = channel(); let photos = ids.len();+ // (where the PDF went, for the row the print leaves when it ends)+ let folder = folder_of(&path); let work = move || { let result = prepared .run(&mut |done, name| {@@ -181,7 +204,7 @@ pub fn start_print(app: &mut LightcraftApp, params: &Value) -> Result<Value, Str work(); let row = app.session.activity.start("export", "Printing to PDF", Cancel::Flag(cancel.clone())); row.progress(0, total as u64);- app.export = Some(ExportTask { total, progress, cancel, rx, guard: row });+ app.export = Some(ExportTask { total, progress, cancel, rx, guard: row, folder }); Ok(json!({"background": true, "total": total})) } @@ -232,63 +255,113 @@ pub fn poll_photos(app: &mut LightcraftApp, ctx: &egui::Context) { #[cfg(target_arch = "wasm32")] pub fn poll_photos(_: &mut LightcraftApp, _: &egui::Context) {} -/// Per frame: keep the activity row up to date; when the batch finishes, report it.+/// How an export that ran ended, for the activity stack. Photos taking only some of the files is a warning, and+/// Photos not asked at all (`skipped`: the export wrote no files, an earlier request still running…) is not an error+/// either: not all went well in both cases.+fn export_ending(cancelled: bool, ok: usize, failed: usize, photos: Option<&Value>) -> Ending {+ let photos_short = photos.is_some_and(|p| ["error", "warning", "skipped"].iter().any(|k| p.get(k).is_some()));+ if cancelled {+ Ending::Stopped+ } else if failed > 0 && ok == 0 {+ Ending::Failed+ } else if failed > 0 || photos_short {+ Ending::Problems+ } else {+ Ending::Done+ }+}++/// An export that came back as an error. The error is the row's detail: its headline stays empty, so the row reads+/// "Exporting: failed" (or "Printing to PDF: failed") under the task's own name.+fn failed_outcome(error: String, stopped: bool) -> Outcome {+ Outcome { ending: if stopped { Ending::Stopped } else { Ending::Failed }, detail: error, ..Default::default() }+}++/// Per frame: keep the activity row up to date; when the batch finishes, report it: in a toast, as ever, and to+/// the activity stack, where an export that ran long or did not go well stays in `activity.finished` with its+/// failed files and where its results are. pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { let Some(task) = &app.export else { return };- match task.rx.try_recv() {- Ok(r) => {- let cancelled = matches!(&r, Ok((_, _, true)));- let total = task.total;- app.export = None;- // Photos refused (permission, not installed…): leave the way out on screen longer- let photos_failed = matches!(&r, Ok((_, Some(p), _)) if p.get("error").is_some());- let msg = match r {- Ok((files, photos, _)) => {- let sheet = files.first().filter(|f| f["contactSheet"] == true);- let ok = files.iter().filter(|f| f.get("path").is_some()).count();- let failed: Vec<&Value> = files.iter().filter(|f| f.get("error").is_some()).collect();- let skipped = files.iter().filter(|f| f.get("skipped").is_some()).count();- let mut m =- crate::i18n::tr_format!("Exported {ok} of {total} photo{}", if total == 1 { "" } else { "s" }, ok = ok, total = total);- if let Some(sheet) = sheet {- m = format!("Exported contact sheet: {} photos, {} pages", sheet["photos"], sheet["pages"]);- }- if let Some(pdf) = files.first().filter(|f| f["print"] == true) {- m = format!("Printed to PDF: {} photos, {} pages", pdf["photos"], pdf["pages"]);- }- if skipped > 0 {- m += &crate::i18n::tr_format!(" · {skipped} skipped (file exists)", skipped = skipped);- }- if let Some(first) = failed.first() {- m += &crate::i18n::tr_format!(" · {} failed: {}", failed.len(), first["error"].as_str().unwrap_or("error"));- }- if cancelled {- m += " · cancelled";- }- if let Some(photos) = &photos {- m += " · ";- m += &apple_photos_note(photos);- }- app.last_export_result = Some(json!({"files": files, "cancelled": cancelled, "applePhotos": photos}));- m- }- Err(e) => e,- };- if photos_failed {- app.toast_for(ctx, msg, 15.0);- } else {- app.toast(ctx, msg);- }- }+ let r = match task.rx.try_recv() {+ Ok(r) => Some(r), Err(std::sync::mpsc::TryRecvError::Empty) => { let (done, current) = task.progress.lock().map(|g| g.clone()).unwrap_or_default(); task.guard.progress(done as u64, task.total as u64); task.guard.detail(¤t);+ return; }- Err(std::sync::mpsc::TryRecvError::Disconnected) => {- app.export = None;- app.toast(ctx, crate::i18n::tr("Export stopped unexpectedly"));+ Err(std::sync::mpsc::TryRecvError::Disconnected) => None,+ };+ let Some(task) = app.export.take() else { return };+ let total = task.total;+ let folder = task.folder.clone();+ // (a contact sheet that was stopped comes back as an error: it was stopped all the same)+ let stopped = task.cancel.load(Ordering::Relaxed);+ let Some(r) = r else {+ let msg = crate::i18n::tr("Export stopped unexpectedly");+ task.guard.finish(Outcome { ending: Ending::Died, summary: msg.to_string(), path: folder, ..Default::default() });+ app.toast(ctx, msg);+ return;+ };+ let cancelled = matches!(&r, Ok((_, _, true)));+ // Photos refused (permission, not installed…): leave the way out on screen longer+ let photos_failed = matches!(&r, Ok((_, Some(p), _)) if p.get("error").is_some());+ let (msg, outcome) = match r {+ Ok((files, photos, _)) => {+ let sheet = files.first().filter(|f| f["contactSheet"] == true);+ let ok = files.iter().filter(|f| f.get("path").is_some()).count();+ let failed: Vec<&Value> = files.iter().filter(|f| f.get("error").is_some()).collect();+ let skipped = files.iter().filter(|f| f.get("skipped").is_some()).count();+ let mut m = crate::i18n::tr_format!("Exported {ok} of {total} photo{}", if total == 1 { "" } else { "s" }, ok = ok, total = total);+ // (the finished row keeps this headline: it is translated like the toast's)+ if let Some(sheet) = sheet {+ m = crate::i18n::tr_format!(+ "Exported contact sheet: {photos} photos, {pages} pages",+ photos = sheet["photos"],+ pages = sheet["pages"]+ );+ }+ if let Some(pdf) = files.first().filter(|f| f["print"] == true) {+ m = crate::i18n::tr_format!("Printed to PDF: {photos} photos, {pages} pages", photos = pdf["photos"], pages = pdf["pages"]);+ }+ // what was done is the row's first line; the rest is what needs reading under it+ let summary = m.clone();+ if skipped > 0 {+ m += &crate::i18n::tr_format!(" · {skipped} skipped (file exists)", skipped = skipped);+ }+ if let Some(first) = failed.first() {+ m += &crate::i18n::tr_format!(" · {} failed: {}", failed.len(), first["error"].as_str().unwrap_or("error"));+ }+ if cancelled {+ m += " · ";+ m += crate::i18n::tr("cancelled");+ }+ if let Some(photos) = &photos {+ m += " · ";+ m += &apple_photos_note(photos);+ }+ let ending = export_ending(cancelled, ok, failed.len(), photos.as_ref());+ // one of the files written shows where they all are (a subfolder of the folder asked for, maybe)+ let path = files.iter().find_map(|f| f["path"].as_str()).map(str::to_string).or(folder);+ let failures = failed+ .iter()+ .map(|f| Failure {+ name: f["file"].as_str().unwrap_or_default().to_string(),+ reason: f["error"].as_str().unwrap_or("error").to_string(),+ })+ .collect();+ let detail = m.get(summary.len()..).unwrap_or_default().trim_start_matches(" · ").to_string();+ app.last_export_result = Some(json!({"files": files, "cancelled": cancelled, "applePhotos": photos}));+ (m, Outcome { ending, summary, detail, failures, path, ..Default::default() }) }+ // (the folder stays on the row, as for a worker that died: something may have been written there)+ Err(e) => (e.clone(), Outcome { path: folder, ..failed_outcome(e, stopped) }),+ };+ task.guard.finish(outcome);+ if photos_failed {+ app.toast_for(ctx, msg, 15.0);+ } else {+ app.toast(ctx, msg); } } @@ -396,6 +469,48 @@ mod contact_sheet_tests { let task = app.export.take().unwrap(); assert!(task.rx.recv_timeout(Duration::from_secs(20)).unwrap().is_err()); }++ /// A contact sheet stopped by its ✕ comes back as an error, but it is a stopped task, not a failed one.+ #[test]+ fn a_stopped_contact_sheet_leaves_a_stopped_row_that_knows_its_folder() {+ let mut app = LightcraftApp::new(+ lightcraft_engine::Session::with_demo(),+ crate::Services { write_shared: Some(Arc::new(|_, _| Ok(()))), ..Default::default() },+ );+ let ids: Vec<_> = app.session.catalog.photos().take(24).map(|p| p.id.0).collect();+ let path = std::env::temp_dir().join("lc-stopped-sheet.pdf");+ start_contact_sheet(&mut app, &json!({"path": path.to_string_lossy(), "ids": ids})).unwrap();+ let id = app.session.activity.list()[0].id;+ app.session.activity.cancel(id).unwrap();+ let ctx = egui::Context::default();+ let deadline = Instant::now() + Duration::from_secs(60);+ while app.export.is_some() {+ poll(&mut app, &ctx);+ assert!(Instant::now() < deadline, "the stopped sheet did not end");+ std::thread::sleep(Duration::from_millis(10));+ }+ let row = app.session.activity.finished().into_iter().next().expect("a stopped task always leaves a row");+ assert_eq!((row.kind, row.ending), ("export", Ending::Stopped), "{row:?}");+ // (the row keeps the folder the sheet was to go to: something may have been written there)+ assert_eq!(row.path.as_deref(), path.parent().and_then(|d| d.to_str()), "{row:?}");+ }++ /// A worker that is gone without a word leaves a row saying the export stopped unexpectedly.+ #[test]+ fn an_export_whose_worker_is_gone_leaves_a_died_row() {+ let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services::default());+ let cancel = Arc::new(AtomicBool::new(false));+ let guard = app.session.activity.start("export", "Exporting", Cancel::Flag(cancel.clone()));+ let (tx, rx) = channel();+ drop(tx);+ app.export =+ Some(ExportTask { total: 3, progress: Arc::new(Mutex::new((0, String::new()))), cancel, rx, guard, folder: Some("/tmp/out".into()) });+ poll(&mut app, &egui::Context::default());+ assert!(app.export.is_none());+ let row = app.session.activity.finished().into_iter().next().expect("a worker that died always leaves a row");+ assert_eq!((row.kind, row.ending, row.path.as_deref()), ("export", Ending::Died, Some("/tmp/out")), "{row:?}");+ assert_eq!(row.summary, "Export stopped unexpectedly");+ } } #[cfg(test)]@@ -424,4 +539,36 @@ mod tests { "not added to Apple Photos: an earlier, cancelled request is still running" ); }++ /// An Apple Photos step that did not add the files (error, warning, or skipped without a cancel) is a problem.+ #[test]+ fn export_ending_counts_an_uncancelled_photos_skip_as_a_problem() {+ let skipped = json!({"skipped": "the export wrote no files"});+ assert_eq!(export_ending(false, 3, 0, Some(&skipped)), Ending::Problems);+ assert_eq!(export_ending(false, 3, 0, Some(&json!({"warning": "added 1 of 3"}))), Ending::Problems);+ assert_eq!(export_ending(false, 3, 0, Some(&json!({"imported": 3}))), Ending::Done);+ assert_eq!(export_ending(false, 3, 0, None), Ending::Done);+ assert_eq!(export_ending(false, 0, 2, None), Ending::Failed);+ assert_eq!(export_ending(true, 3, 0, Some(&json!({"skipped": "cancelled", "cancelled": true}))), Ending::Stopped);+ }++ /// The row of an export points at the folder its files go to, not at one it does not use.+ #[test]+ fn an_exports_row_names_the_folder_the_files_go_to() {+ let to = |dir: &str, exact: Option<&str>| Destination { dir: dir.into(), exact: exact.map(str::to_string) };+ assert_eq!(files_folder(&to("/out", None), DestinationMode::SpecificFolder, 3).as_deref(), Some("/out"));+ assert_eq!(files_folder(&to("/out", Some("/pics/one.jpg")), DestinationMode::SpecificFolder, 1).as_deref(), Some("/pics"));+ // (an exact file is for a single photo: with more, the files go to the folder)+ assert_eq!(files_folder(&to("/out", Some("/pics/one.jpg")), DestinationMode::SpecificFolder, 2).as_deref(), Some("/out"));+ assert_eq!(files_folder(&to("/out", None), DestinationMode::SameAsOriginal, 3), None);+ assert_eq!(files_folder(&to("", None), DestinationMode::SpecificFolder, 1), None);+ }++ /// A failed export keeps its name in the row's headline; the error goes under it.+ #[test]+ fn a_failed_export_says_why_under_its_name() {+ let o = failed_outcome("The disk is full".into(), false);+ assert_eq!((o.ending, o.summary.as_str(), o.detail.as_str()), (Ending::Failed, "", "The disk is full"));+ assert_eq!(failed_outcome("x".into(), true).ending, Ending::Stopped);+ } }@@ -8,7 +8,7 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, mpsc}; -use lightcraft_engine::activity::{Cancel, TaskGuard};+use lightcraft_engine::activity::{Cancel, Ending, Failure, Outcome, TaskGuard}; use lightcraft_engine::lightroom_job::{LightroomProgress, LightroomStep}; use serde_json::{Value, json}; @@ -42,7 +42,8 @@ pub struct LightroomTask { rx: mpsc::Receiver<Message>, phase: Phase, cancelled: bool,- guard: TaskGuard,+ /// Its row in the activity stack; taken at the end to say how the task went.+ guard: Option<TaskGuard>, } impl LightroomTask {@@ -60,7 +61,7 @@ impl LightroomTask { Kind::Import => "Importing Lightroom catalog", }; let guard = s.activity.start("lightroom", label, Cancel::Flag(cancel.clone()));- let task = Self { kind, path, cancel, progress, rx, phase: Phase::Reading, cancelled: false, guard };+ let task = Self { kind, path, cancel, progress, rx, phase: Phase::Reading, cancelled: false, guard: Some(guard) }; task.refresh(); task }@@ -68,7 +69,9 @@ impl LightroomTask { /// Move to `phase`; only reading the catalog can stop, adding the photos runs to the end. fn enter(&mut self, phase: Phase) { self.phase = phase;- self.guard.set_cancellable(phase == Phase::Reading);+ if let Some(guard) = &self.guard {+ guard.set_cancellable(phase == Phase::Reading);+ } self.refresh(); } @@ -76,6 +79,7 @@ impl LightroomTask { /// (reading the photos takes nearly all of an import, so it is counted, not waited out); /// then what is being done. fn refresh(&self) {+ let Some(guard) = &self.guard else { return }; let (step, done, total) = self.progress.step(); let detail = match (self.phase, self.kind, step) { (Phase::Reading, Kind::Inspect, _) => "",@@ -86,11 +90,11 @@ impl LightroomTask { (Phase::Finalizing, ..) => crate::i18n::tr("Saving Lightroom import index…"), }; if self.phase == Phase::Reading {- self.guard.progress(done as u64, total as u64);+ guard.progress(done as u64, total as u64); } else {- self.guard.progress(0, 0);+ guard.progress(0, 0); }- self.guard.detail(detail);+ guard.detail(detail); } #[cfg(not(target_arch = "wasm32"))]@@ -258,7 +262,7 @@ fn inspect_report(data: lightcraft_engine::lightroom_catalog::CatalogImport) -> } /// The task is over: keep its result (with the catalog's `path`) for `status` and say how it went.-fn terminal(app: &mut LightcraftApp, ctx: &egui::Context, task: &LightroomTask, mut value: Value) {+fn terminal(app: &mut LightcraftApp, ctx: &egui::Context, task: &mut LightroomTask, mut value: Value) { let kind = task.kind; if let Some(o) = value.as_object_mut() { o.entry("path").or_insert_with(|| json!(task.path));@@ -306,11 +310,33 @@ fn terminal(app: &mut LightcraftApp, ctx: &egui::Context, task: &LightroomTask, app.lightroom_result = Some(result_dialog(&value, &message)); show_result(app); }+ let ending = if value.get("error").is_some() {+ Ending::Failed+ } else if value.get("cancelled").and_then(Value::as_bool).unwrap_or(false) {+ Ending::Stopped+ } else if value.get("indexWarning").is_some() || value.get("report").unwrap_or(&value)["failed"].as_array().is_some_and(|f| !f.is_empty()) {+ Ending::Problems+ } else {+ Ending::Done+ };+ if let Some(guard) = task.guard.take() {+ guard.finish(Outcome { ending, summary: message.clone(), failures: failed_files(&value), ..Default::default() });+ } app.lightroom_last = Some(value); app.lightroom = None; app.toast_for(ctx, message, 6.0); } +/// The files an import could not do, each with its reason, for the row it leaves (`report.failed` is a list of+/// `[path, reason]`).+fn failed_files(value: &Value) -> Vec<Failure> {+ let failed = value.get("report").unwrap_or(value)["failed"].as_array().map(Vec::as_slice).unwrap_or_default();+ failed+ .iter()+ .map(|f| Failure { name: f[0].as_str().unwrap_or_default().to_string(), reason: f[1].as_str().unwrap_or_default().to_string() })+ .collect()+}+ fn show_result(app: &mut LightcraftApp) { if app.ui.dialog.is_none() { app.ui.dialog = app.lightroom_result.take();@@ -441,7 +467,11 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { return; } Err(mpsc::TryRecvError::Disconnected) => {- terminal(app, ctx, &task, json!({"error": "Lightroom worker stopped unexpectedly"}));+ // the worker died (a panic): its row says so, rather than that the catalog failed+ if let Some(guard) = task.guard.take() {+ guard.finish(Outcome::ended(Ending::Died));+ }+ terminal(app, ctx, &mut task, json!({"error": "Lightroom worker stopped unexpectedly"})); return; } };@@ -449,35 +479,31 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { // error's words: a real failure that mentions "cancelled" is still reported as a failure. match message { Message::Inspected(result) => match result {- Ok(report) if !task.cancel.load(Ordering::Relaxed) => terminal(app, ctx, &task, report),- Ok(_) => terminal(app, ctx, &task, json!({"cancelled": true})),- Err(_) if task.cancel.load(Ordering::Relaxed) => terminal(app, ctx, &task, json!({"cancelled": true})),- Err(e) => terminal(app, ctx, &task, json!({"error": e})),+ Ok(report) if !task.cancel.load(Ordering::Relaxed) => terminal(app, ctx, &mut task, report),+ Ok(_) => terminal(app, ctx, &mut task, json!({"cancelled": true})),+ Err(_) if task.cancel.load(Ordering::Relaxed) => terminal(app, ctx, &mut task, json!({"cancelled": true})),+ Err(e) => terminal(app, ctx, &mut task, json!({"error": e})), }, Message::Prepared(result) => { let prepared = match result { Ok(p) => p, Err(_) if task.cancel.load(Ordering::Relaxed) => {- terminal(app, ctx, &task, json!({"cancelled": true}));+ terminal(app, ctx, &mut task, json!({"cancelled": true})); return; } Err(e) => {- terminal(app, ctx, &task, json!({"error": e}));+ terminal(app, ctx, &mut task, json!({"error": e})); return; } }; if task.cancel.load(Ordering::Relaxed) || prepared.is_cancelled() || !prepared.token().matches(&app.session) { prepared.rollback();- terminal(- app,- ctx,- &task,- if task.cancel.load(Ordering::Relaxed) {- json!({"cancelled": true})- } else {- json!({"error": "Lightroom import belongs to a different open library"})- },- );+ let value = if task.cancel.load(Ordering::Relaxed) {+ json!({"cancelled": true})+ } else {+ json!({"error": "Lightroom import belongs to a different open library"})+ };+ terminal(app, ctx, &mut task, value); return; } task.enter(Phase::Committing);@@ -494,7 +520,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { let (tx, rx) = mpsc::channel(); let commit_error = committed.as_ref().err().map(ToString::to_string); let Some(finalization) = finalization else {- terminal(app, ctx, &task, json!({"error": commit_error.clone().unwrap_or_else(|| "Lightroom import did not commit".into())}));+ terminal(app, ctx, &mut task, json!({"error": commit_error.clone().unwrap_or_else(|| "Lightroom import did not commit".into())})); return; }; if let Err(e) = spawn_finalize(finalization, tx, ctx.clone()) {@@ -502,7 +528,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { if let Some(error) = commit_error { result["error"] = json!(error); }- terminal(app, ctx, &task, result);+ terminal(app, ctx, &mut task, result); return; } task.rx = rx;@@ -518,14 +544,14 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { let pending = app.lightroom_last.take().unwrap_or(Value::Null); let report = pending.get("report").cloned().unwrap_or(Value::Null); match result {- Ok(()) if pending.get("error").is_none() => terminal(app, ctx, &task, report),- Ok(()) => terminal(app, ctx, &task, json!({"report": report, "error": pending["error"].clone()})),+ Ok(()) if pending.get("error").is_none() => terminal(app, ctx, &mut task, report),+ Ok(()) => terminal(app, ctx, &mut task, json!({"report": report, "error": pending["error"].clone()})), Err(e) => { let mut value = json!({"report": report, "indexWarning": e}); if let Some(error) = pending.get("error") { value["error"] = error.clone(); }- terminal(app, ctx, &task, value);+ terminal(app, ctx, &mut task, value); } } }@@ -573,6 +599,17 @@ mod tests { h } + /// An import that could not do some files names them in its row, whether the result is the report or wraps it.+ #[test]+ fn the_row_of_an_import_names_the_files_it_could_not_do() {+ let report = serde_json::json!({"failed": [["/photos/a.jpg", "no such file"], ["/photos/b.jpg", "unreadable"]], "photos": 2});+ let names = |v: &Value| failed_files(v).into_iter().map(|f| (f.name, f.reason)).collect::<Vec<_>>();+ let expected = vec![("/photos/a.jpg".to_string(), "no such file".to_string()), ("/photos/b.jpg".to_string(), "unreadable".to_string())];+ assert_eq!(names(&serde_json::json!({"report": report.clone()})), expected);+ assert_eq!(names(&report), expected);+ assert!(failed_files(&serde_json::json!({"imported": 3})).is_empty());+ }+ #[test] fn lightroom_row_is_not_cancellable_while_committing() { let mut h = demo();@@ -649,9 +686,9 @@ mod tests { mode: lightcraft_catalog::rules::Match::All, rules: vec![lightcraft_catalog::rules::Rule::Field { field: "rating".into(), op: "is".into(), value: json!(5) }], });- let (task, _tx, _, _) = fake_import(&h);+ let (mut task, _tx, _, _) = fake_import(&h); let value = json!({"photos": 1, "imported": 1, "collections": 0, "missing": ["D:/Photos/a.NEF"], "mapping": {"1": id}, "library": "C:/Pictures/LightCraft Library"});- terminal(&mut h.app, &h.view.ctx, &task, value);+ terminal(&mut h.app, &h.view.ctx, &mut task, value); assert!(h.app.ui.toast.as_ref().unwrap().0.contains("1 photos added")); h.step(); h.step();@@ -676,8 +713,8 @@ mod tests { #[test] fn lightroom_failure_stays_readable_without_offering_a_success_action() { let mut h = demo();- let (task, _tx, _, _) = fake_import(&h);- terminal(&mut h.app, &h.view.ctx, &task, json!({"error": "catalog or WAL exceeds 1 GiB import limit"}));+ let (mut task, _tx, _, _) = fake_import(&h);+ terminal(&mut h.app, &h.view.ctx, &mut task, json!({"error": "catalog or WAL exceeds 1 GiB import limit"})); h.step(); h.step(); assert!(h.painted_text().join(" ").contains("exceeds 1 GiB import limit"));@@ -689,8 +726,8 @@ mod tests { fn lightroom_result_waits_for_an_existing_dialog() { let mut h = demo(); h.app.ui.dialog = Some(crate::state::Dialog::About);- let (task, _tx, _, _) = fake_import(&h);- terminal(&mut h.app, &h.view.ctx, &task, json!({"error": "catalog could not be read"}));+ let (mut task, _tx, _, _) = fake_import(&h);+ terminal(&mut h.app, &h.view.ctx, &mut task, json!({"error": "catalog could not be read"})); assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::About))); assert!(h.app.lightroom_result.is_some()); h.app.ui.dialog = None;@@ -622,7 +622,7 @@ fn find_missing_waited_completes_while_unrelated_task_remains_active() { move || { let _ = rx_unblock.recv(); },- |_app, _ctx, _| {},+ |_app, _ctx, _| lightcraft_engine::activity::Outcome::default(), ) .unwrap(); assert!(app.tasks.is_running("Unrelated Long Background Task"));@@ -1499,22 +1499,22 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re } else { String::new() };- app.toast(- ctx,- crate::i18n::tr_format!(- "Found {n} missing photo{}; {left} still missing{unsure}",- if n == 1 { "" } else { "s" },- left = left,- n = n,- unsure = unsure- ),+ let said = crate::i18n::tr_format!(+ "Found {n} missing photo{}; {left} still missing{unsure}",+ if n == 1 { "" } else { "s" },+ left = left,+ n = n,+ unsure = unsure );+ app.toast(ctx, said.clone()); app.ui.last_find_missing = Some(v.clone()); app.ui.last_find_missing_result = Some(Ok(v));+ lightcraft_engine::activity::Outcome { summary: said, ..Default::default() } } Err(e) => { app.toast(ctx, e.clone());- app.ui.last_find_missing_result = Some(Err(e));+ app.ui.last_find_missing_result = Some(Err(e.clone()));+ lightcraft_engine::activity::Outcome::failed(e) } } };@@ -616,6 +616,7 @@ fn start_checks(app: &mut LightcraftApp, paths: Vec<String>) { app.recent_checks.known.insert(path, Answer::from_check(why, delivered_frame)); apply_answers(app); ctx.request_repaint();+ lightcraft_engine::activity::Outcome::default() } }); match started {@@ -3,6 +3,7 @@ //! `lightcraft_engine::cmd::recheck`). The probe runs on a worker thread, so opening never waits //! for it; the result is applied back here, unless another library was opened meanwhile. +use lightcraft_engine::activity::{Ending, Outcome}; use lightcraft_engine::cmd::recheck; use crate::LightcraftApp;@@ -26,7 +27,8 @@ pub fn tick(app: &mut LightcraftApp) { let work = move || recheck::probe(&probe, stale); let done = move |app: &mut LightcraftApp, ctx: &egui::Context, results| { if app.session.library_generation() != library {- return;+ // the library was left: the user did not stop this, and there is nothing to look at+ return Outcome::ended(Ending::Done); } match recheck::apply(&mut app.session, results) { Ok(r) => {@@ -36,8 +38,12 @@ pub fn tick(app: &mut LightcraftApp) { } else if n > 1 { app.toast(ctx, crate::i18n::tr_format!("{n} raws can now be developed from their raw data", n = n)); }+ Outcome::ended(Ending::Done)+ }+ Err(e) => {+ log::warn!("recheck of preview-only raws: {e}");+ Outcome::failed(e.to_string()) }- Err(e) => log::warn!("recheck of preview-only raws: {e}"), } }; if let Err(e) = crate::tasks::spawn(app, LABEL, Some("recheck"), work, done) {@@ -9,6 +9,7 @@ use std::sync::Arc; use std::sync::atomic::AtomicBool; use lightcraft_engine::EngineError;+use lightcraft_engine::activity::{Ending, Failure, Outcome}; use lightcraft_engine::cmd::reload; use serde_json::{Value, json}; @@ -48,30 +49,48 @@ pub fn command(app: &mut LightcraftApp, p: &Value) -> Option<Result<Value, Strin let total = items.len() as u64; task.progress(0, total); let results = reload::probe(probe.as_ref(), &items, &flag, &|done| task.progress(done as u64, total));- (items, results)+ // the files that could not be read (a stop leaves the ones it never reached as errors: those are not failures)+ let unread: Vec<Failure> = if flag.load(std::sync::atomic::Ordering::Relaxed) {+ Vec::new()+ } else {+ items.iter().zip(&results).filter_map(|(i, r)| r.as_ref().err().map(|e| Failure { name: i.path.clone(), reason: e.clone() })).collect()+ };+ (items, results, unread) };- let done = move |app: &mut LightcraftApp, ctx: &egui::Context, (items, results)| {+ let done = move |app: &mut LightcraftApp, ctx: &egui::Context, (items, results, unread): (_, _, Vec<Failure>)| {+ let was_stopped = stopped.load(std::sync::atomic::Ordering::Relaxed);+ let ending = if was_stopped {+ Ending::Stopped+ } else if unread.is_empty() {+ Ending::Done+ } else {+ Ending::Problems+ };+ let outcome = Outcome { ending, failures: unread, ..Default::default() }; if app.session.library_generation() != library {- return;+ // the library was left: there is nothing to look at (a ✕ pressed before that still counts)+ return outcome; } // as the command: one undo step, the durable save and the automatic XMP sidecar write if let Err(e) = app.session.execute_fn("photo.reload", |s| reload::apply(s, items, results).map(|ids| reload::to_json(&ids))) {- return app.toast_error(ctx, e.to_string());+ app.toast_error(ctx, e.to_string());+ return Outcome::failed(e.to_string()); } let cleared = preview_only.iter().filter(|id| app.session.catalog.photo(**id).is_some_and(|ph| ph.preview_only.is_none())).count(); // stopped before the files were read: they weren't tried again, so say nothing about them // (a raw that did decode before the stop is still reported)- if stopped.load(std::sync::atomic::Ordering::Relaxed) && cleared == 0 {- return;+ if was_stopped && cleared == 0 {+ return outcome; } let message = match (count, preview_only.len(), cleared) { (1, 1, 1) => crate::i18n::tr("This raw is now developed from its raw data").to_string(), (1, 1, _) => crate::i18n::tr("This raw variant still can't be decoded").to_string(),- (_, _, 0) => return,+ (_, _, 0) => return outcome, (_, _, 1) => crate::i18n::tr("1 raw can now be developed from its raw data").to_string(), (_, _, n) => crate::i18n::tr_format!("{n} raws can now be developed from their raw data", n = n), }; app.toast(ctx, message);+ outcome }; if let Err(e) = crate::tasks::spawn_tracked(app, LABEL, "reload", cancel, work, done) { return Some(Err(e));@@ -7,7 +7,7 @@ use std::sync::Arc; use std::sync::atomic::Ordering; -use lightcraft_engine::activity::{Cancel, TaskGuard};+use lightcraft_engine::activity::{Cancel, Ending, Failure, Outcome, TaskGuard}; use lightcraft_engine::sync::{FolderChanges, SyncChoice, SyncCommit, SyncInput, SyncJob, SyncProgress, SyncStep, scan_with}; use serde_json::{Value, json}; @@ -106,6 +106,10 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { } if task.guard.is_cancelled() { // ✕ on its row: a scan stopped part-way has nothing to show+ if let Some(task) = app.sync.take() {+ task.progress.files.cancel.store(true, Ordering::Relaxed);+ task.guard.finish(Outcome::ended(Ending::Stopped));+ } cancel(app); app.ui.dialog = None; return;@@ -118,13 +122,24 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { Ok(c) => c, Err(std::sync::mpsc::TryRecvError::Empty) => return, Err(_) => {- app.sync = None;+ // the worker died (a panic, logged)+ if let Some(task) = app.sync.take() {+ task.guard.finish(Outcome::ended(Ending::Died));+ } app.ui.dialog = None; app.toast(ctx, crate::i18n::tr("Scan failed")); return; } };- app.sync = None;+ if let Some(task) = app.sync.take() {+ task.guard.finish(if changes.offline {+ Outcome::failed(crate::i18n::tr("This folder isn't there: it was moved or renamed, or its disk isn't connected."))+ } else if !changes.unreadable.is_empty() {+ unreadable_outcome(&changes)+ } else {+ Outcome::default()+ });+ } let c = SyncCounts { offline: changes.offline, new: changes.new.len(),@@ -141,6 +156,20 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { } } +/// The scan's end when files in the folder could not be read: the row names each one by where it is below the+/// scanned folder, with why.+fn unreadable_outcome(changes: &FolderChanges) -> Outcome {+ let roots = [changes.path.clone()];+ let failures = changes+ .unreadable+ .iter()+ .map(|c| Failure { name: crate::import::failure_name(&c.path, &roots), reason: c.error.clone().unwrap_or_default() })+ .take(lightcraft_engine::activity::MAX_FAILURES)+ .collect();+ let failed = changes.unreadable.len();+ Outcome { ending: Ending::Problems, detail: crate::import::not_readable_text(failed), failed, failures, ..Default::default() }+}+ /// How far the scan is: the folders and files listed while it lists the folder, else the files /// and photos checked (done, of how many). enum ScanStage {@@ -275,7 +304,8 @@ pub fn confirm(app: &mut LightcraftApp, dlg: &Dialog) -> Result<Value, String> { job(); let guard = app.session.activity.start("sync", "Synchronizing folder", Cancel::Flag(progress.files.cancel.clone())); guard.detail(name);- app.sync_run = Some(SyncRun { name: name.clone(), progress, crashed, rx, commit: Some(commit), stopping: false, not_saved: Vec::new(), guard });+ app.sync_run =+ Some(SyncRun { name: name.clone(), progress, crashed, rx, commit: Some(commit), stopping: false, not_saved: Vec::new(), guard: Some(guard) }); Ok(json!({"started": true})) } @@ -292,8 +322,8 @@ pub struct SyncRun { commit: Option<SyncCommit>, /// Cancel was pressed: nothing new is started; what was readied is still committed. stopping: bool,- /// Its row in the activity stack (its ✕ is Cancel).- guard: TaskGuard,+ /// Its row in the activity stack (its ✕ is Cancel); taken at the end to say how the run went.+ guard: Option<TaskGuard>, } impl SyncRun {@@ -332,7 +362,7 @@ pub(crate) fn stall_run(app: &mut LightcraftApp, name: &str) -> Box<dyn std::any commit: None, stopping: false, not_saved: Vec::new(),- guard,+ guard: Some(guard), }); Box::new(tx) }@@ -342,11 +372,13 @@ pub(crate) fn stall_run(app: &mut LightcraftApp, name: &str) -> Box<dyn std::any pub fn poll_run(app: &mut LightcraftApp, ctx: &egui::Context) { let Some(run) = app.sync_run.as_mut() else { return }; ctx.request_repaint_after(std::time::Duration::from_millis(100));- if run.guard.is_cancelled() {+ if run.guard.as_ref().is_some_and(TaskGuard::is_cancelled) { run.stopping = true; } let (done, total) = run.counts();- run.guard.progress(done as u64, total as u64);+ if let Some(guard) = &run.guard {+ guard.progress(done as u64, total as u64);+ } let mut steps = Vec::new(); let finished = loop { match run.rx.try_recv() {@@ -372,8 +404,8 @@ pub fn poll_run(app: &mut LightcraftApp, ctx: &egui::Context) { run.commit = Some(commit); return; }- let (name, stopped, crashed, mut not_saved) =- (run.name.clone(), run.stopping, run.crashed.load(Ordering::Relaxed), std::mem::take(&mut run.not_saved));+ let (name, stopped, crashed, mut not_saved, guard) =+ (run.name.clone(), run.stopping, run.crashed.load(Ordering::Relaxed), std::mem::take(&mut run.not_saved), run.guard.take()); app.sync_run = None; let mut report = None; if let Err(e) = app.session.execute_fn("folder.synchronize", |s| {@@ -405,6 +437,19 @@ pub fn poll_run(app: &mut LightcraftApp, ctx: &egui::Context) { parts.push(format!("{}: {e}", crate::i18n::tr("Not saved"))); } let text = if parts.is_empty() { crate::i18n::tr("The library is up to date with this folder.").to_string() } else { parts.join(" · ") };+ if let Some(guard) = guard {+ let ending = if crashed {+ Ending::Died+ } else if stopped {+ Ending::Stopped+ } else if !r.failed.is_empty() || !not_saved.is_empty() {+ Ending::Problems+ } else {+ Ending::Done+ };+ let failures = r.failed.iter().map(|(path, e)| Failure { name: path.clone(), reason: e.clone() }).collect();+ guard.finish(Outcome { ending, summary: format!("{name}: {text}"), failures, ..Default::default() });+ } if not_saved.is_empty() && !crashed { app.toast(ctx, format!("{name}: {text}")); } else {@@ -6,16 +6,18 @@ use std::sync::mpsc::{Receiver, TryRecvError, channel}; +use lightcraft_engine::activity::{Ending, Outcome, TaskGuard};+ use crate::LightcraftApp; -/// Applies a task's result to the app (on the UI thread).-type Finish = Box<dyn FnOnce(&mut LightcraftApp, &egui::Context) + Send>;+/// Applies a task's result to the app (on the UI thread), and says how the task ended.+type Finish = Box<dyn FnOnce(&mut LightcraftApp, &egui::Context) -> Outcome + Send>; struct Task { label: String, rx: Receiver<Finish>,- /// Its row in the activity stack (none for quiet tasks); goes with the task.- _guard: Option<lightcraft_engine::activity::TaskGuard>,+ /// Its row in the activity stack (none for quiet tasks); it says how the task ended.+ guard: Option<TaskGuard>, } /// The background tasks in flight.@@ -43,13 +45,14 @@ impl Tasks { /// Run `work` on a worker thread, then `done(app, ctx, result)` on the UI thread (in the browser /// build, which has no threads, `work` runs at once and `done` on the next frame). With `kind`, the-/// activity stack shows `label` meanwhile, without a count and without ✕ (the work can't be stopped).+/// activity stack shows `label` meanwhile, without a count and without ✕ (the work can't be stopped);+/// what `done` returns is how its row says the task ended. pub fn spawn<T: Send + 'static>( app: &mut LightcraftApp, label: &str, kind: Option<&'static str>, work: impl FnOnce() -> T + Send + 'static,- done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) + Send + 'static,+ done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) -> Outcome + Send + 'static, ) -> Result<(), String> { let guard = kind.map(|k| app.session.activity.start(k, label, lightcraft_engine::activity::Cancel::No)); start(app, label, guard, work, done)@@ -57,14 +60,14 @@ pub fn spawn<T: Send + 'static>( /// [`spawn`] for a task with progress that can stop: its row (`kind`, `label`) adopts `cancel`, /// the flag `work` checks, so the row's ✕ and `activity.cancel` stop it; `work` reports progress-/// through the handle it is given.+/// through the handle it is given; what `done` returns is how its row says the task ended. pub fn spawn_tracked<T: Send + 'static>( app: &mut LightcraftApp, label: &str, kind: &'static str, cancel: std::sync::Arc<std::sync::atomic::AtomicBool>, work: impl FnOnce(lightcraft_engine::activity::TaskHandle) -> T + Send + 'static,- done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) + Send + 'static,+ done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) -> Outcome + Send + 'static, ) -> Result<(), String> { let guard = app.session.activity.start(kind, label, lightcraft_engine::activity::Cancel::Flag(cancel)); let handle = guard.handle();@@ -75,15 +78,15 @@ pub fn spawn_tracked<T: Send + 'static>( fn start<T: Send + 'static>( app: &mut LightcraftApp, label: &str,- guard: Option<lightcraft_engine::activity::TaskGuard>,+ guard: Option<TaskGuard>, work: impl FnOnce() -> T + Send + 'static,- done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) + Send + 'static,+ done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) -> Outcome + Send + 'static, ) -> Result<(), String> { let (tx, rx) = channel::<Finish>(); let repaint = app.tasks.repaint.clone(); let job = move || { let t = work();- let _ = tx.send(Box::new(move |app: &mut LightcraftApp, ctx: &egui::Context| done(app, ctx, t)));+ let _ = tx.send(Box::new(move |app: &mut LightcraftApp, ctx: &egui::Context| done(app, ctx, t)) as Finish); if let Some(c) = repaint { c.request_repaint(); }@@ -92,7 +95,7 @@ fn start<T: Send + 'static>( std::thread::Builder::new().name(format!("lc-task-{label}")).spawn(job).map_err(|e| format!("{label}: could not start: {e}"))?; #[cfg(target_arch = "wasm32")] job();- app.tasks.running.push(Task { label: label.to_string(), rx, _guard: guard });+ app.tasks.running.push(Task { label: label.to_string(), rx, guard }); Ok(()) } @@ -103,14 +106,20 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { while i < app.tasks.running.len() { match app.tasks.running[i].rx.try_recv() { Ok(finish) => {- app.tasks.running.remove(i);- finish(app, ctx);+ let t = app.tasks.running.remove(i);+ let outcome = finish(app, ctx);+ if let Some(guard) = t.guard {+ guard.finish(outcome);+ } } Err(TryRecvError::Empty) => i += 1, Err(TryRecvError::Disconnected) => { // the worker died (a panic, already logged) let t = app.tasks.running.remove(i); app.toast(ctx, crate::i18n::tr_format!("{} failed", t.label));+ if let Some(guard) = t.guard {+ guard.finish(Outcome::ended(Ending::Died));+ } } } }@@ -2,7 +2,7 @@ use std::time::Duration; -use lightcraft_engine::activity::{Cancel, TaskInfo, Unit};+use lightcraft_engine::activity::{Cancel, Ending, TaskInfo, Unit}; use serde_json::json; use crate::headless::Headless;@@ -336,6 +336,8 @@ fn scan_shows_a_row_and_cancel_closes_it() { h.step(); } assert!(h.app.ui.dialog.is_none(), "no review opened");+ let row = h.app.session.activity.finished().into_iter().next().expect("a stopped scan leaves a row");+ assert_eq!((row.label.as_str(), row.ending), ("Scanning folder", Ending::Stopped)); let _ = std::fs::remove_dir_all(&dir); } @@ -378,6 +380,8 @@ fn final_merge_shows_a_row_and_cancels() { assert!(h.app.session.activity.list().is_empty()); let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); assert_eq!(toast, "HDR merge cancelled");+ let row = h.app.session.activity.finished().into_iter().next().expect("a stopped merge leaves a row");+ assert_eq!((row.ending, row.summary.as_str()), (Ending::Stopped, "HDR merge cancelled")); let _ = std::fs::remove_dir_all(dir); } @@ -467,6 +471,7 @@ fn batch_merge_runs_each_set_in_turn() { #[test] fn find_missing_shows_an_indeterminate_row() { let mut h = demo();+ h.app.session.activity.keep_after(0); let dir = std::env::temp_dir().join(format!("lc-activity-findmissing-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); // run directly, not through a request (which runs frames): the search of an empty folder is over at once, but its@@ -479,6 +484,25 @@ fn find_missing_shows_an_indeterminate_row() { assert_eq!((rows[0].total, rows[0].cancellable), (0, false), "indeterminate, no ✕"); assert!(h.step_until(T, |h| h.app.tasks.is_empty())); assert!(h.app.session.activity.list().is_empty(), "the row goes with the task");+ let row = h.app.session.activity.finished().into_iter().next().expect("and a row says what it found");+ assert_eq!(row.ending, Ending::Done);+ assert!(row.summary.starts_with("Found 0 missing photos"), "{:?}", row.summary);+ let _ = std::fs::remove_dir_all(dir);+}++/// A Lightroom catalog that can't be read leaves a row saying the task failed, and why.+#[test]+fn a_lightroom_catalog_that_cannot_be_read_leaves_a_failed_row() {+ let mut h = demo();+ let dir = std::env::temp_dir().join(format!("lc-activity-lrcat-{}", std::process::id()));+ std::fs::create_dir_all(&dir).unwrap();+ let path = dir.join("Not a catalog.lrcat");+ std::fs::write(&path, b"this is not a Lightroom catalog").unwrap();+ let r = crate::menus::run_ui_command(&mut h.app, "library.inspectLightroom", &json!({"path": path.to_string_lossy(), "wait": true}));+ assert!(r.is_some(), "the command is the interface's");+ let row = h.app.session.activity.finished().into_iter().next().expect("a failed task always leaves a row");+ assert_eq!((row.kind, row.ending), ("lightroom", Ending::Failed));+ assert!(row.summary.starts_with("Lightroom inspection failed"), "{:?}", row.summary); let _ = std::fs::remove_dir_all(dir); } @@ -534,3 +558,130 @@ fn immediate_library_commands_and_auto_sync_show_completion() { assert!(h.app.run("library.compact", json!({})).is_err()); assert!(h.app.ui.toast.is_none()); }++/// The demo app, reading the files as they are on disk (the demo library reads none), keeping every ended task's row.+fn demo_on_disk() -> Headless {+ let mut h = demo();+ h.app.session.media.file_probe = lightcraft_engine::Session::new().with_fs().media.file_probe;+ h.app.session.activity.keep_after(0);+ h+}++/// A folder with a valid PNG and, in a subfolder, a JPEG cut short before its end-of-image marker (the real reason).+fn folder_with_a_truncated_jpeg(tag: &str) -> std::path::PathBuf {+ use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta};+ let dir = std::env::temp_dir().join(format!("lc-activity-unreadable-{tag}-{}", std::process::id()));+ let _ = std::fs::remove_dir_all(&dir);+ std::fs::create_dir_all(dir.join("trip")).unwrap();+ let img = lightcraft_raster::Rgba8 { width: 64, height: 48, data: vec![[90, 120, 160, 255]; 64 * 48] };+ let png = lightcraft_codecs::encode_png(&EncodeImage::rgba8(&img), &EncodeMeta::default()).unwrap();+ std::fs::write(dir.join("good.png"), png).unwrap();+ let mut jpeg = lightcraft_codecs::encode_jpeg(&EncodeImage::rgba8(&img), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();+ jpeg.truncate(jpeg.len() - 2);+ std::fs::write(dir.join("trip").join("bad.jpg"), jpeg).unwrap();+ dir+}++/// What an import's row says it could not read.+fn the_row_names_the_truncated_jpeg(h: &Headless) {+ let rows = h.app.session.activity.finished();+ assert_eq!(rows.len(), 1, "{rows:?}");+ let row = &rows[0];+ assert_eq!((row.kind, row.ending, row.failed), ("import", Ending::Problems, 1), "{row:?}");+ let names: Vec<&str> = row.failures.iter().map(|f| f.name.as_str()).collect();+ assert_eq!(names, vec![std::path::Path::new("trip").join("bad.jpg").to_string_lossy()], "{row:?}");+ assert!(row.failures[0].reason.contains("truncated"), "{}", row.failures[0].reason);+}++#[test]+fn an_import_row_names_each_file_it_could_not_read_and_why() {+ let dir = folder_with_a_truncated_jpeg("paths");+ let mut h = demo_on_disk();+ crate::import::start_paths(&mut h.app, vec![dir.to_string_lossy().to_string()]).unwrap();+ assert!(h.step_until(Duration::from_secs(60), |h| h.app.import.is_none()));+ the_row_names_the_truncated_jpeg(&h);+ let _ = std::fs::remove_dir_all(&dir);+}++/// The review hands the engine the checked files, not the folder: the names of those it cannot read still start at+/// the source folder. (A file the scan could not read is not offered, so this one is made to look readable.)+#[test]+fn a_reviewed_imports_row_names_unreadable_files_below_its_source() {+ let dir = folder_with_a_truncated_jpeg("review");+ let mut h = demo_on_disk();+ crate::import::open(&mut h.app, vec![dir.to_string_lossy().to_string()]).unwrap();+ assert!(h.step_until(T, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. }))));+ h.app.session.activity.dismiss_all();+ let Some(crate::state::Dialog::Import { opts }) = &mut h.app.ui.dialog else { panic!("no review") };+ for c in &mut opts.candidates {+ c.error = None;+ }+ opts.checked.iter_mut().for_each(|on| *on = true);+ assert_eq!(opts.selected_paths().len(), 2);+ assert_eq!(h.request("ui.dialog.confirm", json!({}), T)["ok"], true);+ assert!(h.step_until(Duration::from_secs(60), |h| h.app.import.is_none()));+ the_row_names_the_truncated_jpeg(&h);+ let _ = std::fs::remove_dir_all(&dir);+}++/// A scan that ran to its end says so, and says when it found nothing to import.+#[test]+fn a_scan_that_found_nothing_leaves_a_row_saying_so() {+ let mut h = demo();+ h.app.session.activity.keep_after(0);+ let dir = std::env::temp_dir().join(format!("lc-activity-emptyscan-{}", std::process::id()));+ std::fs::create_dir_all(&dir).unwrap();+ crate::import::open(&mut h.app, vec![dir.to_string_lossy().to_string()]).unwrap();+ assert!(h.step_until(T, |h| h.app.scan.is_none()));+ let row = h.app.session.activity.finished().into_iter().next().expect("a row for the scan");+ assert_eq!((row.label.as_str(), row.ending, row.detail.as_str()), ("Scanning folder", Ending::Done, "No photos found"));+ let _ = std::fs::remove_dir_all(dir);+}++/// A scan that met files it could not read says so in its row, by name, whether it was for the import review or the+/// Local view (which leaves those files out, so nothing later would say it). The Local view's row says which folder,+/// and opening that folder again replaces it instead of adding another.+#[test]+fn a_scan_that_met_unreadable_files_leaves_a_row_with_problems() {+ for browse in [false, true] {+ let mut h = demo();+ h.app.session.activity.keep_after(0);+ // the files are read as on disk (the demo library reads none)+ h.app.session.media.file_probe = lightcraft_engine::Session::new().with_fs().media.file_probe;+ let dir = std::env::temp_dir().join(format!("lc-activity-badscan-{browse}-{}", std::process::id()));+ std::fs::create_dir_all(dir.join("sub")).unwrap();+ std::fs::write(dir.join("broken.jpg"), b"not a jpeg at all").unwrap();+ std::fs::write(dir.join("sub").join("also.jpg"), b"not a jpeg either").unwrap();+ let path = dir.to_string_lossy().to_string();+ let scan = |h: &mut Headless| {+ if browse {+ crate::import::browse(&mut h.app, &path, Some(true)).unwrap();+ } else {+ crate::import::open(&mut h.app, vec![path.clone()]).unwrap();+ }+ assert!(h.step_until(T, |h| h.app.scan.is_none()));+ };+ scan(&mut h);+ let names = |h: &Headless| {+ let mut v: Vec<String> = h.app.session.activity.finished()[0].failures.iter().map(|f| f.name.clone()).collect();+ v.sort();+ v+ };+ let row = h.app.session.activity.finished()[0].clone();+ assert_eq!((row.ending, row.failed, row.detail.as_str()), (Ending::Problems, 2, "2 photos not readable"), "browse {browse}: {row:?}");+ // the Local view names a file by where it is in the folder (two subfolders can hold the same name)+ let deeper = if browse { std::path::Path::new("sub").join("also.jpg").to_string_lossy().into_owned() } else { "also.jpg".into() };+ let mut want = vec!["broken.jpg".to_string(), deeper];+ want.sort();+ assert_eq!(names(&h), want, "{row:?}");+ if browse {+ let folder = std::path::absolute(&dir).unwrap().to_string_lossy().trim_end_matches(std::path::MAIN_SEPARATOR).to_string();+ assert_eq!(row.path.as_deref(), Some(folder.as_str()), "{row:?}");+ scan(&mut h);+ let rows = h.app.session.activity.finished();+ assert_eq!(rows.len(), 1, "one row for the folder: {rows:?}");+ assert!(rows[0].id != row.id && rows[0].failed == 2, "the new row is the one kept: {rows:?}");+ }+ let _ = std::fs::remove_dir_all(dir);+ }+}@@ -314,6 +314,57 @@ fn printing_shows_a_row_in_the_activity_stack_and_stops() { assert!(app.session.activity.list().is_empty(), "the row goes with the print"); } +#[test]+fn a_print_that_fails_leaves_a_row_that_still_knows_its_folder() {+ use lightcraft_engine::activity::Ending;+ let mut app = LightcraftApp::new(+ lightcraft_engine::Session::with_demo(),+ Services { write_shared: Some(Arc::new(|_, _| Err("The disk is full".into()))), ..Default::default() },+ );+ app.session.execute("print.set", &json!({"job": {"ppi": 100.0}})).unwrap();+ let ids: Vec<_> = app.session.catalog.photos().take(1).map(|p| p.id.0).collect();+ let dir = std::env::temp_dir().join("lc-print-fails");+ let path = dir.join("Full.pdf").to_string_lossy().into_owned();+ crate::export_task::start_print(&mut app, &json!({"path": path, "ids": ids})).unwrap();+ let ctx = egui::Context::default();+ let until = std::time::Instant::now() + Duration::from_secs(60);+ while app.export.is_some() && std::time::Instant::now() < until {+ crate::export_task::poll(&mut app, &ctx);+ std::thread::sleep(Duration::from_millis(10));+ }+ assert!(app.export.is_none(), "the print did not end");+ let rows = app.session.activity.finished();+ assert_eq!(rows.len(), 1, "{rows:?}");+ assert_eq!((rows[0].ending, rows[0].detail.as_str()), (Ending::Failed, "The disk is full"), "{:?}", rows[0]);+ // the row can still show where the PDF was going+ assert_eq!(rows[0].path.as_deref(), Some(dir.to_string_lossy().as_ref()), "{:?}", rows[0]);+}++/// A print that ended leaves a finished row (`activity.finished`): its headline is in the interface's language, like+/// the rest of the row.+#[test]+fn a_finished_print_row_is_in_the_interface_language() {+ crate::i18n::set_language(crate::i18n::Locale::De);+ let mut app =+ LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { write_shared: Some(Arc::new(|_, _| Ok(()))), ..Default::default() });+ app.session.activity.keep_after(0);+ app.session.execute("print.set", &json!({"job": {"ppi": 100.0}})).unwrap();+ let ids: Vec<_> = app.session.catalog.photos().take(1).map(|p| p.id.0).collect();+ let path = std::env::temp_dir().join("lc-print-de").join("Druck.pdf").to_string_lossy().into_owned();+ crate::export_task::start_print(&mut app, &json!({"path": path, "ids": ids})).unwrap();+ let ctx = egui::Context::default();+ let until = std::time::Instant::now() + Duration::from_secs(60);+ while app.export.is_some() && std::time::Instant::now() < until {+ crate::export_task::poll(&mut app, &ctx);+ std::thread::sleep(Duration::from_millis(10));+ }+ let rows = app.session.activity.finished();+ // the catalog's own words, not ours: the row says what the German catalog says+ let headline = crate::i18n::tr_format!("Printed to PDF: {photos} photos, {pages} pages", photos = 1, pages = 1);+ crate::i18n::set_language(crate::i18n::Locale::En);+ assert_eq!(rows.first().map(|r| r.summary.as_str()), Some(headline.as_str()), "{rows:?}");+}+ #[test] fn the_print_menu_item_needs_a_photo_and_is_listed() { let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default());@@ -76,6 +76,8 @@ fn recheck_result_is_dropped_after_switching_libraries() { assert!(crate::tasks::wait(&mut app, &ctx, Duration::from_secs(10))); assert!(app.ui.toast.is_none(), "{:?}", app.ui.toast); assert!(app.session.catalog.photo(id).is_none(), "the other library is untouched");+ // the user did not stop it: no row asks for attention+ assert!(app.session.activity.finished().iter().all(|r| !r.ending.needs_attention()), "{:?}", app.session.activity.finished()); } /// The notice's Reload from Disk re-reads the file in the background (a row in the activity stack,@@ -241,6 +243,27 @@ fn reload_with_bad_ids_names_the_command() { assert!(!background.contains("``") && !background.starts_with("photo.reload:"), "{background}"); } +/// A Reload that could not read a file says so in its row: it did not do everything it was asked to.+#[test]+fn a_reload_that_could_not_read_a_file_leaves_a_row_naming_it() {+ let dir = Dir(std::env::temp_dir().join(format!("lc-ui-reload-unread-{}", std::process::id())));+ let (mut app, id) = app_with_stale_photo(&dir.0);+ let ctx = egui::Context::default();+ app.session.activity.keep_after(0);+ app.session.media.file_probe = Some(std::sync::Arc::new(|path| Err(format!("{path}: could not be read"))));+ let path = match &app.session.catalog.photo(id).unwrap().source {+ lightcraft_catalog::Source::File { path } => path.clone(),+ other => panic!("not a file: {other:?}"),+ };+ app.run("photo.reload", json!({"ids": [id.0]})).unwrap();+ assert!(crate::tasks::wait(&mut app, &ctx, Duration::from_secs(10)));+ let finished = app.session.activity.finished();+ assert_eq!(finished.len(), 1, "{finished:?}");+ let row = &finished[0];+ assert_eq!((row.kind, row.ending, row.failed), ("reload", lightcraft_engine::activity::Ending::Problems, 1), "{row:?}");+ assert_eq!((row.failures[0].name.as_str(), row.failures[0].reason.as_str()), (path.as_str(), format!("{path}: could not be read").as_str()));+}+ /// A Reload stopped from the activity stack before its file was read says nothing (the raw wasn't /// tried again); one stopped after the file was read still says what came of it. #[test]@@ -250,7 +273,9 @@ fn a_cancelled_reload_reports_only_what_it_did() { let (mut app, id) = app_with_stale_photo(&dir.0); let ctx = egui::Context::default(); let native = app.session.media.file_probe.clone().unwrap();+ app.session.activity.keep_after(0); for read in [false, true] {+ app.session.activity.dismiss_all(); // (the probe has started, the probe may return) let gate = Arc::new((Mutex::new((false, false)), Condvar::new())); let worker_gate = gate.clone();@@ -273,6 +298,12 @@ fn a_cancelled_reload_reports_only_what_it_did() { gate.0.lock().unwrap().1 = true; gate.1.notify_all(); assert!(crate::tasks::wait(&mut app, &ctx, Duration::from_secs(10)));+ // however it went, the row says it was stopped: ✕ was pressed+ let finished = app.session.activity.finished();+ assert!(+ finished.len() == 1 && finished[0].kind == "reload" && finished[0].ending == lightcraft_engine::activity::Ending::Stopped,+ "{finished:?}"+ ); if read { assert_eq!(app.session.catalog.photo(id).unwrap().preview_only, None); assert!(app.ui.toast.as_ref().is_some_and(|(t, _, _)| t == "This raw is now developed from its raw data"), "{:?}", app.ui.toast);@@ -339,6 +339,39 @@ fn a_synchronize_shows_a_row_and_its_cross_stops_it() { let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); assert!(toast.contains("Stopped"), "{toast:?}"); assert!(h.app.session.activity.list().is_empty(), "the row goes with the run");+ let row = h.app.session.activity.finished().into_iter().next().expect("a stopped run leaves a row");+ assert_eq!((row.label.as_str(), row.ending), ("Synchronizing folder", lightcraft_engine::activity::Ending::Stopped));+ assert!(row.summary.contains("trip: Stopped"), "it names the folder and says how it ended: {:?}", row.summary);+}++#[test]+fn a_synchronize_that_ran_to_its_end_leaves_a_row_saying_what_it_did() {+ let dir = Scratch::new("donerow");+ let mut h = changed_folder(&dir);+ h.app.session.activity.keep_after(0);+ open_and_scan(&mut h, &dir.path("trip"));+ synchronize(&mut h);+ let row = h.app.session.activity.finished().into_iter().next().expect("a row for the run");+ assert_eq!((row.label.as_str(), row.ending), ("Synchronizing folder", lightcraft_engine::activity::Ending::Done));+ assert!(row.summary.ends_with("trip: Imported 1"), "it names the folder and says what it did: {:?}", row.summary);+}++#[test]+fn a_scan_that_found_unreadable_files_leaves_a_row_that_needs_looking_at() {+ let dir = Scratch::new("unreadablerow");+ let mut h = changed_folder(&dir);+ h.app.session.activity.keep_after(0);+ // the new file can't be read: the scan still completes, with it listed as unreadable+ h.app.session.media.file_probe = Some(std::sync::Arc::new(|_: &str| Err("cannot read".to_string())));+ open_and_scan(&mut h, &dir.path("trip"));+ assert!(counts(&h).unreadable > 0, "the scan found an unreadable file: {:?}", h.app.ui.dialog);+ let row = h.app.session.activity.finished().into_iter().next().expect("a row for the scan");+ assert_eq!((row.label.as_str(), row.ending), ("Looking for changes", lightcraft_engine::activity::Ending::Problems));+ assert_eq!(row.failed, counts(&h).unreadable, "it counts the files it could not read");+ assert_eq!(row.failures.len(), row.failed, "and names each: {:?}", row.failures);+ assert_eq!(row.failures[0].name, "c.png", "by where it is below the scanned folder: {:?}", row.failures);+ assert_eq!(row.failures[0].reason, "cannot read");+ assert!(row.detail.contains("not readable"), "{:?}", row.detail); } #[test]@@ -363,6 +396,8 @@ fn the_folders_scan_shows_a_row_and_its_cross_closes_the_dialog() { h.step(); assert!(h.app.sync.is_none(), "✕ stops the scan"); assert!(h.app.ui.dialog.is_none(), "and closes its dialog: there is nothing to show");+ let row = h.app.session.activity.finished().into_iter().next().expect("a stopped scan leaves a row");+ assert_eq!((row.label.as_str(), row.ending), ("Looking for changes", lightcraft_engine::activity::Ending::Stopped)); gate.store(true, std::sync::atomic::Ordering::Relaxed); assert!(h.app.session.activity.list().is_empty()); h.settle(SETTLE);@@ -4,7 +4,8 @@ Everything that takes a while runs off the UI thread and shows up in **one** pla top-left corner under the top bar (issue #345). Each task has a row with its name, a progress bar (a sweep while the amount of work isn't known), the count and what it's working on, and ✕ if it can stop. Quitting asks first while a task that can stop is running. Agents see the same list through `activity.list` / `activity.cancel`, and-`ui.inspect` reports it as `activity`.+`ui.inspect` reports it as `activity`; the tasks that ended and left a row are `activity.finished` /+`activity.dismiss` and `activityFinished`. The registry is `lightcraft_engine::activity` (`crates/engine/src/activity.rs`, `Session::activity`). The stack is `crates/ui-egui/src/panels/activity.rs`. Every job that can last more than a moment uses it; **don't add a progress@@ -48,6 +49,20 @@ guard.detail(&file_name); - `set_cancellable(false)` for a phase that has to finish once started (a Lightroom import adding its photos). - **Quiet periodic work** shows no row: the auto-import folder listing runs every few seconds through `tasks::spawn(.., None, ..)`.+- **Say how it ended:** `guard.finish(Outcome { ending, summary, detail, failures, failed, path })` in place of+ letting the guard drop. These jobs do: exports (contact sheet and Print to PDF from the app included), imports,+ folder scans, Synchronize Folder, Build / Smart Previews, Photo Merge, Lightroom catalog inspect / import, model and+ place-name downloads, Fill Location from GPS, moving photos, Find Missing Photos and Reload from Disk. These do not+ yet: slideshow export, the engine's `print.exportPdf`, reading focus points, thumbnail-cache tidying, the face GPU+ check, Apple Photos imports, and the face scan and AI Denoise (queues whose row goes when they empty). `ending` is `Done`, `Problems` (not for every file),+ `Failed`, `Stopped`, or `Died` when the job finds its worker gone; `summary` is the sentence the row shows (UI code+ passes it translated); `failures` are the files with their reasons (`failed` when the job only counts them); `path`+ is one of the files it made, or the folder they are in or were to go to. `Outcome::ended(ending)` and `Outcome::failed(why)` cover the plain cases. The row+ stays in `activity.finished` until `activity.dismiss` lets go of it, for this+ session: always for a task that did not end well, and for one that ended well only if it ran 10 s or more. Keep the+ toast that says how it went: the row is the record, not the announcement. A guard that is only dropped leaves+ nothing, as before, unless a panic dropped it: then the row says the task stopped unexpectedly. A scan or Local folder read+ that another replaced leaves nothing either: only the row's ✕ makes it a stopped task. ## Examples in the code @@ -68,4 +83,6 @@ guard.detail(&file_name); Assert the row (`session.activity.list()`: kind, label, total, `cancellable`), that `activity.cancel(id)` stops the job, and that the row is gone once the job ends. `crates/ui-egui/src/tests/activity.rs` has the helpers, and the job-specific tests show the pattern (`tests_sync.rs`, `export_task.rs`, `tests_denoise.rs`…). A row only appears-on screen once its task is 0.5 s old, so tests that click its ✕ wait for `activity:cancel:<id>` first.+on screen once its task is 0.5 s old, so tests that click its ✕ wait for `activity:cancel:<id>` first. For a job+that reports how it ended, assert `session.activity.finished()` (ending, summary, failures);+`session.activity.keep_after(0)` keeps a clean end's row however short the test's job.@@ -79,7 +79,7 @@ filmstrip is visible. | `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled | | `ui.menu.list` | — | Menu entries (flat: id, label, menu path, shortcut, enabled) | | `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus |-| `ui.inspect` | — | UI state, window, `loupe` (`source`, `pending`, `failed` = why its render failed, else `null`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches — `budgetBytes`, `trimmed`, `sharedSourceBytes` (the one device copy of a big original) — and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: tasks that would be cut short are running — `button:quitAnyway` cancels them and quits, `button:quitCancel` keeps working — or unsaved changes: `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `activity` (every long-running task in flight as the activity stack in the top-left corner shows it, same shape as `activity.list`; a row appears once its task is 0.5 s old, ids `activity:row:<id>`, ✕ = `activity:cancel:<id>`, `activity:more` expands past 3 rows), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes), `copied` (the text the UI last put on the clipboard, `null` until something is copied) |+| `ui.inspect` | — | UI state, window, `loupe` (`source`, `pending`, `failed` = why its render failed, else `null`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches — `budgetBytes`, `trimmed`, `sharedSourceBytes` (the one device copy of a big original) — and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: tasks that would be cut short are running — `button:quitAnyway` cancels them and quits, `button:quitCancel` keeps working — or unsaved changes: `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `activity` (every long-running task in flight as the activity stack in the top-left corner shows it, same shape as `activity.list`; a row appears once its task is 0.5 s old, ids `activity:row:<id>`, ✕ = `activity:cancel:<id>`, `activity:more` expands past 3 rows), `activityFinished` (the tasks that ended and left a row, same shape as `activity.finished`), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes), `copied` (the text the UI last put on the clipboard, `null` until something is copied) | | `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) | | `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, button?, fx?, fy?}` (`button: "right"` right-clicks; `count` is capped at 16) / `{id, toX?, toY?, dx?, dy?, steps?}` (steps are capped at 1000) / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews). `fx`/`fy` are fractions of the registered rect and may lie outside `[0,1]`; the resulting point must still be inside egui's clipped interaction rect, and a non-finite fraction or resolved point is rejected before any input is queued. Geometry comes from the latest completed layout; if the widget moves before the first event or before a press, or its latest clipped rect no longer contains the point, the request fails with an error instead of clicking another widget or retargeting. A target below an ordinary modal dialog may succeed as a no-op or a backdrop click, because egui routes that input to the dialog; a target covered by a non-modal overlay fails. The blocking library-problem/launch-list window is explicit: its dim takes the modal layer, but a background target fails instead of reporting a no-op success, and only that window's own controls (`button:library…`) pass. Its creation frame is shielded and redrawn with empty input like a prompt's, so background text/pointer input cannot act while the new modal areas settle. A quit prompt or settings notice is a real egui modal scope with a foreground backdrop. Only its own buttons (`button:quit…`, `button:noticeOk`) accept a `ui.clickWidget`; background targets fail, and background keystrokes, text/clipboard input, wheel/zoom input and AccessKit actions are dropped before panels see them. The prompt itself takes Tab/Shift+Tab, Enter/Space on the focused button, and Escape/Enter for the safe default Cancel; AccessKit Focus/Click works when targeted at a prompt button. Its first creation frame (including a Tasks→Unsaved or notice→next-notice transition) is shielded and redrawn with empty input, so a press/release in that frame, or a held press from before it, cannot act underneath it; a synthetic drag already in progress is canceled. Once settled, raw `ui.click`/`ui.drag`/`ui.pointer` and backdrop clicks are still delivered to the prompt backdrop as no-ops, and the prompt's own button clicks work normally. The area/layer check cannot see a different widget covering the point in the same layer. A `ui.clickWidget` whose widget disappears before its release also fails, after canceling the pointer press without a click or drag (the same rules apply when a click spans several host frames) | | `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points (the same click and step caps apply) |@@ -98,6 +98,8 @@ filmstrip is visible. | `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background`; `destinationMode: "sameAsOriginal"` writes each photo into its original's folder (+ `subfolder`) instead of `dir` | Writes the files and returns `{files}` (one `{path, width, height, bytes, sidecars}` per file, plus `gpuFallback` — why that render fell back from the GPU to the CPU — when it did; see `docs/gpu-pipeline.md`); with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` | | `engine.execute {command: "activity.list"}` | — | The long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first: `{tasks: [{id, kind, label, done, total, unit, detail, cancellable, cancelling, ageMs}]}`; `total` 0 = not known yet; `unit` is `count`, `bytes` or `percent`; `label` is English (the app shows it translated) | | `engine.execute {command: "activity.cancel"}` | `{id}` or `{all: true}` | Asks the task (or every cancellable one) to stop at its next file or photo → `{cancelled: n}`; an unknown id (it may have just finished) or a task that can't be cancelled is an error |+| `engine.execute {command: "activity.finished"}` | — | The tasks that ended and left a row, newest first: `{finished: [{id, kind, label, ending, summary, detail, failures: [{name, reason}], failed, path, ranMs, ageMs}]}`. `ending` is `done`, `problems` (not for every file), `failed`, `stopped` (cancelled) or `died` (its worker crashed). A clean end is kept when the task ran 10 s or more, any other always; `failed` counts the files that failed and `failures` holds the first 500 with their reasons; `path` is one of the files the task made, or the folder they are in or were to go to; at most 50 clean endings and 200 rows in all are kept; `summary` is as the app shows it (in its language) and empty for jobs that say nothing of themselves. These jobs report how they ended: exports (the contact sheet and Print to PDF from the app included), imports, folder scans, Synchronize Folder, Build / Smart Previews, Photo Merge, Lightroom catalog inspect / import, model and place-name downloads, Fill Location from GPS, moving photos, Find Missing Photos and Reload from Disk. The others leave no row, or only one for a worker that died: slideshow export, the engine's `print.exportPdf`, reading focus points, thumbnail-cache tidying, the face GPU check, Apple Photos imports, the face scan and AI Denoise |+| `engine.execute {command: "activity.dismiss"}` | `{id}` or `{all: true}` | Lets go of a finished task's row (or of all of them) → `{dismissed: n}`; an unknown id (it may have been dismissed already) is an error | | `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` | | `engine.execute {command: "slideshow.layout"}` | `{margins?, marginLeft?, …, zoomToFill?, strokeBorder?, castShadow?, backdropColor?, colorWash?, backgroundImage?, …}` (see `engine.commands`) | The slideshow's slide layout (Layout, Options, Backdrop), saved with the library; omitted keys keep their value, `{}` reads it; out-of-range numbers are clamped, a wrong type or unknown key is refused and nothing changes | | `engine.execute {command: "slideshow.overlays"}` | `{identityPlate?: {enabled?, text?, image?, size?, color?, opacity?, anchor?, offsetX?, offsetY?, relativeTo?, behind?}, ratingStars?: {enabled?, color?, size?, anchor?, …}, texts?: [{text?, size?, color?, opacity?, anchor?, offsetX?, offsetY?, relativeTo?, shadow?, shadowOpacity?, shadowOffset?, shadowRadius?, shadowAngle?}], addText?, removeText?, showWatermark?, watermark?}` (see `engine.commands`) | The overlays drawn on every slide, saved with the library: sizes and offsets are fractions of the slide's shorter side, anchors `topLeft`…`bottomRight` of the `slide` or the `photo`; text overlays expand tokens (`{caption}`, `{exposure}`, `{date}`, the file-naming tokens…; unknown tags show as typed); `watermark` takes the export watermark's keys. Replies with the settings and `warnings` |@@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (LR-BEHAV-BG-TASKS: a background task says how it ended (`activity.finished`); LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good@@ -461,7 +461,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-BATCH | Batch apply to selection | P0 | ✅ | `cmd:photo.rate`, `cmd:develop.paste`, `cmd:preset.apply`, `cmd:app.export` | | | LR-BEHAV-PREVIEW-HOVER | Hover previews | P1 | ✅ | `crates/ui-egui/src/panels/presets.rs`, `crates/ui-egui/src/panels/profiles.rs`, `crates/ui-egui/src/panels/right.rs` (`versions`), `crates/ui-egui/src/panels/detail.rs` | presets, profile menu, profile browser and versions preview in the loupe | | LR-BEHAV-PROGRESSIVE | Progressive rendering | P0 | ✅ | `crates/engine/src/media.rs`, `crates/preview/src/lib.rs` | |-| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `crates/ui-egui/src/widgets/progress.rs`, `docs/background-tasks.md`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports (and the contact sheet PDF), Synchronize Folder (its scan and its run), Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise, Find Missing Photos and Reload from Disk (`cmd:photo.reload`, its files decoded on a worker thread, cancellable; `wait: true` for agents) each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`) see the same list. No pause (Lightroom can pause some tasks) |+| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `cmd:activity.finished`, `cmd:activity.dismiss`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `crates/ui-egui/src/widgets/progress.rs`, `docs/background-tasks.md`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports (and the contact sheet PDF), Synchronize Folder (its scan and its run), Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise, Find Missing Photos and Reload from Disk (`cmd:photo.reload`, its files decoded on a worker thread, cancellable; `wait: true` for agents) each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`, `activityFinished`) see the same list; exports (and the contact sheet and the app's Print to PDF), imports, folder scans, Synchronize Folder, Build / Smart Previews, Photo Merge, Lightroom catalog inspect / import, model and place-name downloads, Fill Location from GPS, moving photos, Find Missing Photos and Reload from Disk say how they ended and leave a finished row for agents when they ran long or did not go well (`activity.finished`, `activity.dismiss`); slideshow export, `print.exportPdf`, focus points, cache tidying, the face GPU check, Apple Photos imports, the face scan and AI Denoise do not yet. No pause (Lightroom can pause some tasks) | | LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline | | LR-BEHAV-WEB-SAFETY | Library safety in the browser build | P1 | 🟡 | `cmd:file.backupLibrary`, `cmd:file.restoreLibrary`, `apps/lightcraft-web/src/safety.rs`, `apps/lightcraft-web/src/backup.rs`, `apps/lightcraft-web/src/files.rs` | web only (experimental): Back Up Library (zip of the catalog and every stored original) and a non-destructive Restore; failed saves show as unsaved and are retried; a photo that can't be stored isn't added; one tab per library (Web Locks); notices for storage that isn't persistent and for a library that can't open; `?reset` asks first; a panic shows a message. Missing: `?safe` start, deleting originals of removed photos, zip64 (backups > 4 GB) | | LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md`, `crates/ui-egui/src/panels/notices.rs` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`; each render reports its own reason — `lightcraft_gpu::try_render`, `gpuFallback` per exported file — issue #675); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); a GPU lost while LightCraft runs (driver reset) stops it with that message and turns GPU rendering off for the next start, which says why (issue #1337; the window's renderer can't survive the lost device); quitting waits (up to 2 s) for renders in flight and starts no new GPU work, so none is inside the driver when the process exits (`crates/gpu/src/exit.rs`, issue #620; seen crashing only on Linux + NVIDIA, other drivers unverified); with GPU rendering on and no adapter found at all (e.g. no Vulkan loader on NixOS outside `nix develop`), the desktop app says once per session that it renders on the CPU and points to Help ▸ System Info (`gpu_notice`; not when the GPU is turned off or only a software adapter exists); no tiling beyond the buffer limit (CPU) |@@ -959,3 +959,5 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) | | 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone | | 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) |+| 2026-10-26 | minor | LR-BEHAV-BG-TASKS: a task says how it ended (`cmd:activity.finished`, `cmd:activity.dismiss`); exports and imports (each file that failed, with the reason), folder scans, Build / Smart Previews, model and place-name downloads, Fill Location from GPS and moving photos that ran long, were stopped or did not go well leave a finished row for agents; status unchanged (✅) |+| 2026-10-26 | minor | LR-BEHAV-BG-TASKS: Synchronize Folder (its scan and its run), Photo Merge, Lightroom catalogs, Find Missing Photos, the preview-only recheck and Reload from Disk say how they ended too (`cmd:activity.finished`), naming the files they could not do; every other task of the app is finished through `tasks::spawn`'s result; status unchanged (✅) |