Issue #345: Lightroom catalog import in the activity stack

Inspecting and importing a Lightroom catalog show a row instead of the
progress window; reading the catalog stops from its cross, adding the
photos and saving the index run to the end.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Andrey VyrodovandClaude Opus 5.5 committed 2026-10-10 08:49:52 +02:00
1 parent 9c8a721389
commit c3bb8e9916
11 files changed
+140 -58

No files matched your search

+3 -1
View File
@@ -2024,5 +2024,7 @@
"Scanning folder": "Ordner wird durchsucht",
"Building previews": "Vorschauen werden erstellt",
"Building smart previews": "Smart-Vorschauen werden erstellt",
"Discarding smart previews": "Smart-Vorschauen werden verworfen"
"Discarding smart previews": "Smart-Vorschauen werden verworfen",
"Importing Lightroom catalog": "Lightroom-Katalog wird importiert",
"Reading Lightroom catalog": "Lightroom-Katalog wird gelesen"
}
+3 -1
View File
@@ -1869,5 +1869,7 @@
"Scanning folder": "Explorando carpeta",
"Building previews": "Generando vistas previas",
"Building smart previews": "Generando vistas previas inteligentes",
"Discarding smart previews": "Descartando vistas previas inteligentes"
"Discarding smart previews": "Descartando vistas previas inteligentes",
"Importing Lightroom catalog": "Importando catálogo de Lightroom",
"Reading Lightroom catalog": "Leyendo catálogo de Lightroom"
}
+6 -1
View File
@@ -1866,5 +1866,10 @@
"Quit while tasks are running?": "Quitter pendant que des tâches sont en cours ?",
"Exporting contact sheet": "Exportation de la planche-contact",
"Reading folder": "Lecture du dossier",
"Scanning folder": "Analyse du dossier"
"Scanning folder": "Analyse du dossier",
"Building previews": "Génération des aperçus",
"Building smart previews": "Génération des aperçus dynamiques",
"Discarding smart previews": "Suppression des aperçus dynamiques",
"Importing Lightroom catalog": "Importation du catalogue Lightroom",
"Reading Lightroom catalog": "Lecture du catalogue Lightroom"
}
+3 -1
View File
@@ -1871,5 +1871,7 @@
"Scanning folder": "フォルダーをスキャン中",
"Building previews": "プレビューを作成中",
"Building smart previews": "スマートプレビューを作成中",
"Discarding smart previews": "スマートプレビューを破棄中"
"Discarding smart previews": "スマートプレビューを破棄中",
"Importing Lightroom catalog": "Lightroom カタログを読み込み中",
"Reading Lightroom catalog": "Lightroom カタログを読み取り中"
}
+3 -1
View File
@@ -1869,5 +1869,7 @@
"Scanning folder": "Verificando pasta",
"Building previews": "Gerando prévias",
"Building smart previews": "Gerando prévias inteligentes",
"Discarding smart previews": "Descartando prévias inteligentes"
"Discarding smart previews": "Descartando prévias inteligentes",
"Importing Lightroom catalog": "Importando catálogo do Lightroom",
"Reading Lightroom catalog": "Lendo catálogo do Lightroom"
}
+3 -1
View File
@@ -1582,5 +1582,7 @@
"Scanning folder": "Сканирование папки",
"Building previews": "Создание просмотров",
"Building smart previews": "Создание смарт-просмотров",
"Discarding smart previews": "Удаление смарт-просмотров"
"Discarding smart previews": "Удаление смарт-просмотров",
"Importing Lightroom catalog": "Импорт каталога Lightroom",
"Reading Lightroom catalog": "Чтение каталога Lightroom"
}
+6 -1
View File
@@ -2060,5 +2060,10 @@
"Quit while tasks are running?": "Вийти, поки виконуються завдання?",
"Exporting contact sheet": "Експорт аркуша мініатюр",
"Reading folder": "Читання папки",
"Scanning folder": "Сканування папки"
"Scanning folder": "Сканування папки",
"Building previews": "Створення попередніх переглядів",
"Building smart previews": "Створення смарт-переглядів",
"Discarding smart previews": "Видалення смарт-переглядів",
"Importing Lightroom catalog": "Імпорт каталогу Lightroom",
"Reading Lightroom catalog": "Читання каталогу Lightroom"
}
+3 -1
View File
@@ -1869,5 +1869,7 @@
"Scanning folder": "正在扫描文件夹",
"Building previews": "正在构建预览",
"Building smart previews": "正在构建智能预览",
"Discarding smart previews": "正在丢弃智能预览"
"Discarding smart previews": "正在丢弃智能预览",
"Importing Lightroom catalog": "正在导入 Lightroom 目录",
"Reading Lightroom catalog": "正在读取 Lightroom 目录"
}
+3 -1
View File
@@ -1869,5 +1869,7 @@
"Scanning folder": "正在掃描檔案夾",
"Building previews": "正在建立預覽",
"Building smart previews": "正在建立智慧型預覽",
"Discarding smart previews": "正在捨棄智慧型預覽"
"Discarding smart previews": "正在捨棄智慧型預覽",
"Importing Lightroom catalog": "正在匯入 Lightroom 目錄",
"Reading Lightroom catalog": "正在讀取 Lightroom 目錄"
}
-1
View File
@@ -1066,7 +1066,6 @@ impl LightcraftApp {
panels::dialogs::show(self, &ctx);
panels::library_problem::show(self, &ctx);
sync::progress_window(self, &ctx);
lightroom_import::progress(self, &ctx);
export_task::poll(self, &ctx);
panels::activity::show(self, &ctx);
pick::poll(self, &ctx);
+107 -48
View File
@@ -8,12 +8,10 @@ use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, mpsc};
use egui::Align2;
use lightcraft_engine::activity::{Cancel, TaskGuard};
use serde_json::{Value, json};
use crate::LightcraftApp;
use crate::theme::Tokens;
use crate::widgets::register;
#[derive(Clone, Copy)]
enum Kind {
@@ -34,7 +32,7 @@ enum Phase {
Finalizing,
}
/// A Lightroom task in flight. The receiver is polled from the egui owner thread.
/// A Lightroom task in flight. The receiver is polled from the egui owner thread; the activity stack shows it.
pub struct LightroomTask {
kind: Kind,
path: PathBuf,
@@ -44,9 +42,53 @@ pub struct LightroomTask {
rx: mpsc::Receiver<Message>,
phase: Phase,
cancelled: bool,
guard: TaskGuard,
}
impl LightroomTask {
#[cfg(not(target_arch = "wasm32"))]
fn new(
s: &lightcraft_engine::Session,
kind: Kind,
path: PathBuf,
cancel: Arc<AtomicBool>,
total: Arc<AtomicUsize>,
done: Arc<AtomicUsize>,
rx: mpsc::Receiver<Message>,
) -> Self {
let label = match kind {
Kind::Inspect => "Reading Lightroom catalog",
Kind::Import => "Importing Lightroom catalog",
};
let guard = s.activity.start("lightroom", label, Cancel::Flag(cancel.clone()));
let task = Self { kind, path, cancel, total, done, rx, phase: Phase::Reading, cancelled: false, guard };
task.refresh();
task
}
/// Move to `phase`; only reading the catalog can stop, adding the photos runs to the end.
fn enter(&mut self, phase: Phase) {
self.phase = phase;
self.guard.set_cancellable(phase == Phase::Reading);
self.refresh();
}
/// Bring the activity row up to date: the count while reading, then what is being done.
fn refresh(&self) {
let detail = match (self.phase, self.kind) {
(Phase::Reading, Kind::Inspect) => "",
(Phase::Reading, Kind::Import) => crate::i18n::tr("Reading Lightroom catalog…"),
(Phase::Committing, _) => crate::i18n::tr("Adding Lightroom photos…"),
(Phase::Finalizing, _) => crate::i18n::tr("Saving Lightroom import index…"),
};
if self.phase == Phase::Reading {
self.guard.progress(self.done.load(Ordering::Relaxed) as u64, self.total.load(Ordering::Relaxed) as u64);
} else {
self.guard.progress(0, 0);
}
self.guard.detail(detail);
}
#[cfg(not(target_arch = "wasm32"))]
fn status(&self) -> Value {
let kind = match self.kind {
@@ -236,7 +278,7 @@ fn start_inspect(app: &mut LightcraftApp, path: PathBuf, ctx: &egui::Context) ->
let (tx, rx) = mpsc::channel();
spawn_inspect(path.clone(), cancel.clone(), total.clone(), done.clone(), tx, ctx.clone())?;
app.lightroom_last = None;
app.lightroom = Some(LightroomTask { kind: Kind::Inspect, path: path.clone(), cancel, total, done, rx, phase: Phase::Reading, cancelled: false });
app.lightroom = Some(LightroomTask::new(&app.session, Kind::Inspect, path.clone(), cancel, total, done, rx));
Ok(json!({"running": true, "kind": "inspect", "path": path}))
}
@@ -250,7 +292,7 @@ fn start_import(app: &mut LightcraftApp, path: PathBuf, update_existing: bool, c
let (tx, rx) = mpsc::channel();
spawn_prepare(job, cancel.clone(), tx, ctx.clone())?;
app.lightroom_last = None;
app.lightroom = Some(LightroomTask { kind: Kind::Import, path: path.clone(), cancel, total, done, rx, phase: Phase::Reading, cancelled: false });
app.lightroom = Some(LightroomTask::new(&app.session, Kind::Import, path.clone(), cancel, total, done, rx));
Ok(json!({"running": true, "kind": "import", "path": path}))
}
@@ -296,6 +338,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
let message = match task.rx.try_recv() {
Ok(m) => m,
Err(mpsc::TryRecvError::Empty) => {
task.refresh();
app.lightroom = Some(task);
return;
}
@@ -337,7 +380,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
);
return;
}
task.phase = Phase::Committing;
task.enter(Phase::Committing);
let mut finalization = None;
let mut report = None;
let committed = app.session.execute_fn("library.importLightroom", |s| {
@@ -347,7 +390,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
Ok(completion.report)
});
let report = report.unwrap_or(Value::Null);
task.phase = Phase::Finalizing;
task.enter(Phase::Finalizing);
let (tx, rx) = mpsc::channel();
let commit_error = committed.as_ref().err().map(ToString::to_string);
let Some(finalization) = finalization else {
@@ -409,50 +452,66 @@ pub fn wait_for(_app: &mut LightcraftApp, _ctx: &egui::Context, _timeout: std::t
unsupported_wasm()
}
/// The progress window for inspect/import, with cancellation.
pub fn progress(app: &mut LightcraftApp, ctx: &egui::Context) {
let Some(task) = app.lightroom.as_ref() else { return };
let total = task.total.load(Ordering::Relaxed);
let done = task.done.load(Ordering::Relaxed);
let title = match task.kind {
Kind::Inspect => crate::i18n::tr("Inspecting Lightroom Catalog"),
Kind::Import => crate::i18n::tr("Importing Lightroom Catalog"),
};
let text = match task.phase {
Phase::Reading if total == 0 => crate::i18n::tr("Reading Lightroom catalog…").into(),
Phase::Reading => crate::i18n::tr_format!("Reading Lightroom catalog… {done} of {total}", done = done, total = total),
Phase::Committing => crate::i18n::tr("Adding Lightroom photos…").into(),
Phase::Finalizing => crate::i18n::tr("Saving Lightroom import index…").into(),
};
let mut cancel = false;
let t = Tokens::get(ctx);
egui::Window::new(title).title_bar(false).resizable(false).anchor(Align2::CENTER_BOTTOM, [0.0, -80.0]).fixed_size([360.0, 92.0]).show(
ctx,
|ui| {
ui.label(egui::RichText::new(text).color(t.text));
ui.add(egui::ProgressBar::new(done as f32 / total.max(1) as f32).desired_width(340.0));
let r =
ui.add_enabled(task.phase == Phase::Reading && !task.cancel.load(Ordering::Relaxed), egui::Button::new(crate::i18n::tr("Cancel")));
register(ui.ctx(), "button:lightroomCancel", r.rect);
cancel = r.clicked();
},
);
if cancel {
if let Some(task) = app.lightroom.as_mut() {
task.cancel();
}
ctx.request_repaint();
}
if app.lightroom.is_some() {
ctx.request_repaint_after(std::time::Duration::from_millis(100));
}
}
#[cfg(test)]
mod tests {
use super::inspect_report;
use super::*;
use lightcraft_engine::lightroom_catalog::CatalogImport;
/// A task as `start_import` makes it, fed by a channel the test holds instead of a worker.
fn fake_import(h: &crate::headless::Headless) -> (LightroomTask, mpsc::Sender<Message>, Arc<AtomicBool>, Arc<AtomicUsize>, Arc<AtomicUsize>) {
let (tx, rx) = mpsc::channel();
let (cancel, total, done) = (Arc::new(AtomicBool::new(false)), Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)));
let task = LightroomTask::new(&h.app.session, Kind::Import, "fixture.lrcat".into(), cancel.clone(), total.clone(), done.clone(), rx);
(task, tx, cancel, total, done)
}
fn demo() -> crate::headless::Headless {
let app = crate::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.step();
h
}
#[test]
fn lightroom_row_is_not_cancellable_while_committing() {
let mut h = demo();
let (task, tx, cancel, total, done) = fake_import(&h);
h.app.lightroom = Some(task);
total.store(10, Ordering::Relaxed);
done.store(3, Ordering::Relaxed);
h.step();
let rows = h.app.session.activity.list();
assert_eq!(rows.len(), 1);
assert_eq!((rows[0].kind, rows[0].label.as_str()), ("lightroom", "Importing Lightroom catalog"));
assert_eq!((rows[0].done, rows[0].total), (3, 10));
assert!(rows[0].cancellable, "reading the catalog can stop");
h.app.lightroom.as_mut().unwrap().enter(Phase::Committing);
let row = h.app.session.activity.list().remove(0);
assert!(!row.cancellable, "once photos are being added it runs to the end");
assert!(h.app.session.activity.cancel(row.id).is_err());
assert!(!cancel.load(Ordering::Relaxed));
tx.send(Message::Finalized(Ok(()))).unwrap();
h.step();
assert!(h.app.lightroom.is_none());
assert!(h.app.session.activity.list().is_empty(), "the row goes with the task");
}
#[test]
fn lightroom_cancel_from_the_stack_stops_the_read() {
let mut h = demo();
let (task, tx, cancel, _, _) = fake_import(&h);
h.app.lightroom = Some(task);
h.step();
let id = h.app.session.activity.list()[0].id;
h.app.session.activity.cancel(id).unwrap();
assert!(cancel.load(Ordering::Relaxed), "the worker sees the stack's ✕");
tx.send(Message::Prepared(Err("cancelled".into()))).unwrap();
h.step();
assert!(h.app.lightroom.is_none());
assert_eq!(h.app.lightroom_last, Some(serde_json::json!({"cancelled": true})));
assert!(h.app.session.activity.list().is_empty());
}
#[test]
fn inspect_report_keeps_empty_catalog_shape() {
let report = inspect_report(CatalogImport {