Browse / github.com/storytold/lightcraft / patch
LR-BEHAV-BG-TASKS: exports and imports say how they ended (stacked on #1711)
LR-BEHAV-BG-TASKS: exports and imports say how they ended (stacked on #1711) Stacked on #1711 (part 1 of 3 of one change, which was a single PR until 26 Oct): waits for #1711, then adds the exports and imports. Own commit: `3fedd07ce1` only. Part 3, #3151, is stacked on this. This part was ported across #3103's new module layout. ## Problem An export or import of an hour leaves nothing to come back to once its toast is gone: you cannot tell whether it finished, failed or was stopped, or which files failed and why. A folder scan that could not read some files, an import whose preparation worker died part-way, and a contact sheet or Print to PDF that failed look the sa
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- nledford (imported from pull request #3150, open)
- Base
662df66f1c08bdb1b1f1d0c1c638c4cef936d0b0- Size
- +1380 −132 in 54 files
- Review
- pending
- Shared
- 2026-10-28
- Id
3e752bcf27a74eaae51caa02
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/scan.rs crates/ui-egui/src/dialogs/import/task.rs crates/ui-egui/src/export_task.rs crates/ui-egui/src/tests/activity.rs crates/ui-egui/src/tests/print.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,7 +104,7 @@ 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 if let Err(e) = tasks::spawn(self, LABEL, None, work, done) {@@ -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),@@ -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:?}");+ }+}@@ -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);+ } }@@ -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); } @@ -534,3 +536,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());@@ -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,21 @@ 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 and+ their folder scans, Build / Smart Previews, model and place-name downloads, Fill Location from GPS and moving+ photos. The others do not yet and leave a row only when a panic drops them: Synchronize Folder, Photo Merge,+ Lightroom catalogs, Find Missing Photos and Reload from Disk (the app's jobs that run through `tasks::spawn`, or+ their own thread), 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 +84,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 and their folder scans, Build / Smart Previews, model and place-name downloads, Fill Location from GPS and moving photos. The others leave no row yet, or only one for a worker that died: Synchronize Folder, Photo Merge, Lightroom catalogs, Find Missing Photos, Reload from Disk, 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 and their folder scans, Build / Smart Previews, model and place-name downloads, Fill Location from GPS and moving photos say how they ended and leave a finished row for agents when they ran long or did not go well (`activity.finished`, `activity.dismiss`); the other jobs 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,4 @@ 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 (✅) |