From 20c26b83c849c9eb57bcb7895ecea2cb503ce3ce Mon Sep 17 00:00:00 2001 From: aiongg Date: Mon, 26 Oct 2026 21:13:11 +0000 Subject: [PATCH] refactor(lightcraft-ui-egui): split dialogs/import.rs into dialogs/import/ --- crates/ui-egui/src/dialogs/import.rs | 1474 +-------------------- crates/ui-egui/src/dialogs/import/body.rs | 558 ++++++++ crates/ui-egui/src/dialogs/import/scan.rs | 272 ++++ crates/ui-egui/src/dialogs/import/tags.rs | 95 ++ crates/ui-egui/src/dialogs/import/task.rs | 544 ++++++++ 5 files changed, 1486 insertions(+), 1457 deletions(-) create mode 100644 crates/ui-egui/src/dialogs/import/body.rs create mode 100644 crates/ui-egui/src/dialogs/import/scan.rs create mode 100644 crates/ui-egui/src/dialogs/import/tags.rs create mode 100644 crates/ui-egui/src/dialogs/import/task.rs diff --git a/crates/ui-egui/src/dialogs/import.rs b/crates/ui-egui/src/dialogs/import.rs index 74dcc6c40..8425c3ec9 100644 --- a/crates/ui-egui/src/dialogs/import.rs +++ b/crates/ui-egui/src/dialogs/import.rs @@ -21,35 +21,24 @@ use crate::render::Slot; use crate::theme::Tokens; use crate::widgets::register; -/// Files per batch in the browser build (each batch joins the catalog as it is ready, so the -/// progress window updates); the desktop's worker uses [`lightcraft_engine::import::batch_size`]. -#[cfg(target_arch = "wasm32")] -const BATCH: usize = 8; +mod body; +mod scan; +mod tags; +mod task; -/// The review dialog's first size (points). It can be resized; the photo grid takes the height. -pub(crate) const DIALOG_SIZE: [f32; 2] = [960.0, 720.0]; -/// Memory keys: where the photo grid ended and where the dialog's content (its buttons) ended, last -/// frame. Their difference is the room the grid leaves below it. -const GRID_BOTTOM: &str = "import-grid-bottom"; -const CONTENT_BOTTOM: &str = "import-content-bottom"; -/// Room below the grid before it has been measured (more than the options and buttons take). -const DEFAULT_BELOW_GRID: f32 = 360.0; - -/// Height of the photo grid: what is `available` once `below` (options and buttons) is left free, -/// in whole points so the window never grows by a fraction each frame, and at least one `row`. -pub(crate) fn grid_height(available: f32, below: f32, row: f32) -> f32 { - let h = (available - below.max(0.0)).floor(); - if h.is_finite() { h.max(row) } else { (DIALOG_SIZE[1] - DEFAULT_BELOW_GRID).max(row) } -} - -/// Remember where the review dialog's content ends this frame (after its buttons), for -/// [`grid_height`] on the next. -pub(crate) fn note_dialog_bottom(ui: &egui::Ui) { - if !ui.is_sizing_pass() { - let y = ui.min_rect().bottom(); - ui.ctx().data_mut(|m| m.insert_temp(egui::Id::unique(CONTENT_BOTTOM), y)); - } -} +#[cfg(test)] +pub(crate) use body::grid_height; +#[cfg(test)] +use body::{DEFAULT_BELOW_GRID, badge_rect, dup_counts}; +pub use body::{DEFAULT_FOLDER_TEMPLATE, body, example_destination, source_summary}; +pub(crate) use body::{DIALOG_SIZE, note_dialog_bottom}; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) use scan::cards_seen; +pub use scan::{ScanTask, browse, open, poll_scan, running}; +#[cfg(test)] +pub(crate) use scan::{stall_scan, stall_scan_of}; +pub(crate) use tags::{tag_field_width, tag_help, tag_toggle, unknown_tags_warning}; +pub use task::{ImportTask, start, start_paths, tick}; #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -237,1435 +226,6 @@ impl ImportDialog { } } -/// 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 -/// UI thread between frames, so a slow drive never stalls the window. -#[derive(Debug, Default)] -pub struct ImportTask { - /// Files and folders to import (handed to the worker when the task starts). - queue: Vec, - pub total: usize, - pub done: usize, - params: Value, - pub imported: usize, - /// Brought back from Recently Deleted (`onDeleted: restore`). - pub restored: usize, - pub duplicates: usize, - /// The library photos the skipped duplicates match (shown when nothing new was added). - existing: Vec, - pub failed: usize, - /// Move: originals moved, and sources left in place (reported by the engine with a reason). - pub moved: usize, - pub kept: usize, - /// Structured diagnostics from foreign XMP develop settings. - pub warnings: Vec, - undo0: usize, - first: Option, - /// Reading a folder for the Local view: the photos stay out of the library, and nothing is - /// selected or announced as added. - browse: bool, - /// Cancel was pressed: no further files are started; batches already readied are added. - pub cancelled: 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. - keep_selection: Option, - /// The selection and source as the import last left them: a change between frames is the - /// user choosing another photo or view, which the end of the import doesn't take away. - left: Option<(lightcraft_engine::Selection, lightcraft_engine::LibrarySource)>, - /// The user chose another photo or view while the import ran. - moved_on: bool, - run: Option, - /// The import's row in the activity stack (its ✕ and `activity.cancel` set the run's cancel flag). - guard: Option, -} - -impl ImportTask { - /// An import of `paths` (files or folders) with `library.import` params (without `paths`). - pub fn new(paths: Vec, params: Value, undo0: usize, browse: bool) -> Self { - ImportTask { total: paths.len(), queue: paths, params, undo0, browse, ..Default::default() } - } - - /// An Auto Import (see [`ImportTask::auto`]). - pub fn auto(mut self) -> Self { - self.auto = true; - self - } - - /// `{done, total, imported, cancelled}` for `ui.inspect`. - pub fn status(&self) -> Value { - json!({"done": self.done, "total": self.total, "imported": self.imported, "cancelled": self.cancelled}) - } -} - -/// 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 { - #[cfg(not(target_arch = "wasm32"))] - Thread(std::sync::mpsc::Receiver), - #[cfg(target_arch = "wasm32")] - Inline { job: Box, queue: Vec, files: Option> }, -} - -struct ImportRun { - runner: Runner, - cancel: std::sync::Arc, - total: std::sync::Arc, - done: std::sync::Arc, - opts: lightcraft_engine::import::ImportOptions, - now: String, - album: Option, - album_name: Option, -} - -impl std::fmt::Debug for ImportRun { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ImportRun").field("cancel", &self.cancel.load(Ordering::Relaxed)).finish() - } -} - -impl Drop for ImportRun { - /// A dropped import (replaced, or the app closing) stops its worker at the next file. - fn drop(&mut self) { - self.cancel.store(true, Ordering::Relaxed); - } -} - -impl ImportRun { - fn start( - job: lightcraft_engine::import::ImportJob, - queue: Vec, - album: Option, - album_name: Option, - ctx: &egui::Context, - ) -> Result { - let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let (total, done) = (job.total.clone(), job.done.clone()); - let (opts, now) = (job.opts.clone(), job.now().to_string()); - let runner = Self::spawn(job, queue, cancel.clone(), ctx)?; - Ok(ImportRun { runner, cancel, total, done, opts, now, album, album_name }) - } - - #[cfg(not(target_arch = "wasm32"))] - fn spawn( - mut job: lightcraft_engine::import::ImportJob, - queue: Vec, - cancel: std::sync::Arc, - ctx: &egui::Context, - ) -> Result { - let (tx, rx) = std::sync::mpsc::channel(); - 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(); - }; - std::thread::Builder::new().name("lc-import".into()).spawn(work).map(|_| Runner::Thread(rx)).map_err(|e| format!("could not start: {e}")) - } - - #[cfg(target_arch = "wasm32")] - fn spawn( - job: lightcraft_engine::import::ImportJob, - queue: Vec, - _cancel: std::sync::Arc, - _ctx: &egui::Context, - ) -> Result { - Ok(Runner::Inline { job: Box::new(job), queue, files: None }) - } - - /// Readied batches (at most a few per frame). `None` once the worker is done. - fn next_batches(&mut self) -> Option> { - match &mut self.runner { - #[cfg(not(target_arch = "wasm32"))] - Runner::Thread(rx) => { - let mut out = Vec::new(); - while out.len() < 4 { - match rx.try_recv() { - Ok(p) => out.push(p), - Err(std::sync::mpsc::TryRecvError::Empty) => return Some(out), - Err(std::sync::mpsc::TryRecvError::Disconnected) => return if out.is_empty() { None } else { Some(out) }, - } - } - Some(out) - } - #[cfg(target_arch = "wasm32")] - Runner::Inline { job, queue, files } => { - if self.cancel.load(Ordering::Relaxed) { - return None; - } - if files.is_none() { - *files = Some(job.expand(queue).into()); - return Some(Vec::new()); - } - let Some(files) = files.as_mut() else { return None }; - if files.is_empty() { - return None; - } - let n = files.len().min(BATCH); - let batch: Vec = files.drain(..n).collect(); - Some(vec![job.prepare_files(batch, &self.cancel)]) - } - } - } -} - -/// A folder scan running on a worker thread (a network share can take minutes to read; the -/// window must keep answering meanwhile). -pub struct ScanTask { - progress: std::sync::Arc, - rx: std::sync::mpsc::Receiver, - /// Open the review with "copy into the library" checked (a camera / card). - pub copy: bool, - /// Open the review with this album chosen (an album's context menu ▸ Import…). - pub album: Option, - /// Browsing a folder (Local): the photos are read in place instead of opening the review. - browse: bool, - /// What is being scanned (the review's source). - sources: Vec, - /// The scan's row in the activity stack (its ✕ and `activity.cancel` set `progress.cancel`). - guard: lightcraft_engine::activity::TaskGuard, -} - -/// The activity row of a scan: the review's ("Scanning folder") or the Local view's ("Reading folder"). -fn scan_guard(app: &LightcraftApp, progress: &ScanProgress, browse: bool) -> lightcraft_engine::activity::TaskGuard { - let label = if browse { "Reading folder" } else { "Scanning folder" }; - app.session.activity.start("scan", label, lightcraft_engine::activity::Cancel::Flag(progress.cancel.clone())) -} - -/// An import and a Synchronize Folder never run at once: each readies its files against the -/// library as it was when it started, so both could add the same file. -pub(crate) fn busy_synchronizing(app: &LightcraftApp) -> Result<(), String> { - if app.sync_run.is_some() { Err(crate::i18n::tr("A folder is being synchronized").to_string()) } else { Ok(()) } -} - -/// Settings → Import → "Show the import review when a camera or card is connected", called with -/// each device scan while it's on. The first look only notes the cards already in. A card that -/// shows up after that opens Import from Device once nothing else is open, and a card that is -/// taken out and put back in asks again. -#[cfg(not(target_arch = "wasm32"))] -pub(crate) fn cards_seen(app: &mut LightcraftApp, now: Vec) { - let Some(known) = app.ui.known_cards.as_mut() else { - app.ui.known_cards = Some(now.into_iter().map(|d| d.root).collect()); - return; - }; - known.retain(|root| now.iter().any(|d| d.root == *root)); - let Some(card) = now.into_iter().find(|d| !known.contains(&d.root)) else { return }; - // busy: ask when the window is free (the card isn't noted until then) - if app.import.is_some() || app.scan.is_some() || app.sync_run.is_some() || app.lightroom.is_some() || app.ui.dialog.is_some() { - return; - } - known.push(card.root); - if let Err(e) = app.run("file.addFromDevice", json!({"path": card.path})) { - log::warn!("import from {}: {e}", card.name); - } -} - -/// Scan `paths` in the background, then open the review dialog (see [`poll_scan`]). -pub fn open(app: &mut LightcraftApp, paths: Vec) -> Result { - if app.scan.is_some() { - return Err("a scan is already running".into()); - } - if app.review_pending.is_some() { - return Err("an import review is waiting for the open dialog to close".into()); - } - let sources = paths.clone(); - let (input, paths) = ScanInput::new(&mut app.session, &paths); - let progress = std::sync::Arc::new(ScanProgress::default()); - let (tx, rx) = std::sync::mpsc::channel(); - let p = progress.clone(); - let job = move || { - let _ = tx.send(scan_with(input, &paths, &p)); - }; - #[cfg(not(target_arch = "wasm32"))] - std::thread::spawn(job); - #[cfg(target_arch = "wasm32")] - job(); - let guard = scan_guard(app, &progress, false); - app.scan = Some(ScanTask { progress, rx, copy: false, album: None, browse: false, sources, guard }); - Ok(json!({"scanning": true})) -} - -/// Show a folder's photos in the Local view (`library.browse` from the UI): the view switches at -/// once, the folder is listed and read on a worker thread (a network share can take minutes), -/// and the photos then join the view in small batches under a progress window. A browse already -/// running is replaced; the import review's scan is not. -pub fn browse(app: &mut LightcraftApp, path: &str, subfolders: Option) -> Result { - let dir = std::path::absolute(std::path::Path::new(path)).map_err(|e| e.to_string())?; - if !dir.is_dir() { - return Err(format!("{path}: not a folder")); - } - if app.scan.as_ref().is_some_and(|t| !t.browse) || app.import.as_ref().is_some_and(|t| !t.browse) { - return Err("an import is running".into()); - } - let dir_s = dir.to_string_lossy().trim_end_matches(['/', '\\']).to_string(); - let subfolders = subfolders.unwrap_or_else(|| app.session.browse.as_ref().is_some_and(|b| b.subfolders)); - let running = app.scan.as_ref().is_some_and(|t| t.browse) || app.import.as_ref().is_some_and(|t| t.browse); - if running && app.session.browse.as_ref().is_some_and(|b| b.path == dir_s && b.subfolders == subfolders) { - // already reading this folder: clicking it again must not restart the progress - app.session.source = lightcraft_engine::LibrarySource::Folder; - app.session.keep_active_in_view(); - return Ok(json!({"path": dir_s, "subfolders": subfolders, "scanning": true})); - } - if let Some(t) = app.scan.take() { - t.progress.cancel.store(true, Ordering::Relaxed); - } - app.import = None; - app.session.browse = Some(lightcraft_engine::Browse { path: dir_s.clone(), subfolders }); - app.session.source = lightcraft_engine::LibrarySource::Folder; - // the photos the library already knows from this folder show at once: keep or pick the active one - app.session.keep_active_in_view(); - let (input, _) = ScanInput::new(&mut app.session, std::slice::from_ref(&dir_s)); - let progress = std::sync::Arc::new(ScanProgress::default()); - let (tx, rx) = std::sync::mpsc::channel(); - let p = progress.clone(); - let root = dir_s.clone(); - let job = move || { - let files: Vec = if subfolders { - lightcraft_engine::import::expand(&[root], None) - } else { - let mut v: Vec = std::fs::read_dir(&root) - .map(|rd| { - rd.flatten() - .map(|e| e.path()) - .filter(|f| { - f.is_file() - && lightcraft_engine::import::is_supported(f) - && !f.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) - }) - .map(|f| f.to_string_lossy().to_string()) - .collect() - }) - .unwrap_or_default(); - v.sort(); - v - }; - let _ = tx.send(scan_with(input, &files, &p)); - }; - #[cfg(not(target_arch = "wasm32"))] - std::thread::spawn(job); - #[cfg(target_arch = "wasm32")] - job(); - let guard = scan_guard(app, &progress, true); - app.scan = Some(ScanTask { progress, rx, copy: false, album: None, browse: true, sources: Vec::new(), guard }); - app.renderer.forget_imports(); - Ok(json!({"path": dir_s, "subfolders": subfolders, "scanning": true})) -} - -/// Collect a finished scan and open the review (called every frame). -pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { - show_pending_review(app, ctx); - let Some(task) = app.scan.as_ref() else { return }; - ctx.request_repaint_after(std::time::Duration::from_millis(100)); - let out = match task.rx.try_recv() { - Ok(o) => o, - 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; - return; - } - let (total, done) = (task.progress.total.load(Ordering::Relaxed), task.progress.done.load(Ordering::Relaxed)); - task.guard.progress(done as u64, total as u64); - // while the folders are listed the total isn't known: say what is happening instead - task.guard.detail(if total == 0 { crate::i18n::tr("Looking for photos…") } else { "" }); - return; - } - Err(_) => { - app.scan = None; - app.toast(ctx, crate::i18n::tr("Scan failed")); - return; - } - }; - let Some(task) = app.scan.take() else { return }; - if task.progress.cancel.load(Ordering::Relaxed) { - return; - } - 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 - // their untouched Local photos aren't forgotten while the folder is in use - if let Some(root) = app.session.browse.as_ref().map(|b| b.path.clone()) { - let shown: Vec = out.candidates.iter().map(|c| c.path.clone()).collect(); - app.session.note_browsed(&root, &shown); - } - // records of files gone from the folder (deleted or moved by another app) that nothing was changed on go, - // as `library.browse` forgets them (issue #1392) - if let Some(b) = app.session.browse.clone() { - let listed: Vec = out.candidates.iter().map(|c| c.path.clone()).collect(); - app.session.forget_gone_from(&b.path, b.subfolders, &listed); - } - // what the library doesn't know yet joins the Local view - let queue: Vec = - out.candidates.iter().filter(|c| c.duplicate != Some("path".into()) && c.error.is_none()).map(|c| c.path.clone()).collect(); - if !queue.is_empty() { - 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)); - } - return; - } - if out.candidates.is_empty() { - app.toast(ctx, crate::i18n::tr("No photos found")); - return; - } - app.renderer.forget_imports(); - let mut d = ImportDialog::new(out.candidates); - let trashed = d - .candidates - .iter() - .map(|c| { - c.duplicate.is_some() - && c.existing.is_some_and(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted)) - }) - .collect(); - d.set_trashed(trashed); - d.copy = task.copy; - // only an album the review offers (it may have been deleted while the scan ran) - d.album = task.album.filter(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(*id)).is_some_and(|a| !a.folder && !a.is_smart())); - d.sources = task.sources; - d.library = Some(app.session.library_generation()); - app.review_pending = Some(crate::state::Dialog::Import { opts: Box::new(d) }); - show_pending_review(app, ctx); -} - -/// Open the review of a finished scan once no other dialog is open (a face model's licence, Settings… -/// opened while the scan ran stays), checked again shortly while it waits. A review whose library was -/// closed meanwhile (Settings can open another) is dropped. -fn show_pending_review(app: &mut LightcraftApp, ctx: &egui::Context) { - if app.review_pending.is_none() { - return; - } - if let Some(crate::state::Dialog::Import { opts }) = &app.review_pending - && !opts.in_library(app) - { - app.review_pending = None; - return; - } - if app.ui.dialog.is_none() { - app.ui.dialog = app.review_pending.take(); - } else { - ctx.request_repaint_after(std::time::Duration::from_millis(100)); - } -} - -/// Tests: a review scan that never ends while the returned value lives, so a drop meets it running. -#[cfg(test)] -pub(crate) fn stall_scan(app: &mut LightcraftApp) -> Box { - stall_scan_of(app, false) -} - -/// Tests: the same, for a Local folder browse scan (`browse`) instead of the review's scan. -#[cfg(test)] -pub(crate) fn stall_scan_of(app: &mut LightcraftApp, browse: bool) -> Box { - let progress = std::sync::Arc::new(ScanProgress::default()); - let (tx, rx) = std::sync::mpsc::channel::(); - let guard = scan_guard(app, &progress, false); - app.scan = Some(ScanTask { progress, rx, copy: false, album: None, browse, sources: Vec::new(), guard }); - Box::new(tx) -} - -/// Whether an import or any scan (the review's, or a Local folder browse) is running, or a finished scan's -/// review waits for a dialog to close, which a drop of photos must not replace: `open` refuses while a scan -/// runs, and a browse that finishes replaces `app.import`. -pub fn running(app: &LightcraftApp) -> bool { - app.import.is_some() || app.scan.is_some() || app.review_pending.is_some() -} - -impl ScanTask { - /// `{done, total}` for `ui.inspect` (total is 0 while the folders are still being listed). - pub fn status(&self) -> Value { - json!({"done": self.progress.done.load(Ordering::Relaxed), "total": self.progress.total.load(Ordering::Relaxed)}) - } -} - -/// Start importing the dialog's checked files (the dialog's OK / `ui.dialog.confirm`). Refused while another -/// import runs: replacing `app.import` would stop its worker. -pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result { - busy_synchronizing(app)?; - if !d.in_library(app) { - return Err("another library was opened since the photos were scanned: nothing was imported".into()); - } - // a browse scan that finishes starts its own import, replacing this one - if app.import.is_some() || app.scan.as_ref().is_some_and(|t| t.browse) { - return Err(crate::i18n::tr("An import is running").to_string()); - } - let queue = d.selected_paths(); - if queue.is_empty() { - return Err("no photos selected".into()); - } - let undo0 = app.session.undo.len(); - let mut album = d.album; - if album.is_none() && !d.new_album.trim().is_empty() { - let r = app.session.execute("album.create", &json!({"name": d.new_album.trim()})).map_err(|e| e.to_string())?; - album = r["id"].as_u64(); - } - let mode = match (d.copy, d.move_files) { - (false, _) => "add", - (true, false) => "copy", - (true, true) => "move", - }; - let mut params = json!({"mode": mode, "keywords": d.keywords()}); - if !d.on_deleted.is_empty() { - params["onDeleted"] = json!(d.on_deleted); - } - if let Some(a) = album { - params["album"] = json!(a); - } - if !d.preset.is_empty() { - params["preset"] = json!(d.preset); - } - if !d.metadata_preset.is_empty() { - params["metadataPreset"] = json!(d.metadata_preset); - } - if d.copy { - if !d.destination.trim().is_empty() { - params["destination"] = json!(d.destination.trim()); - } - if let Some(o) = d.organize_param()? { - params["organize"] = json!(o); - } - if !d.rename.trim().is_empty() { - params["rename"] = json!(d.rename.trim()); - params["renameStart"] = json!(1); - } - if d.dng && !d.move_files { - params["dng"] = json!(true); - } - } - let total = queue.len(); - app.import = Some(ImportTask::new(queue, params, undo0, false)); - app.renderer.forget_imports(); - Ok(json!({"importing": total})) -} - -/// Start importing `paths` (files or folders) in the background, e.g. dropped on the window. -pub fn start_paths(app: &mut LightcraftApp, paths: Vec) -> Result { - busy_synchronizing(app)?; - if running(app) { - return Err(crate::i18n::tr("An import is running").to_string()); - } - let undo0 = app.session.undo.len(); - let total = paths.len(); - app.import = Some(ImportTask::new(paths, json!({"mode": "add"}), undo0, false)); - Ok(json!({"importing": total})) -} - -/// Advance the import in progress (called every frame): start its worker, then add the batches it -/// has readied to the catalog. -pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { - let Some(mut task) = app.import.take() else { return }; - if task.run.is_none() { - let mut p = task.params.clone(); - p["paths"] = json!(task.queue); - let started = lightcraft_engine::cmd::library::import_params(&app.session, &p) - .and_then(|req| Ok((lightcraft_engine::import::ImportJob::new(&mut app.session, req.opts)?, req.album, req.album_name))) - .map_err(|e| e.to_string()) - .and_then(|(job, album, name)| ImportRun::start(job, std::mem::take(&mut task.queue), album, name, ctx)); - if task.auto { - task.keep_selection = Some(app.session.selection.clone()); - } - match started { - Ok(run) => { - let label = if task.browse { "Reading folder" } else { "Importing" }; - task.guard = Some(app.session.activity.start("import", label, lightcraft_engine::activity::Cancel::Flag(run.cancel.clone()))); - task.run = Some(run); - } - Err(e) => { - log::warn!("import: {e}"); - app.toast(ctx, crate::i18n::tr_format!("Import failed: {e}", e = e)); - return; - } - } - } - ctx.request_repaint_after(std::time::Duration::from_millis(100)); - let Some(run) = task.run.as_mut() else { return }; - let batches = run.next_batches(); - let finished = batches.is_none(); - if task.left.as_ref().is_some_and(|(sel, source)| *sel != app.session.selection || *source != app.session.source) { - task.moved_on = true; - } - for prepared in batches.into_iter().flatten() { - commit_batch(app, &mut task, prepared); - // arriving photos don't take over the selection - if let Some(sel) = &task.keep_selection { - app.session.selection = sel.clone(); - } - // a Local folder still on screen: its first photo becomes active once there is one (#1447) - if task.browse && app.session.source == lightcraft_engine::LibrarySource::Folder { - app.session.keep_active_in_view(); - } - } - task.left = Some((app.session.selection.clone(), app.session.source)); - if let Some(run) = &task.run { - task.total = run.total.load(Ordering::Relaxed).max(task.total); - task.done = run.done.load(Ordering::Relaxed); - } - if let Some(guard) = &task.guard { - guard.progress(task.done as u64, task.total as u64); - // ✕ in the stack (or `activity.cancel`): no further files are started - if guard.is_cancelled() { - task.cancelled = true; - } - } - if !finished { - app.import = Some(task); - return; - } - finish(app, ctx, task); -} - -/// Add one readied batch to the catalog (an undo step merged into the import's at the end). -fn commit_batch(app: &mut LightcraftApp, task: &mut ImportTask, prepared: lightcraft_engine::import::Prepared) { - let Some(run) = task.run.as_ref() else { return }; - let n = prepared.len(); - let (opts, now, album, album_name) = (&run.opts, run.now.as_str(), run.album, run.album_name.clone()); - let r = app.session.execute_fn("library.import", |s| { - let report = lightcraft_engine::import::commit_prepared(s, opts, now, prepared)?; - lightcraft_engine::cmd::library::import_batch_done(s, report, album, album_name.as_deref()) - }); - match r { - Ok(v) => { - // a new album (named in the params) is created with the first photos; later batches join it - if let (Some(a), Some(run)) = (v["album"].as_u64(), task.run.as_mut()) { - run.album = Some(a); - } - let len = |k: &str| v[k].as_array().map_or(0, Vec::len); - task.imported += len("imported"); - task.restored += len("restored"); - 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"); - 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())); - for k in v["kept"].as_array().into_iter().flatten() { - log::warn!("import: kept {}: {}", k["path"].as_str().unwrap_or(""), k["reason"].as_str().unwrap_or("")); - } - if task.first.is_none() { - task.first = ["imported", "restored"].iter().find_map(|k| v[*k].as_array().and_then(|a| a.first()).and_then(Value::as_u64)); - } - } - Err(e) => { - log::warn!("import: {e}"); - task.failed += n; - } - } -} - -fn xmp_warning_text(warning: &ImportWarning) -> String { - let path = &warning.path; - match warning.code { - ImportWarningCode::CameraProfile => crate::i18n::tr_format!( - "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.", - profile = warning.profile.as_deref().unwrap_or_default(), - path = path, - ), - ImportWarningCode::UprightGeometry => match warning.mode.unwrap_or(ImportUprightMode::Unknown) { - ImportUprightMode::Off => crate::i18n::tr_format!( - "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.", - path = path, - ), - ImportUprightMode::Guided => crate::i18n::tr_format!( - "Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.", - path = path, - ), - ImportUprightMode::Auto => crate::i18n::tr_format!( - "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.", - path = path, - mode = crate::i18n::tr("Auto") - ), - ImportUprightMode::Level => crate::i18n::tr_format!( - "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.", - path = path, - mode = crate::i18n::tr("Level") - ), - ImportUprightMode::Vertical => crate::i18n::tr_format!( - "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.", - path = path, - mode = crate::i18n::tr("Vertical") - ), - ImportUprightMode::Full => crate::i18n::tr_format!( - "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.", - path = path, - mode = crate::i18n::tr("Full") - ), - ImportUprightMode::Unknown => { - crate::i18n::tr_format!("Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.", path = path,) - } - }, - ImportWarningCode::UnmappedXmp => { - crate::i18n::tr_format!("XMP adjustments were not imported for {path}: {fields}.", path = path, fields = warning.fields.join(", "),) - } - } -} - -fn xmp_warnings_text(warnings: &[ImportWarning]) -> String { - crate::i18n::tr_format!( - "Import warnings ({n}):\n{warnings}", - n = warnings.len(), - warnings = warnings.iter().map(xmp_warning_text).collect::>().join("\n"), - ) -} - -/// 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) { - 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 { - crate::i18n::tr_format!("Restore {} Photo{}", task.restored, plural(task.restored)) - } else if task.restored > 0 { - crate::i18n::tr_format!("Add {} Photo{}, restore {}", task.imported, plural(task.imported), task.restored) - } else { - crate::i18n::tr_format!("Add {} Photo{}", task.imported, if task.imported == 1 { "" } else { "s" }) - }; - app.session.merge_undo(steps, &label); - if task.auto { - if !task.warnings.is_empty() { - app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0); - } else if task.imported > 0 { - app.toast(ctx, crate::i18n::tr_format!("Auto Import: added {} photo{}", task.imported, if task.imported == 1 { "" } else { "s" })); - } - return; - } - if task.browse { - if !task.warnings.is_empty() { - app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0); - } else if task.failed > 0 { - app.toast(ctx, crate::i18n::tr_format!("{} photo{} not readable", task.failed, if task.failed == 1 { "" } else { "s" })); - } - return; - } - if let Some(f) = task.first.filter(|_| !task.moved_on) { - // a view the new photos aren't in (an album, Picks, a Local folder) would leave the active - // photo off screen: show All Photos, as Lightroom shows the import's own collection (#1446) - if !app.session.visible().contains(&lightcraft_catalog::PhotoId(f)) { - let _ = app.run("library.source", json!({"kind": "all"})); - } - let _ = app.run("library.select", json!({"ids": [f]})); - } - 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 - && task.restored == 0 - && task.failed == 0 - && task.warnings.is_empty() - && !task.cancelled - && show_existing(app, ctx, &task.existing) - { - return; - } - 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 { - crate::i18n::tr_format!("Restored {} photo{} from Recently Deleted", task.restored, plural(task.restored)) - } else { - crate::i18n::tr_format!("Imported {} photo{}", task.imported, plural(task.imported)) - }; - if task.restored > 0 && (task.imported > 0 || task.cancelled) { - msg.push_str(&crate::i18n::tr_format!(" · {} restored from Recently Deleted", task.restored)); - } - if task.params["mode"] == "move" { - msg.push_str(&crate::i18n::tr_format!(" · {} moved", task.moved)); - if task.kept > 0 { - msg.push_str(&crate::i18n::tr_format!(" · {} original{} left at the source", task.kept, plural(task.kept))); - } - } - if task.duplicates > 0 { - msg.push_str(&crate::i18n::tr_format!(" · {} duplicate{} skipped", task.duplicates, if task.duplicates == 1 { "" } else { "s" })); - let deleted = deleted_of(app, &task.existing).len(); - if deleted > 0 { - msg.push_str(&crate::i18n::tr_format!(" ({} in Recently Deleted)", deleted)); - } - } - if task.failed > 0 { - msg.push_str(&crate::i18n::tr_format!(" · {} not readable", task.failed)); - } - if task.warnings.is_empty() { - app.toast(ctx, msg); - } else { - msg.push_str("\n\n"); - msg.push_str(&xmp_warnings_text(&task.warnings)); - app.toast_for(ctx, msg, 8.0); - } -} - -/// The photos among `ids` that are in Recently Deleted (each once). -fn deleted_of(app: &LightcraftApp, ids: &[u64]) -> Vec { - let mut v: Vec = - ids.iter().copied().filter(|&id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted)).collect(); - v.sort_unstable(); - v.dedup(); - v -} - -/// Select the library photos an import skipped as duplicates of, in Recently Deleted (side panel -/// opened, with how to get them back) or in All Photos, and say so. `false` when there are none. -fn show_existing(app: &mut LightcraftApp, ctx: &egui::Context, existing: &[u64]) -> bool { - let deleted = deleted_of(app, existing); - let (kind, mut ids) = if deleted.is_empty() { ("all", existing.to_vec()) } else { ("recentlyDeleted", deleted) }; - ids.sort_unstable(); - ids.dedup(); - ids.retain(|&id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some()); - if ids.is_empty() || app.run("library.source", json!({"kind": kind})).is_err() { - return false; - } - let _ = app.run("library.select", json!({"ids": ids})); - app.ui.left_panel = true; - let n = ids.len(); - let plural = if n == 1 { "" } else { "s" }; - let msg = if kind == "all" { - crate::i18n::tr_format!("Already in the library: {} photo{} selected in All Photos", n, plural) - } else { - crate::i18n::tr_format!( - "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh", - n, - plural - ) - }; - app.toast_for(ctx, msg, 6.0); - true -} - -/// Skipped candidates: (already in the library, identical copies within the source, in Recently -/// Deleted). Files of photos in Recently Deleted are not "already in the library", and a file with -/// the same bytes as one earlier in the list (no library photo) is a copy in the source. -fn dup_counts(d: &ImportDialog) -> (usize, usize, usize) { - let trashed = d.trashed.iter().filter(|t| **t).count(); - let in_source = d.candidates.iter().filter(|c| c.duplicate.as_deref() == Some("content") && c.existing.is_none()).count(); - let dups = d.candidates.iter().filter(|c| c.duplicate.is_some()).count().saturating_sub(trashed).saturating_sub(in_source); - (dups, in_source, trashed) -} - -/// The dialog body: options, then the candidate grid. -pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { - let t = Tokens::get(ui.ctx()); - let n = d.candidates.len(); - let (dups, in_source, trashed) = dup_counts(d); - let sel = d.selected_paths().len(); - ui.horizontal(|ui| { - ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} found · {sel} selected", n = n, sel = sel)).color(t.text)); - if dups > 0 { - ui.label(egui::RichText::new(crate::i18n::tr_format!("· {dups} already in the library", dups = dups)).color(t.text_dim)); - } - if in_source > 0 { - ui.label(egui::RichText::new(crate::i18n::tr_format!("· {in_source} identical copies", in_source = in_source)).color(t.text_dim)) - .on_hover_text(crate::i18n::tr("Files with the same content as another file in this import: only the first is imported.")); - } - if trashed > 0 { - ui.label(egui::RichText::new(crate::i18n::tr_format!("· {trashed} in Recently Deleted", trashed = trashed)).color(t.text_dim)); - } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if crate::widgets::text_button(ui, "importNone", crate::i18n::tr("Uncheck All"), false).clicked() { - d.checked.iter_mut().for_each(|c| *c = false); - } - if crate::widgets::text_button(ui, "importAll", crate::i18n::tr("Check All"), false).clicked() { - for i in 0..n { - d.checked[i] = d.checked_by_default(i); - } - } - }); - }); - // with more than one file type: a toggle per type checks or unchecks all of its files - let types = d.file_types(); - if types.len() > 1 { - ui.horizontal_wrapped(|ui| { - ui.label(egui::RichText::new(crate::i18n::tr("File types:")).color(t.text_dim)); - for (ty, count) in &types { - let on = d.type_checked(ty); - let r = crate::widgets::text_button(ui, &format!("importType:{ty}"), &format!("{ty} · {count}"), on) - .on_hover_text(crate::i18n::tr(if on { "Uncheck the files of this type" } else { "Check the files of this type" })); - if r.clicked() { - d.check_type(ty, !on); - } - } - }); - } - // candidate grid - let cell = 116.0; - let avail = ui.available_width(); - let cols = ((avail + 6.0) / (cell + 6.0)).floor().max(1.0) as usize; - let rows = n.div_ceil(cols); - // as tall as the window leaves after the options and buttons below it (measured last frame), - // so resizing the dialog resizes the grid - let below = ui.ctx().data(|m| { - let y = |key: &str| m.get_temp::(egui::Id::unique(key)); - y(CONTENT_BOTTOM).zip(y(GRID_BOTTOM)).map(|(content, grid)| content - grid) - }); - let grid_h = grid_height(ui.available_height(), below.unwrap_or(DEFAULT_BELOW_GRID), cell + 26.0); - let grid = - egui::ScrollArea::vertical().id_salt("import-grid").max_height(grid_h).auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { - let (area, _) = ui.allocate_exact_size(vec2(avail, rows as f32 * (cell + 26.0)), Sense::hover()); - for i in 0..n { - let (c, r) = (i % cols, i / cols); - let local = Rect::from_min_size(pos2(c as f32 * (cell + 6.0), r as f32 * (cell + 26.0)), vec2(cell, cell + 20.0)); - if !local.intersects(viewport.expand(cell)) { - continue; - } - let rect = local.translate(area.min.to_vec2()); - candidate_cell(app, ui, d, i, rect); - } - }); - if !ui.is_sizing_pass() { - ui.ctx().data_mut(|m| m.insert_temp(egui::Id::unique(GRID_BOTTOM), grid.inner_rect.bottom())); - } - ui.add_space(4.0); - // options - if !d.sources.is_empty() { - field(ui, "Source", |ui| { - // (whether each source is a folder is checked off the UI thread, once) - let joined = d.sources.join("\n"); - let summary = crate::panels::left::fs_cached(ui, "import-source", &joined, f64::INFINITY, |s| { - inspect_sources(&s.split('\n').map(str::to_string).collect::>()) - }) - .map(|summary| summary.display()) - .unwrap_or_else(|| crate::i18n::tr_format!("{} item{}", d.sources.len(), if d.sources.len() == 1 { "" } else { "s" })); - let r = ui.label(egui::RichText::new(summary).color(t.text_label)).on_hover_text(joined); - register(ui, "label:importSource", r.rect); - }); - ui.label( - egui::RichText::new(crate::i18n::tr("Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).")) - .color(t.text_dim) - .small(), - ); - } - field(ui, "Transfer", |ui| { - ui.spacing_mut().item_spacing.x = 4.0; - if crate::widgets::text_button(ui, "importAdd", crate::i18n::tr("Add in place"), !d.copy) - .on_hover_text(crate::i18n::tr("Reference the files where they are")) - .clicked() - { - d.copy = false; - d.move_files = false; - } - let can_copy = app.session.library.as_ref().is_some_and(|l| l.on_disk) || app.services.pick_folder.is_some(); - let r = - ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importCopy", crate::i18n::tr("Copy"), d.copy && !d.move_files)).inner; - if r.on_hover_text(crate::i18n::tr("Copy the files (into the library's Originals/, or a folder you choose)")).clicked() { - d.copy = true; - d.move_files = false; - } - let r = - ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importMove", crate::i18n::tr("Move"), d.copy && d.move_files)).inner; - if r.on_hover_text(crate::i18n::tr("Move the files (into the library's Originals/, or a folder you choose), removing them from the source")) - .clicked() - { - d.copy = true; - d.move_files = true; - } - }); - let r = ui.label( - egui::RichText::new(crate::i18n::tr(match (d.copy, d.move_files) { - (true, false) => { - "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are." - } - (true, true) => { - "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are." - } - _ => "Add in place: the library references the files where they are; nothing is copied or moved.", - })) - .color(if d.copy && d.move_files { t.caution } else { t.text_dim }) - .small(), - ); - register(ui, "label:importModeHelp", r.rect); - if d.copy { - field(ui, if d.move_files { "Move to" } else { "Copy to" }, |ui| { - ui.spacing_mut().item_spacing.x = 4.0; - let shown = if d.destination.trim().is_empty() { crate::i18n::tr("Library Originals").to_string() } else { d.destination.clone() }; - ui.label(egui::RichText::new(shown).color(Tokens::get(ui.ctx()).text_label)); - if app.services.pick_folder.is_some() - && crate::widgets::text_button(ui, "importDest", crate::i18n::tr("Choose…"), false).clicked() - && let Some(f) = app.services.pick_folder.as_mut().and_then(|f| f()) - { - d.destination = f; - } - if !d.destination.is_empty() && crate::widgets::text_button(ui, "importDestReset", crate::i18n::tr("Library"), false).clicked() { - d.destination.clear(); - } - }); - field(ui, "Folders", |ui| { - let opts = [ - ("date", "By day (YYYY/YYYY-MM-DD)"), - ("month", "By month (YYYY/YYYY-MM)"), - ("flat", "Into one folder"), - ("custom", "Custom template…"), - ]; - let key = if d.organize.is_empty() { "date".to_string() } else { d.organize.clone() }; - let cur = opts.iter().find(|o| o.0 == key).map_or(opts[0].1, |o| o.1); - let combo = crate::widgets::combo_box("import-organize").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { - for (k, label) in opts { - let r = ui.selectable_label(key == k, crate::i18n::tr(label)); - register(ui, format!("button:importOrganize-{k}"), r.rect); - if r.clicked() { - d.organize = k.to_string(); - if k == "custom" && d.folder_template.trim().is_empty() { - d.folder_template = DEFAULT_FOLDER_TEMPLATE.into(); - } - } - } - }); - crate::widgets::name_combo(&combo.response, crate::i18n::tr("Organize into")); - register(ui, "combo:importOrganize", combo.response.rect); - }); - if d.organize == "custom" { - let folders_id = egui::Id::unique("import-folder-template"); - let tags_open = field(ui, "Template", |ui| { - ui.spacing_mut().item_spacing.x = 4.0; - let w = tag_field_width(ui); - let r = ui.add(egui::TextEdit::singleline(&mut d.folder_template).id(folders_id).hint_text(DEFAULT_FOLDER_TEMPLATE).desired_width(w)); - register(ui, "field:importFolderTemplate", r.rect); - tag_toggle(ui, "importFolders") - }); - if tags_open { - tag_help(ui, "importFolders", &mut d.folder_template, folders_id); - } - let t = Tokens::get(ui.ctx()); - match lightcraft_engine::rename::folder_template_error(&d.folder_template) { - Some(e) => { - ui.label(egui::RichText::new(e).color(t.caution)); - } - None => unknown_tags_warning(ui, &d.folder_template), - } - ui.label( - egui::RichText::new(crate::i18n::tr( - "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").", - )) - .color(t.text_dim) - .small(), - ); - } - if !d.move_files { - field(ui, "Raw files", |ui| { - let r = ui.checkbox(&mut d.dng, crate::i18n::tr("Copy as DNG")); - register(ui, "check:importDng", r.rect); - }); - } - let rename_id = egui::Id::unique("import-rename"); - let tags_open = field(ui, "Rename", |ui| { - ui.spacing_mut().item_spacing.x = 4.0; - let w = tag_field_width(ui); - let r = ui.add( - egui::TextEdit::singleline(&mut d.rename) - .id(rename_id) - .hint_text(crate::i18n::tr("keep names — or e.g. {date}_{seq:3}")) - .desired_width(w), - ); - register(ui, "field:importRename", r.rect); - tag_toggle(ui, "importRename") - }); - if tags_open { - tag_help(ui, "importRename", &mut d.rename, rename_id); - } - unknown_tags_warning(ui, &d.rename); - if let Some(example) = example_destination(app, d) { - let t = Tokens::get(ui.ctx()); - let r = ui.label(egui::RichText::new(crate::i18n::tr_format!("Example: {example}", example = example)).color(t.text_dim)); - register(ui, "label:importExample", r.rect); - ui.label( - egui::RichText::new(crate::i18n::tr("Folders and {date} use the capture time; a photo without one uses today's date.")) - .color(t.text_dim) - .small(), - ); - } - } - if d.trashed.iter().any(|t| *t) { - field(ui, "In Recently Deleted", |ui| { - let cur = match d.on_deleted.as_str() { - "restore" => "Restore them", - "fresh" => "Import as new", - _ => "Leave them", - }; - let mut pick = None; - let combo = crate::widgets::combo_box("import-on-deleted").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { - for (v, label) in [("restore", "Restore them"), ("fresh", "Import as new"), ("", "Leave them")] { - if ui.selectable_label(d.on_deleted == v, crate::i18n::tr(label)).clicked() { - pick = Some(v); - } - } - }); - crate::widgets::name_combo(&combo.response, crate::i18n::tr("Recently deleted files")); - if let Some(v) = pick { - d.set_on_deleted(v); - } - let t = Tokens::get(ui.ctx()); - ui.label( - egui::RichText::new(crate::i18n::tr("Restore keeps their edits; Import as new deletes the old record first.")) - .color(t.text_dim) - .small(), - ); - }); - } - let mut albums = crate::album_picker::entries_from(&app.session.catalog, |_| None); - albums.retain(|a| a.folder || !a.smart); - // Catalog changes while the review is open must not leave a hidden, unselectable destination. - d.album = d.album.filter(|id| albums.iter().any(|a| a.id == *id && !a.folder)); - field(ui, "Album", |ui| { - ui.vertical(|ui| { - let mut chosen = d.album; - let mut extra = None; - let empty = crate::i18n::tr(if d.new_album.is_empty() { "None" } else { "New album" }); - let response = crate::album_picker::AlbumPicker::new("import-album", &mut chosen, &albums) - .width(ui.available_width()) - .empty_text(empty) - .show_with_choices(ui, |ui| { - let none = ui.selectable_label(d.album.is_none() && d.new_album.is_empty(), crate::i18n::tr("None")); - register(ui, "importAlbum:none", none.rect); - if none.clicked() { - extra = Some(false); - } - let new = ui.selectable_label(d.album.is_none() && !d.new_album.is_empty(), crate::i18n::tr("New album…")); - register(ui, "importAlbum:new", new.rect); - if new.clicked() { - extra = Some(true); - } - ui.separator(); - extra.is_some() - }); - if response.changed() { - d.album = chosen; - d.new_album.clear(); - } - if let Some(new) = extra { - d.album = None; - if !new { - d.new_album.clear(); - } else if d.new_album.is_empty() { - d.new_album = crate::i18n::tr("Imported Photos").into(); - } - } - if d.album.is_none() && !d.new_album.is_empty() { - let r = ui.add(egui::TextEdit::singleline(&mut d.new_album).desired_width(f32::INFINITY)); - register(ui, "field:importAlbumName", r.rect); - } - }); - }); - field(ui, "Preset", |ui| { - let cur = app - .session - .presets - .iter() - .find(|p| p.id == d.preset) - .map(|p| crate::i18n::builtin_label(&p.name, p.builtin).to_string()) - .unwrap_or_else(|| crate::i18n::tr("None").into()); - let combo = crate::widgets::combo_box("import-preset").selected_text(cur).height(300.0).show_ui(ui, |ui| { - if ui.selectable_label(d.preset.is_empty(), crate::i18n::tr("None")).clicked() { - d.preset.clear(); - } - let selected = (!d.preset.is_empty()).then_some(d.preset.as_str()); - if let Some(pid) = crate::panels::presets::picker_rows(ui, "import-preset", &app.session.presets, selected) { - d.preset = pid; - } - }); - crate::widgets::name_combo(&combo.response, crate::i18n::tr("Preset")); - }); - if !app.session.metadata_presets.is_empty() { - field(ui, "Metadata", |ui| { - let cur = if d.metadata_preset.is_empty() { crate::i18n::tr("None").to_string() } else { d.metadata_preset.clone() }; - let combo = crate::widgets::combo_box("import-metadata").selected_text(cur).show_ui(ui, |ui| { - if ui.selectable_label(d.metadata_preset.is_empty(), crate::i18n::tr("None")).clicked() { - d.metadata_preset.clear(); - } - for m in &app.session.metadata_presets { - if ui.selectable_label(d.metadata_preset == m.name, &m.name).clicked() { - d.metadata_preset = m.name.clone(); - } - } - }); - crate::widgets::name_combo(&combo.response, crate::i18n::tr("Metadata preset")); - }); - } - field(ui, "Keywords", |ui| { - let r = ui.add(egui::TextEdit::singleline(&mut d.keywords).hint_text(crate::i18n::tr("comma, separated")).desired_width(f32::INFINITY)); - register(ui, "field:importKeywords", r.rect); - }); -} - -/// Draw one import-dialog candidate cell: register `import:{i}`, request the cell's thumbnail, -/// paint the selection checkbox, and label duplicates, unreadable or trashed files. A click on an -/// importable cell toggles it (Shift extends the range); `rect` is the full cell and the thumbnail -/// uses its width as height. -fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog, i: usize, rect: Rect) { - let t = Tokens::get(ui.ctx()); - let c = d.candidates[i].clone(); - let ok = d.importable(i); - let img = Rect::from_min_size(rect.min, vec2(rect.width(), rect.width())); - let resp = ui.interact(img, egui::Id::unique(("import-cell", i)), Sense::click()); - crate::widgets::announce(&resp, c.name.clone()); - register(ui, format!("import:{i}"), img); - let p = ui.painter(); - p.rect_filled(img, 3.0, t.skeleton); - // thumbnail (background job; the grid only asks for the cells in view) - let slot = Slot::Import(i as u32); - if !app.renderer.textures.contains_key(&slot) - && let Some(job) = app.session.candidate_thumb_job(&c, 192, i as u64) - { - app.renderer.request_quick(slot, job, 4); - } - if let Some(tex) = app.renderer.textures.get(&slot) { - let [tw, th] = tex.size; - let s = ((img.width() - 8.0) / tw as f32).min((img.height() - 8.0) / th as f32); - let fit = Rect::from_center_size(img.center(), vec2(tw as f32 * s, th as f32 * s)); - let tint = if ok { Color32::WHITE } else { Color32::from_gray(110) }; - p.image(tex.tex.id(), fit, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), tint); - } - let on = d.checked[i] && ok; - if on { - p.rect_stroke(img, 3.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); - } - // checkbox - let cb = Rect::from_min_size(img.min + vec2(6.0, 6.0), vec2(16.0, 16.0)); - p.rect(cb, 3.0, if on { t.accent } else { Color32::from_black_alpha(150) }, Stroke::new(1.0, Color32::from_gray(200)), StrokeKind::Inside); - if on { - p.line_segment([cb.left_center() + vec2(3.5, 0.5), cb.center_bottom() + vec2(-1.0, -4.0)], Stroke::new(2.0, Color32::WHITE)); - p.line_segment([cb.center_bottom() + vec2(-1.0, -4.0), cb.right_top() + vec2(-3.5, 4.0)], Stroke::new(2.0, Color32::WHITE)); - } - let in_trash = c.existing.is_some_and(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted)); - let badge = match (&c.duplicate, &c.error) { - // checked with "Leave them", the file goes without `onDeleted`: the engine restores it - (Some(_), _) if in_trash && on => Some(if d.on_deleted == "fresh" { "Will be replaced" } else { "Will be restored" }), - (Some(_), _) if in_trash => Some("In Recently Deleted"), - (Some(r), _) if r == "path" => Some("In library"), - (Some(_), _) => Some("Duplicate"), - (None, Some(_)) => Some("Unreadable"), - _ => None, - }; - if let Some(b) = badge { - let g = p.layout_no_wrap(crate::i18n::tr(b).to_string(), t.semibold(10.0), Color32::WHITE); - let br = badge_rect(img, cb, g.size()); - p.rect_filled(br, 3.0, Color32::from_rgba_unmultiplied(170, 60, 50, 220)); - p.galley(br.min + vec2(4.0, 2.0), g, Color32::WHITE); - } - let name = if c.name.chars().count() > 18 { format!("{}…", c.name.chars().take(17).collect::()) } else { c.name.clone() }; - p.text(pos2(rect.left() + 2.0, img.bottom() + 9.0), Align2::LEFT_CENTER, name, t.font(10.5), if ok { t.text_label } else { t.text_dim }); - let tip = format!( - "{}\n{} × {} · {} · {:.1} MB{}", - c.path, - c.width, - c.height, - c.format, - c.file_size as f64 / 1e6, - c.captured.as_deref().map(|d| format!("\n{}", d.replace('T', " "))).unwrap_or_default() - ); - let resp = resp.on_hover_text(tip); - if resp.clicked() && ok { - let shift = ui.input(|input| input.modifiers.shift); - d.click(i, shift); - } -} - -/// Where a cell's badge goes: top right, or under the checkbox when the text is too wide to sit -/// beside it — drawn over the checkbox it hid it (issue #1285). -fn badge_rect(img: Rect, cb: Rect, text: Vec2) -> Rect { - let size = text + vec2(8.0, 4.0); - let left = img.right() - size.x - 4.0; - let top = if left < cb.right() + 4.0 { cb.bottom() + 4.0 } else { img.top() + 6.0 }; - Rect::from_min_size(pos2(left, top), size) -} - -/// Reserve the actual translated tag button width beside a template field. -pub(crate) fn tag_field_width(ui: &egui::Ui) -> f32 { - let t = Tokens::get(ui.ctx()); - let galley = ui.painter().layout_no_wrap(crate::i18n::tr("Tags").to_string(), t.semibold(11.5), t.text); - (ui.available_width() - (galley.size().x + 18.0).max(37.0) - ui.spacing().item_spacing.x).max(80.0) -} - -/// The "Tags" toggle beside a template field (`button:Tags`); returns whether the tag help -/// ([`tag_help`]) is open. -pub(crate) fn tag_toggle(ui: &mut egui::Ui, key: &str) -> bool { - let id = egui::Id::unique((key, "tags-open")); - let mut open = ui.ctx().data(|d| d.get_temp::(id)).unwrap_or(false); - let r = crate::widgets::text_button(ui, &format!("{key}Tags"), crate::i18n::tr("Tags"), open) - .on_hover_text(crate::i18n::tr("Show the template tags; click one to insert it")); - if r.clicked() { - open = !open; - ui.ctx().data_mut(|d| d.insert_temp(id, open)); - } - open -} - -/// "Unknown tag {x} stays as typed" under a template field, when it has one. -pub(crate) fn unknown_tags_warning(ui: &mut egui::Ui, template: &str) { - let unknown = lightcraft_engine::rename::unknown_tokens(template); - if !unknown.is_empty() { - let s = if unknown.len() == 1 { "" } else { "s" }; - let text = crate::i18n::tr_format!( - "Unknown tag{s} {} stay{} as typed (see Tags)", - unknown.join(" "), - if unknown.len() == 1 { "s" } else { "" }, - s = s - ); - ui.label(egui::RichText::new(text).color(Tokens::get(ui.ctx()).caution)); - } -} - -/// Insert `tag` into `text` at the text cursor of the field `edit_id` (replacing its selection); -/// appended when the field never had a cursor. The cursor ends up after the tag. -pub(crate) fn insert_at_cursor(ctx: &egui::Context, edit_id: egui::Id, text: &mut String, tag: &str) { - use egui::text::{CCursor, CCursorRange}; - let mut state = egui::text_edit::TextEditState::load(ctx, edit_id).unwrap_or_default(); - let n = text.chars().count(); - let (a, b) = state.cursor.char_range().map_or((n, n), |r| { - let (x, y) = (r.primary.index.0.min(n), r.secondary.index.0.min(n)); - (x.min(y), x.max(y)) - }); - let byte = |c: usize| text.char_indices().nth(c).map_or(text.len(), |(i, _)| i); - let (ba, bb) = (byte(a), byte(b)); - text.replace_range(ba..bb, tag); - state.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(a + tag.chars().count())))); - state.store(ctx, edit_id); - ctx.memory_mut(|m| m.request_focus(edit_id)); -} - -/// The template tag help: every tag (from the engine's one list, `rename::TOKENS`) with its -/// meaning and an example, the `{date:…}` directives and how templates behave. Clicking a tag -/// (`button:Tag-`) inserts it at the cursor of the field `edit_id`. -pub(crate) fn tag_help(ui: &mut egui::Ui, key: &str, text: &mut String, edit_id: egui::Id) { - use lightcraft_engine::rename::{DATE_DIRECTIVES, TEMPLATE_NOTES, TOKENS, token_example}; - let t = Tokens::get(ui.ctx()); - egui::Frame::new().fill(t.inset).corner_radius(4.0).inner_margin(6.0).show(ui, |ui| { - ui.label( - egui::RichText::new(crate::i18n::tr("Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.")) - .color(t.text_dim) - .small(), - ); - egui::ScrollArea::vertical().id_salt((key, "tags")).max_height(170.0).show(ui, |ui| { - egui::Grid::new((key, "tag-grid")).num_columns(3).spacing([10.0, 2.0]).show(ui, |ui| { - for (i, tok) in TOKENS.iter().enumerate() { - let r = ui.add(egui::Button::new(egui::RichText::new(tok.tag).monospace().color(t.text)).small()).on_hover_text( - if tok.aliases.is_empty() { - crate::i18n::tr("Insert at the cursor").to_string() - } else { - crate::i18n::tr_format!("Insert at the cursor (also written {})", tok.aliases.join(", ")) - }, - ); - register(ui, format!("button:{key}Tag-{i}"), r.rect); - if r.clicked() { - insert_at_cursor(ui.ctx(), edit_id, text, tok.tag); - } - ui.label(egui::RichText::new(crate::i18n::tr(tok.meaning)).color(t.text_label)); - ui.label(egui::RichText::new(token_example(tok.tag)).monospace().color(t.text_dim)); - ui.end_row(); - } - }); - let directives: Vec = DATE_DIRECTIVES.iter().map(|(d, m)| format!("{d} {}", crate::i18n::tr(m))).collect(); - ui.label(egui::RichText::new(crate::i18n::tr_format!("{{date:…}} directives: {}", directives.join(" · "))).color(t.text_dim).small()); - for n in TEMPLATE_NOTES { - ui.label(egui::RichText::new(format!("• {}", crate::i18n::tr(n))).color(t.text_dim).small()); - } - }); - }); -} - -/// The folder template the Custom choice starts with (`2026/20260114/`). -pub const DEFAULT_FOLDER_TEMPLATE: &str = "{date:%Y}/{date:%Y%m%d}"; - -/// Where the first selected photo would be copied to (destination, folders, name), for the -/// dialog's example line; `None` without a photo or with an unusable folder template. -pub fn example_destination(app: &LightcraftApp, d: &ImportDialog) -> Option { - let c = d.candidates.iter().zip(&d.checked).enumerate().find(|(i, (_, on))| **on && d.importable(*i)).map(|(_, (c, _))| c)?; - let organize = match d.organize_param().ok()? { - Some(o) => lightcraft_engine::import::Organize::parse(&o)?, - None => Default::default(), - }; - let mut q = lightcraft_catalog::Photo::new( - lightcraft_catalog::PhotoId(0), - lightcraft_catalog::Source::File { path: c.path.clone() }, - &c.name, - &c.format, - 0, - 0, - // a photo without a capture time is dated by the import (now) - &(app.session.clock)(), - ); - q.captured = c.captured.clone(); - // the probe's metadata, so {camera}, {title}… preview as they will import - if let Some(info) = app.session.import_probes.get(&c.path) { - q.meta = info.meta.clone(); - } - let name = if d.rename.trim().is_empty() { c.name.clone() } else { lightcraft_engine::rename::expand(d.rename.trim(), &q, 1) }; - let root = if d.destination.trim().is_empty() { - app.session.library.as_ref().map_or_else(|| "Originals".to_string(), |l| l.dir.join("Originals").to_string_lossy().to_string()) - } else { - d.destination.trim().trim_end_matches(['/', '\\']).to_string() - }; - let sep = std::path::MAIN_SEPARATOR_STR; - let mut parts = vec![root]; - parts.extend(organize.folders(&q)); - parts.push(name); - Some(parts.join(sep)) -} - -/// The import source in a few words: a folder's name, a file's name, or "N files and folders". -pub fn source_summary(sources: &[String]) -> String { - inspect_sources(sources).display() -} - -/// Facts cached by the filesystem worker, formatted in the UI thread's active language. -/// Caching a translated String on the worker would always use its default language (English) -/// and also retain that language after the user changes the UI language. -#[derive(Clone)] -enum SourceSummary { - Folder(String), - File(String), - Many { files: usize, folders: usize }, -} - -impl SourceSummary { - fn display(&self) -> String { - match self { - Self::Folder(name) => crate::i18n::tr_format!("Folder “{}” (and its subfolders)", name), - Self::File(name) => name.clone(), - Self::Many { files, folders } => match (files, folders) { - (_, 0) => crate::i18n::tr_format!("{} files", files), - (0, f) => crate::i18n::tr_format!("{f} folders (and their subfolders)", f = f), - (_, f) => crate::i18n::tr_format!("{} files and {f} folder{}", files, if *f == 1 { "" } else { "s" }, f = f), - }, - } - } -} - -fn inspect_sources(sources: &[String]) -> SourceSummary { - let name = |p: &str| std::path::Path::new(p).file_name().map_or_else(|| p.to_string(), |n| n.to_string_lossy().to_string()); - match sources { - [one] if std::path::Path::new(one).is_dir() => SourceSummary::Folder(name(one)), - [one] => SourceSummary::File(name(one)), - many => { - let folders = many.iter().filter(|p| std::path::Path::new(p.as_str()).is_dir()).count(); - SourceSummary::Many { files: many.len().saturating_sub(folders), folders } - } - } -} - -/// A labelled row (fixed label column). -fn field(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { - let t = Tokens::get(ui.ctx()); - ui.horizontal(|ui| { - ui.allocate_ui_with_layout(vec2(78.0, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { - ui.set_min_width(78.0); - ui.label(egui::RichText::new(crate::i18n::tr(label)).color(t.text_label)); - }); - add(ui) - }) - .inner -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/dialogs/import/body.rs b/crates/ui-egui/src/dialogs/import/body.rs new file mode 100644 index 000000000..9ba470660 --- /dev/null +++ b/crates/ui-egui/src/dialogs/import/body.rs @@ -0,0 +1,558 @@ +use super::tags::{tag_field_width, tag_help, tag_toggle, unknown_tags_warning}; +use super::*; + +/// The review dialog's first size (points). It can be resized; the photo grid takes the height. +pub(crate) const DIALOG_SIZE: [f32; 2] = [960.0, 720.0]; + +/// Memory keys: where the photo grid ended and where the dialog's content (its buttons) ended, last +/// frame. Their difference is the room the grid leaves below it. +const GRID_BOTTOM: &str = "import-grid-bottom"; + +const CONTENT_BOTTOM: &str = "import-content-bottom"; + +/// Room below the grid before it has been measured (more than the options and buttons take). +pub(super) const DEFAULT_BELOW_GRID: f32 = 360.0; + +/// Height of the photo grid: what is `available` once `below` (options and buttons) is left free, +/// in whole points so the window never grows by a fraction each frame, and at least one `row`. +pub(crate) fn grid_height(available: f32, below: f32, row: f32) -> f32 { + let h = (available - below.max(0.0)).floor(); + if h.is_finite() { h.max(row) } else { (DIALOG_SIZE[1] - DEFAULT_BELOW_GRID).max(row) } +} + +/// Remember where the review dialog's content ends this frame (after its buttons), for +/// [`grid_height`] on the next. +pub(crate) fn note_dialog_bottom(ui: &egui::Ui) { + if !ui.is_sizing_pass() { + let y = ui.min_rect().bottom(); + ui.ctx().data_mut(|m| m.insert_temp(egui::Id::unique(CONTENT_BOTTOM), y)); + } +} + +/// Skipped candidates: (already in the library, identical copies within the source, in Recently +/// Deleted). Files of photos in Recently Deleted are not "already in the library", and a file with +/// the same bytes as one earlier in the list (no library photo) is a copy in the source. +pub(super) fn dup_counts(d: &ImportDialog) -> (usize, usize, usize) { + let trashed = d.trashed.iter().filter(|t| **t).count(); + let in_source = d.candidates.iter().filter(|c| c.duplicate.as_deref() == Some("content") && c.existing.is_none()).count(); + let dups = d.candidates.iter().filter(|c| c.duplicate.is_some()).count().saturating_sub(trashed).saturating_sub(in_source); + (dups, in_source, trashed) +} + +/// The dialog body: options, then the candidate grid. +pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { + let t = Tokens::get(ui.ctx()); + let n = d.candidates.len(); + let (dups, in_source, trashed) = dup_counts(d); + let sel = d.selected_paths().len(); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} found · {sel} selected", n = n, sel = sel)).color(t.text)); + if dups > 0 { + ui.label(egui::RichText::new(crate::i18n::tr_format!("· {dups} already in the library", dups = dups)).color(t.text_dim)); + } + if in_source > 0 { + ui.label(egui::RichText::new(crate::i18n::tr_format!("· {in_source} identical copies", in_source = in_source)).color(t.text_dim)) + .on_hover_text(crate::i18n::tr("Files with the same content as another file in this import: only the first is imported.")); + } + if trashed > 0 { + ui.label(egui::RichText::new(crate::i18n::tr_format!("· {trashed} in Recently Deleted", trashed = trashed)).color(t.text_dim)); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if crate::widgets::text_button(ui, "importNone", crate::i18n::tr("Uncheck All"), false).clicked() { + d.checked.iter_mut().for_each(|c| *c = false); + } + if crate::widgets::text_button(ui, "importAll", crate::i18n::tr("Check All"), false).clicked() { + for i in 0..n { + d.checked[i] = d.checked_by_default(i); + } + } + }); + }); + // with more than one file type: a toggle per type checks or unchecks all of its files + let types = d.file_types(); + if types.len() > 1 { + ui.horizontal_wrapped(|ui| { + ui.label(egui::RichText::new(crate::i18n::tr("File types:")).color(t.text_dim)); + for (ty, count) in &types { + let on = d.type_checked(ty); + let r = crate::widgets::text_button(ui, &format!("importType:{ty}"), &format!("{ty} · {count}"), on) + .on_hover_text(crate::i18n::tr(if on { "Uncheck the files of this type" } else { "Check the files of this type" })); + if r.clicked() { + d.check_type(ty, !on); + } + } + }); + } + // candidate grid + let cell = 116.0; + let avail = ui.available_width(); + let cols = ((avail + 6.0) / (cell + 6.0)).floor().max(1.0) as usize; + let rows = n.div_ceil(cols); + // as tall as the window leaves after the options and buttons below it (measured last frame), + // so resizing the dialog resizes the grid + let below = ui.ctx().data(|m| { + let y = |key: &str| m.get_temp::(egui::Id::unique(key)); + y(CONTENT_BOTTOM).zip(y(GRID_BOTTOM)).map(|(content, grid)| content - grid) + }); + let grid_h = grid_height(ui.available_height(), below.unwrap_or(DEFAULT_BELOW_GRID), cell + 26.0); + let grid = + egui::ScrollArea::vertical().id_salt("import-grid").max_height(grid_h).auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { + let (area, _) = ui.allocate_exact_size(vec2(avail, rows as f32 * (cell + 26.0)), Sense::hover()); + for i in 0..n { + let (c, r) = (i % cols, i / cols); + let local = Rect::from_min_size(pos2(c as f32 * (cell + 6.0), r as f32 * (cell + 26.0)), vec2(cell, cell + 20.0)); + if !local.intersects(viewport.expand(cell)) { + continue; + } + let rect = local.translate(area.min.to_vec2()); + candidate_cell(app, ui, d, i, rect); + } + }); + if !ui.is_sizing_pass() { + ui.ctx().data_mut(|m| m.insert_temp(egui::Id::unique(GRID_BOTTOM), grid.inner_rect.bottom())); + } + ui.add_space(4.0); + // options + if !d.sources.is_empty() { + field(ui, "Source", |ui| { + // (whether each source is a folder is checked off the UI thread, once) + let joined = d.sources.join("\n"); + let summary = crate::panels::left::fs_cached(ui, "import-source", &joined, f64::INFINITY, |s| { + inspect_sources(&s.split('\n').map(str::to_string).collect::>()) + }) + .map(|summary| summary.display()) + .unwrap_or_else(|| crate::i18n::tr_format!("{} item{}", d.sources.len(), if d.sources.len() == 1 { "" } else { "s" })); + let r = ui.label(egui::RichText::new(summary).color(t.text_label)).on_hover_text(joined); + register(ui, "label:importSource", r.rect); + }); + ui.label( + egui::RichText::new(crate::i18n::tr("Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).")) + .color(t.text_dim) + .small(), + ); + } + field(ui, "Transfer", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + if crate::widgets::text_button(ui, "importAdd", crate::i18n::tr("Add in place"), !d.copy) + .on_hover_text(crate::i18n::tr("Reference the files where they are")) + .clicked() + { + d.copy = false; + d.move_files = false; + } + let can_copy = app.session.library.as_ref().is_some_and(|l| l.on_disk) || app.services.pick_folder.is_some(); + let r = + ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importCopy", crate::i18n::tr("Copy"), d.copy && !d.move_files)).inner; + if r.on_hover_text(crate::i18n::tr("Copy the files (into the library's Originals/, or a folder you choose)")).clicked() { + d.copy = true; + d.move_files = false; + } + let r = + ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importMove", crate::i18n::tr("Move"), d.copy && d.move_files)).inner; + if r.on_hover_text(crate::i18n::tr("Move the files (into the library's Originals/, or a folder you choose), removing them from the source")) + .clicked() + { + d.copy = true; + d.move_files = true; + } + }); + let r = ui.label( + egui::RichText::new(crate::i18n::tr(match (d.copy, d.move_files) { + (true, false) => { + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are." + } + (true, true) => { + "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are." + } + _ => "Add in place: the library references the files where they are; nothing is copied or moved.", + })) + .color(if d.copy && d.move_files { t.caution } else { t.text_dim }) + .small(), + ); + register(ui, "label:importModeHelp", r.rect); + if d.copy { + field(ui, if d.move_files { "Move to" } else { "Copy to" }, |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let shown = if d.destination.trim().is_empty() { crate::i18n::tr("Library Originals").to_string() } else { d.destination.clone() }; + ui.label(egui::RichText::new(shown).color(Tokens::get(ui.ctx()).text_label)); + if app.services.pick_folder.is_some() + && crate::widgets::text_button(ui, "importDest", crate::i18n::tr("Choose…"), false).clicked() + && let Some(f) = app.services.pick_folder.as_mut().and_then(|f| f()) + { + d.destination = f; + } + if !d.destination.is_empty() && crate::widgets::text_button(ui, "importDestReset", crate::i18n::tr("Library"), false).clicked() { + d.destination.clear(); + } + }); + field(ui, "Folders", |ui| { + let opts = [ + ("date", "By day (YYYY/YYYY-MM-DD)"), + ("month", "By month (YYYY/YYYY-MM)"), + ("flat", "Into one folder"), + ("custom", "Custom template…"), + ]; + let key = if d.organize.is_empty() { "date".to_string() } else { d.organize.clone() }; + let cur = opts.iter().find(|o| o.0 == key).map_or(opts[0].1, |o| o.1); + let combo = crate::widgets::combo_box("import-organize").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { + for (k, label) in opts { + let r = ui.selectable_label(key == k, crate::i18n::tr(label)); + register(ui, format!("button:importOrganize-{k}"), r.rect); + if r.clicked() { + d.organize = k.to_string(); + if k == "custom" && d.folder_template.trim().is_empty() { + d.folder_template = DEFAULT_FOLDER_TEMPLATE.into(); + } + } + } + }); + crate::widgets::name_combo(&combo.response, crate::i18n::tr("Organize into")); + register(ui, "combo:importOrganize", combo.response.rect); + }); + if d.organize == "custom" { + let folders_id = egui::Id::unique("import-folder-template"); + let tags_open = field(ui, "Template", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let w = tag_field_width(ui); + let r = ui.add(egui::TextEdit::singleline(&mut d.folder_template).id(folders_id).hint_text(DEFAULT_FOLDER_TEMPLATE).desired_width(w)); + register(ui, "field:importFolderTemplate", r.rect); + tag_toggle(ui, "importFolders") + }); + if tags_open { + tag_help(ui, "importFolders", &mut d.folder_template, folders_id); + } + let t = Tokens::get(ui.ctx()); + match lightcraft_engine::rename::folder_template_error(&d.folder_template) { + Some(e) => { + ui.label(egui::RichText::new(e).color(t.caution)); + } + None => unknown_tags_warning(ui, &d.folder_template), + } + ui.label( + egui::RichText::new(crate::i18n::tr( + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").", + )) + .color(t.text_dim) + .small(), + ); + } + if !d.move_files { + field(ui, "Raw files", |ui| { + let r = ui.checkbox(&mut d.dng, crate::i18n::tr("Copy as DNG")); + register(ui, "check:importDng", r.rect); + }); + } + let rename_id = egui::Id::unique("import-rename"); + let tags_open = field(ui, "Rename", |ui| { + ui.spacing_mut().item_spacing.x = 4.0; + let w = tag_field_width(ui); + let r = ui.add( + egui::TextEdit::singleline(&mut d.rename) + .id(rename_id) + .hint_text(crate::i18n::tr("keep names — or e.g. {date}_{seq:3}")) + .desired_width(w), + ); + register(ui, "field:importRename", r.rect); + tag_toggle(ui, "importRename") + }); + if tags_open { + tag_help(ui, "importRename", &mut d.rename, rename_id); + } + unknown_tags_warning(ui, &d.rename); + if let Some(example) = example_destination(app, d) { + let t = Tokens::get(ui.ctx()); + let r = ui.label(egui::RichText::new(crate::i18n::tr_format!("Example: {example}", example = example)).color(t.text_dim)); + register(ui, "label:importExample", r.rect); + ui.label( + egui::RichText::new(crate::i18n::tr("Folders and {date} use the capture time; a photo without one uses today's date.")) + .color(t.text_dim) + .small(), + ); + } + } + if d.trashed.iter().any(|t| *t) { + field(ui, "In Recently Deleted", |ui| { + let cur = match d.on_deleted.as_str() { + "restore" => "Restore them", + "fresh" => "Import as new", + _ => "Leave them", + }; + let mut pick = None; + let combo = crate::widgets::combo_box("import-on-deleted").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { + for (v, label) in [("restore", "Restore them"), ("fresh", "Import as new"), ("", "Leave them")] { + if ui.selectable_label(d.on_deleted == v, crate::i18n::tr(label)).clicked() { + pick = Some(v); + } + } + }); + crate::widgets::name_combo(&combo.response, crate::i18n::tr("Recently deleted files")); + if let Some(v) = pick { + d.set_on_deleted(v); + } + let t = Tokens::get(ui.ctx()); + ui.label( + egui::RichText::new(crate::i18n::tr("Restore keeps their edits; Import as new deletes the old record first.")) + .color(t.text_dim) + .small(), + ); + }); + } + let mut albums = crate::album_picker::entries_from(&app.session.catalog, |_| None); + albums.retain(|a| a.folder || !a.smart); + // Catalog changes while the review is open must not leave a hidden, unselectable destination. + d.album = d.album.filter(|id| albums.iter().any(|a| a.id == *id && !a.folder)); + field(ui, "Album", |ui| { + ui.vertical(|ui| { + let mut chosen = d.album; + let mut extra = None; + let empty = crate::i18n::tr(if d.new_album.is_empty() { "None" } else { "New album" }); + let response = crate::album_picker::AlbumPicker::new("import-album", &mut chosen, &albums) + .width(ui.available_width()) + .empty_text(empty) + .show_with_choices(ui, |ui| { + let none = ui.selectable_label(d.album.is_none() && d.new_album.is_empty(), crate::i18n::tr("None")); + register(ui, "importAlbum:none", none.rect); + if none.clicked() { + extra = Some(false); + } + let new = ui.selectable_label(d.album.is_none() && !d.new_album.is_empty(), crate::i18n::tr("New album…")); + register(ui, "importAlbum:new", new.rect); + if new.clicked() { + extra = Some(true); + } + ui.separator(); + extra.is_some() + }); + if response.changed() { + d.album = chosen; + d.new_album.clear(); + } + if let Some(new) = extra { + d.album = None; + if !new { + d.new_album.clear(); + } else if d.new_album.is_empty() { + d.new_album = crate::i18n::tr("Imported Photos").into(); + } + } + if d.album.is_none() && !d.new_album.is_empty() { + let r = ui.add(egui::TextEdit::singleline(&mut d.new_album).desired_width(f32::INFINITY)); + register(ui, "field:importAlbumName", r.rect); + } + }); + }); + field(ui, "Preset", |ui| { + let cur = app + .session + .presets + .iter() + .find(|p| p.id == d.preset) + .map(|p| crate::i18n::builtin_label(&p.name, p.builtin).to_string()) + .unwrap_or_else(|| crate::i18n::tr("None").into()); + let combo = crate::widgets::combo_box("import-preset").selected_text(cur).height(300.0).show_ui(ui, |ui| { + if ui.selectable_label(d.preset.is_empty(), crate::i18n::tr("None")).clicked() { + d.preset.clear(); + } + let selected = (!d.preset.is_empty()).then_some(d.preset.as_str()); + if let Some(pid) = crate::panels::presets::picker_rows(ui, "import-preset", &app.session.presets, selected) { + d.preset = pid; + } + }); + crate::widgets::name_combo(&combo.response, crate::i18n::tr("Preset")); + }); + if !app.session.metadata_presets.is_empty() { + field(ui, "Metadata", |ui| { + let cur = if d.metadata_preset.is_empty() { crate::i18n::tr("None").to_string() } else { d.metadata_preset.clone() }; + let combo = crate::widgets::combo_box("import-metadata").selected_text(cur).show_ui(ui, |ui| { + if ui.selectable_label(d.metadata_preset.is_empty(), crate::i18n::tr("None")).clicked() { + d.metadata_preset.clear(); + } + for m in &app.session.metadata_presets { + if ui.selectable_label(d.metadata_preset == m.name, &m.name).clicked() { + d.metadata_preset = m.name.clone(); + } + } + }); + crate::widgets::name_combo(&combo.response, crate::i18n::tr("Metadata preset")); + }); + } + field(ui, "Keywords", |ui| { + let r = ui.add(egui::TextEdit::singleline(&mut d.keywords).hint_text(crate::i18n::tr("comma, separated")).desired_width(f32::INFINITY)); + register(ui, "field:importKeywords", r.rect); + }); +} + +/// Draw one import-dialog candidate cell: register `import:{i}`, request the cell's thumbnail, +/// paint the selection checkbox, and label duplicates, unreadable or trashed files. A click on an +/// importable cell toggles it (Shift extends the range); `rect` is the full cell and the thumbnail +/// uses its width as height. +fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog, i: usize, rect: Rect) { + let t = Tokens::get(ui.ctx()); + let c = d.candidates[i].clone(); + let ok = d.importable(i); + let img = Rect::from_min_size(rect.min, vec2(rect.width(), rect.width())); + let resp = ui.interact(img, egui::Id::unique(("import-cell", i)), Sense::click()); + crate::widgets::announce(&resp, c.name.clone()); + register(ui, format!("import:{i}"), img); + let p = ui.painter(); + p.rect_filled(img, 3.0, t.skeleton); + // thumbnail (background job; the grid only asks for the cells in view) + let slot = Slot::Import(i as u32); + if !app.renderer.textures.contains_key(&slot) + && let Some(job) = app.session.candidate_thumb_job(&c, 192, i as u64) + { + app.renderer.request_quick(slot, job, 4); + } + if let Some(tex) = app.renderer.textures.get(&slot) { + let [tw, th] = tex.size; + let s = ((img.width() - 8.0) / tw as f32).min((img.height() - 8.0) / th as f32); + let fit = Rect::from_center_size(img.center(), vec2(tw as f32 * s, th as f32 * s)); + let tint = if ok { Color32::WHITE } else { Color32::from_gray(110) }; + p.image(tex.tex.id(), fit, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), tint); + } + let on = d.checked[i] && ok; + if on { + p.rect_stroke(img, 3.0, Stroke::new(2.0, t.accent), StrokeKind::Inside); + } + // checkbox + let cb = Rect::from_min_size(img.min + vec2(6.0, 6.0), vec2(16.0, 16.0)); + p.rect(cb, 3.0, if on { t.accent } else { Color32::from_black_alpha(150) }, Stroke::new(1.0, Color32::from_gray(200)), StrokeKind::Inside); + if on { + p.line_segment([cb.left_center() + vec2(3.5, 0.5), cb.center_bottom() + vec2(-1.0, -4.0)], Stroke::new(2.0, Color32::WHITE)); + p.line_segment([cb.center_bottom() + vec2(-1.0, -4.0), cb.right_top() + vec2(-3.5, 4.0)], Stroke::new(2.0, Color32::WHITE)); + } + let in_trash = c.existing.is_some_and(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted)); + let badge = match (&c.duplicate, &c.error) { + // checked with "Leave them", the file goes without `onDeleted`: the engine restores it + (Some(_), _) if in_trash && on => Some(if d.on_deleted == "fresh" { "Will be replaced" } else { "Will be restored" }), + (Some(_), _) if in_trash => Some("In Recently Deleted"), + (Some(r), _) if r == "path" => Some("In library"), + (Some(_), _) => Some("Duplicate"), + (None, Some(_)) => Some("Unreadable"), + _ => None, + }; + if let Some(b) = badge { + let g = p.layout_no_wrap(crate::i18n::tr(b).to_string(), t.semibold(10.0), Color32::WHITE); + let br = badge_rect(img, cb, g.size()); + p.rect_filled(br, 3.0, Color32::from_rgba_unmultiplied(170, 60, 50, 220)); + p.galley(br.min + vec2(4.0, 2.0), g, Color32::WHITE); + } + let name = if c.name.chars().count() > 18 { format!("{}…", c.name.chars().take(17).collect::()) } else { c.name.clone() }; + p.text(pos2(rect.left() + 2.0, img.bottom() + 9.0), Align2::LEFT_CENTER, name, t.font(10.5), if ok { t.text_label } else { t.text_dim }); + let tip = format!( + "{}\n{} × {} · {} · {:.1} MB{}", + c.path, + c.width, + c.height, + c.format, + c.file_size as f64 / 1e6, + c.captured.as_deref().map(|d| format!("\n{}", d.replace('T', " "))).unwrap_or_default() + ); + let resp = resp.on_hover_text(tip); + if resp.clicked() && ok { + let shift = ui.input(|input| input.modifiers.shift); + d.click(i, shift); + } +} + +/// Where a cell's badge goes: top right, or under the checkbox when the text is too wide to sit +/// beside it — drawn over the checkbox it hid it (issue #1285). +pub(super) fn badge_rect(img: Rect, cb: Rect, text: Vec2) -> Rect { + let size = text + vec2(8.0, 4.0); + let left = img.right() - size.x - 4.0; + let top = if left < cb.right() + 4.0 { cb.bottom() + 4.0 } else { img.top() + 6.0 }; + Rect::from_min_size(pos2(left, top), size) +} + +/// The folder template the Custom choice starts with (`2026/20260114/`). +pub const DEFAULT_FOLDER_TEMPLATE: &str = "{date:%Y}/{date:%Y%m%d}"; + +/// Where the first selected photo would be copied to (destination, folders, name), for the +/// dialog's example line; `None` without a photo or with an unusable folder template. +pub fn example_destination(app: &LightcraftApp, d: &ImportDialog) -> Option { + let c = d.candidates.iter().zip(&d.checked).enumerate().find(|(i, (_, on))| **on && d.importable(*i)).map(|(_, (c, _))| c)?; + let organize = match d.organize_param().ok()? { + Some(o) => lightcraft_engine::import::Organize::parse(&o)?, + None => Default::default(), + }; + let mut q = lightcraft_catalog::Photo::new( + lightcraft_catalog::PhotoId(0), + lightcraft_catalog::Source::File { path: c.path.clone() }, + &c.name, + &c.format, + 0, + 0, + // a photo without a capture time is dated by the import (now) + &(app.session.clock)(), + ); + q.captured = c.captured.clone(); + // the probe's metadata, so {camera}, {title}… preview as they will import + if let Some(info) = app.session.import_probes.get(&c.path) { + q.meta = info.meta.clone(); + } + let name = if d.rename.trim().is_empty() { c.name.clone() } else { lightcraft_engine::rename::expand(d.rename.trim(), &q, 1) }; + let root = if d.destination.trim().is_empty() { + app.session.library.as_ref().map_or_else(|| "Originals".to_string(), |l| l.dir.join("Originals").to_string_lossy().to_string()) + } else { + d.destination.trim().trim_end_matches(['/', '\\']).to_string() + }; + let sep = std::path::MAIN_SEPARATOR_STR; + let mut parts = vec![root]; + parts.extend(organize.folders(&q)); + parts.push(name); + Some(parts.join(sep)) +} + +/// The import source in a few words: a folder's name, a file's name, or "N files and folders". +pub fn source_summary(sources: &[String]) -> String { + inspect_sources(sources).display() +} + +/// Facts cached by the filesystem worker, formatted in the UI thread's active language. +/// Caching a translated String on the worker would always use its default language (English) +/// and also retain that language after the user changes the UI language. +#[derive(Clone)] +enum SourceSummary { + Folder(String), + File(String), + Many { files: usize, folders: usize }, +} + +impl SourceSummary { + fn display(&self) -> String { + match self { + Self::Folder(name) => crate::i18n::tr_format!("Folder “{}” (and its subfolders)", name), + Self::File(name) => name.clone(), + Self::Many { files, folders } => match (files, folders) { + (_, 0) => crate::i18n::tr_format!("{} files", files), + (0, f) => crate::i18n::tr_format!("{f} folders (and their subfolders)", f = f), + (_, f) => crate::i18n::tr_format!("{} files and {f} folder{}", files, if *f == 1 { "" } else { "s" }, f = f), + }, + } + } +} + +fn inspect_sources(sources: &[String]) -> SourceSummary { + let name = |p: &str| std::path::Path::new(p).file_name().map_or_else(|| p.to_string(), |n| n.to_string_lossy().to_string()); + match sources { + [one] if std::path::Path::new(one).is_dir() => SourceSummary::Folder(name(one)), + [one] => SourceSummary::File(name(one)), + many => { + let folders = many.iter().filter(|p| std::path::Path::new(p.as_str()).is_dir()).count(); + SourceSummary::Many { files: many.len().saturating_sub(folders), folders } + } + } +} + +/// A labelled row (fixed label column). +fn field(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.allocate_ui_with_layout(vec2(78.0, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.set_min_width(78.0); + ui.label(egui::RichText::new(crate::i18n::tr(label)).color(t.text_label)); + }); + add(ui) + }) + .inner +} diff --git a/crates/ui-egui/src/dialogs/import/scan.rs b/crates/ui-egui/src/dialogs/import/scan.rs new file mode 100644 index 000000000..ee237fd5a --- /dev/null +++ b/crates/ui-egui/src/dialogs/import/scan.rs @@ -0,0 +1,272 @@ +use super::task::ImportTask; +use super::*; + +/// A folder scan running on a worker thread (a network share can take minutes to read; the +/// window must keep answering meanwhile). +pub struct ScanTask { + progress: std::sync::Arc, + rx: std::sync::mpsc::Receiver, + /// Open the review with "copy into the library" checked (a camera / card). + pub copy: bool, + /// Open the review with this album chosen (an album's context menu ▸ Import…). + pub album: Option, + /// Browsing a folder (Local): the photos are read in place instead of opening the review. + pub(super) browse: bool, + /// What is being scanned (the review's source). + sources: Vec, + /// The scan's row in the activity stack (its ✕ and `activity.cancel` set `progress.cancel`). + guard: lightcraft_engine::activity::TaskGuard, +} + +/// The activity row of a scan: the review's ("Scanning folder") or the Local view's ("Reading folder"). +fn scan_guard(app: &LightcraftApp, progress: &ScanProgress, browse: bool) -> lightcraft_engine::activity::TaskGuard { + let label = if browse { "Reading folder" } else { "Scanning folder" }; + app.session.activity.start("scan", label, lightcraft_engine::activity::Cancel::Flag(progress.cancel.clone())) +} + +/// An import and a Synchronize Folder never run at once: each readies its files against the +/// library as it was when it started, so both could add the same file. +pub(crate) fn busy_synchronizing(app: &LightcraftApp) -> Result<(), String> { + if app.sync_run.is_some() { Err(crate::i18n::tr("A folder is being synchronized").to_string()) } else { Ok(()) } +} + +/// Settings → Import → "Show the import review when a camera or card is connected", called with +/// each device scan while it's on. The first look only notes the cards already in. A card that +/// shows up after that opens Import from Device once nothing else is open, and a card that is +/// taken out and put back in asks again. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn cards_seen(app: &mut LightcraftApp, now: Vec) { + let Some(known) = app.ui.known_cards.as_mut() else { + app.ui.known_cards = Some(now.into_iter().map(|d| d.root).collect()); + return; + }; + known.retain(|root| now.iter().any(|d| d.root == *root)); + let Some(card) = now.into_iter().find(|d| !known.contains(&d.root)) else { return }; + // busy: ask when the window is free (the card isn't noted until then) + if app.import.is_some() || app.scan.is_some() || app.sync_run.is_some() || app.lightroom.is_some() || app.ui.dialog.is_some() { + return; + } + known.push(card.root); + if let Err(e) = app.run("file.addFromDevice", json!({"path": card.path})) { + log::warn!("import from {}: {e}", card.name); + } +} + +/// Scan `paths` in the background, then open the review dialog (see [`poll_scan`]). +pub fn open(app: &mut LightcraftApp, paths: Vec) -> Result { + if app.scan.is_some() { + return Err("a scan is already running".into()); + } + if app.review_pending.is_some() { + return Err("an import review is waiting for the open dialog to close".into()); + } + let sources = paths.clone(); + let (input, paths) = ScanInput::new(&mut app.session, &paths); + let progress = std::sync::Arc::new(ScanProgress::default()); + let (tx, rx) = std::sync::mpsc::channel(); + let p = progress.clone(); + let job = move || { + let _ = tx.send(scan_with(input, &paths, &p)); + }; + #[cfg(not(target_arch = "wasm32"))] + std::thread::spawn(job); + #[cfg(target_arch = "wasm32")] + job(); + let guard = scan_guard(app, &progress, false); + app.scan = Some(ScanTask { progress, rx, copy: false, album: None, browse: false, sources, guard }); + Ok(json!({"scanning": true})) +} + +/// Show a folder's photos in the Local view (`library.browse` from the UI): the view switches at +/// once, the folder is listed and read on a worker thread (a network share can take minutes), +/// and the photos then join the view in small batches under a progress window. A browse already +/// running is replaced; the import review's scan is not. +pub fn browse(app: &mut LightcraftApp, path: &str, subfolders: Option) -> Result { + let dir = std::path::absolute(std::path::Path::new(path)).map_err(|e| e.to_string())?; + if !dir.is_dir() { + return Err(format!("{path}: not a folder")); + } + if app.scan.as_ref().is_some_and(|t| !t.browse) || app.import.as_ref().is_some_and(|t| !t.browse) { + return Err("an import is running".into()); + } + let dir_s = dir.to_string_lossy().trim_end_matches(['/', '\\']).to_string(); + let subfolders = subfolders.unwrap_or_else(|| app.session.browse.as_ref().is_some_and(|b| b.subfolders)); + let running = app.scan.as_ref().is_some_and(|t| t.browse) || app.import.as_ref().is_some_and(|t| t.browse); + if running && app.session.browse.as_ref().is_some_and(|b| b.path == dir_s && b.subfolders == subfolders) { + // already reading this folder: clicking it again must not restart the progress + app.session.source = lightcraft_engine::LibrarySource::Folder; + app.session.keep_active_in_view(); + return Ok(json!({"path": dir_s, "subfolders": subfolders, "scanning": true})); + } + if let Some(t) = app.scan.take() { + t.progress.cancel.store(true, Ordering::Relaxed); + } + app.import = None; + app.session.browse = Some(lightcraft_engine::Browse { path: dir_s.clone(), subfolders }); + app.session.source = lightcraft_engine::LibrarySource::Folder; + // the photos the library already knows from this folder show at once: keep or pick the active one + app.session.keep_active_in_view(); + let (input, _) = ScanInput::new(&mut app.session, std::slice::from_ref(&dir_s)); + let progress = std::sync::Arc::new(ScanProgress::default()); + let (tx, rx) = std::sync::mpsc::channel(); + let p = progress.clone(); + let root = dir_s.clone(); + let job = move || { + let files: Vec = if subfolders { + lightcraft_engine::import::expand(&[root], None) + } else { + let mut v: Vec = std::fs::read_dir(&root) + .map(|rd| { + rd.flatten() + .map(|e| e.path()) + .filter(|f| { + f.is_file() + && lightcraft_engine::import::is_supported(f) + && !f.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) + }) + .map(|f| f.to_string_lossy().to_string()) + .collect() + }) + .unwrap_or_default(); + v.sort(); + v + }; + let _ = tx.send(scan_with(input, &files, &p)); + }; + #[cfg(not(target_arch = "wasm32"))] + std::thread::spawn(job); + #[cfg(target_arch = "wasm32")] + job(); + let guard = scan_guard(app, &progress, true); + app.scan = Some(ScanTask { progress, rx, copy: false, album: None, browse: true, sources: Vec::new(), guard }); + app.renderer.forget_imports(); + Ok(json!({"path": dir_s, "subfolders": subfolders, "scanning": true})) +} + +/// Collect a finished scan and open the review (called every frame). +pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { + show_pending_review(app, ctx); + let Some(task) = app.scan.as_ref() else { return }; + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + let out = match task.rx.try_recv() { + Ok(o) => o, + 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; + return; + } + let (total, done) = (task.progress.total.load(Ordering::Relaxed), task.progress.done.load(Ordering::Relaxed)); + task.guard.progress(done as u64, total as u64); + // while the folders are listed the total isn't known: say what is happening instead + task.guard.detail(if total == 0 { crate::i18n::tr("Looking for photos…") } else { "" }); + return; + } + Err(_) => { + app.scan = None; + app.toast(ctx, crate::i18n::tr("Scan failed")); + return; + } + }; + let Some(task) = app.scan.take() else { return }; + if task.progress.cancel.load(Ordering::Relaxed) { + return; + } + 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 + // their untouched Local photos aren't forgotten while the folder is in use + if let Some(root) = app.session.browse.as_ref().map(|b| b.path.clone()) { + let shown: Vec = out.candidates.iter().map(|c| c.path.clone()).collect(); + app.session.note_browsed(&root, &shown); + } + // records of files gone from the folder (deleted or moved by another app) that nothing was changed on go, + // as `library.browse` forgets them (issue #1392) + if let Some(b) = app.session.browse.clone() { + let listed: Vec = out.candidates.iter().map(|c| c.path.clone()).collect(); + app.session.forget_gone_from(&b.path, b.subfolders, &listed); + } + // what the library doesn't know yet joins the Local view + let queue: Vec = + out.candidates.iter().filter(|c| c.duplicate != Some("path".into()) && c.error.is_none()).map(|c| c.path.clone()).collect(); + if !queue.is_empty() { + 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)); + } + return; + } + if out.candidates.is_empty() { + app.toast(ctx, crate::i18n::tr("No photos found")); + return; + } + app.renderer.forget_imports(); + let mut d = ImportDialog::new(out.candidates); + let trashed = d + .candidates + .iter() + .map(|c| { + c.duplicate.is_some() + && c.existing.is_some_and(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted)) + }) + .collect(); + d.set_trashed(trashed); + d.copy = task.copy; + // only an album the review offers (it may have been deleted while the scan ran) + d.album = task.album.filter(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(*id)).is_some_and(|a| !a.folder && !a.is_smart())); + d.sources = task.sources; + d.library = Some(app.session.library_generation()); + app.review_pending = Some(crate::state::Dialog::Import { opts: Box::new(d) }); + show_pending_review(app, ctx); +} + +/// Open the review of a finished scan once no other dialog is open (a face model's licence, Settings… +/// opened while the scan ran stays), checked again shortly while it waits. A review whose library was +/// closed meanwhile (Settings can open another) is dropped. +fn show_pending_review(app: &mut LightcraftApp, ctx: &egui::Context) { + if app.review_pending.is_none() { + return; + } + if let Some(crate::state::Dialog::Import { opts }) = &app.review_pending + && !opts.in_library(app) + { + app.review_pending = None; + return; + } + if app.ui.dialog.is_none() { + app.ui.dialog = app.review_pending.take(); + } else { + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + } +} + +/// Tests: a review scan that never ends while the returned value lives, so a drop meets it running. +#[cfg(test)] +pub(crate) fn stall_scan(app: &mut LightcraftApp) -> Box { + stall_scan_of(app, false) +} + +/// Tests: the same, for a Local folder browse scan (`browse`) instead of the review's scan. +#[cfg(test)] +pub(crate) fn stall_scan_of(app: &mut LightcraftApp, browse: bool) -> Box { + let progress = std::sync::Arc::new(ScanProgress::default()); + let (tx, rx) = std::sync::mpsc::channel::(); + let guard = scan_guard(app, &progress, false); + app.scan = Some(ScanTask { progress, rx, copy: false, album: None, browse, sources: Vec::new(), guard }); + Box::new(tx) +} + +/// Whether an import or any scan (the review's, or a Local folder browse) is running, or a finished scan's +/// review waits for a dialog to close, which a drop of photos must not replace: `open` refuses while a scan +/// runs, and a browse that finishes replaces `app.import`. +pub fn running(app: &LightcraftApp) -> bool { + app.import.is_some() || app.scan.is_some() || app.review_pending.is_some() +} + +impl ScanTask { + /// `{done, total}` for `ui.inspect` (total is 0 while the folders are still being listed). + pub fn status(&self) -> Value { + json!({"done": self.progress.done.load(Ordering::Relaxed), "total": self.progress.total.load(Ordering::Relaxed)}) + } +} diff --git a/crates/ui-egui/src/dialogs/import/tags.rs b/crates/ui-egui/src/dialogs/import/tags.rs new file mode 100644 index 000000000..770bb5b30 --- /dev/null +++ b/crates/ui-egui/src/dialogs/import/tags.rs @@ -0,0 +1,95 @@ +use super::*; + +/// Reserve the actual translated tag button width beside a template field. +pub(crate) fn tag_field_width(ui: &egui::Ui) -> f32 { + let t = Tokens::get(ui.ctx()); + let galley = ui.painter().layout_no_wrap(crate::i18n::tr("Tags").to_string(), t.semibold(11.5), t.text); + (ui.available_width() - (galley.size().x + 18.0).max(37.0) - ui.spacing().item_spacing.x).max(80.0) +} + +/// The "Tags" toggle beside a template field (`button:Tags`); returns whether the tag help +/// ([`tag_help`]) is open. +pub(crate) fn tag_toggle(ui: &mut egui::Ui, key: &str) -> bool { + let id = egui::Id::unique((key, "tags-open")); + let mut open = ui.ctx().data(|d| d.get_temp::(id)).unwrap_or(false); + let r = crate::widgets::text_button(ui, &format!("{key}Tags"), crate::i18n::tr("Tags"), open) + .on_hover_text(crate::i18n::tr("Show the template tags; click one to insert it")); + if r.clicked() { + open = !open; + ui.ctx().data_mut(|d| d.insert_temp(id, open)); + } + open +} + +/// "Unknown tag {x} stays as typed" under a template field, when it has one. +pub(crate) fn unknown_tags_warning(ui: &mut egui::Ui, template: &str) { + let unknown = lightcraft_engine::rename::unknown_tokens(template); + if !unknown.is_empty() { + let s = if unknown.len() == 1 { "" } else { "s" }; + let text = crate::i18n::tr_format!( + "Unknown tag{s} {} stay{} as typed (see Tags)", + unknown.join(" "), + if unknown.len() == 1 { "s" } else { "" }, + s = s + ); + ui.label(egui::RichText::new(text).color(Tokens::get(ui.ctx()).caution)); + } +} + +/// Insert `tag` into `text` at the text cursor of the field `edit_id` (replacing its selection); +/// appended when the field never had a cursor. The cursor ends up after the tag. +pub(crate) fn insert_at_cursor(ctx: &egui::Context, edit_id: egui::Id, text: &mut String, tag: &str) { + use egui::text::{CCursor, CCursorRange}; + let mut state = egui::text_edit::TextEditState::load(ctx, edit_id).unwrap_or_default(); + let n = text.chars().count(); + let (a, b) = state.cursor.char_range().map_or((n, n), |r| { + let (x, y) = (r.primary.index.0.min(n), r.secondary.index.0.min(n)); + (x.min(y), x.max(y)) + }); + let byte = |c: usize| text.char_indices().nth(c).map_or(text.len(), |(i, _)| i); + let (ba, bb) = (byte(a), byte(b)); + text.replace_range(ba..bb, tag); + state.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(a + tag.chars().count())))); + state.store(ctx, edit_id); + ctx.memory_mut(|m| m.request_focus(edit_id)); +} + +/// The template tag help: every tag (from the engine's one list, `rename::TOKENS`) with its +/// meaning and an example, the `{date:…}` directives and how templates behave. Clicking a tag +/// (`button:Tag-`) inserts it at the cursor of the field `edit_id`. +pub(crate) fn tag_help(ui: &mut egui::Ui, key: &str, text: &mut String, edit_id: egui::Id) { + use lightcraft_engine::rename::{DATE_DIRECTIVES, TEMPLATE_NOTES, TOKENS, token_example}; + let t = Tokens::get(ui.ctx()); + egui::Frame::new().fill(t.inset).corner_radius(4.0).inner_margin(6.0).show(ui, |ui| { + ui.label( + egui::RichText::new(crate::i18n::tr("Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.")) + .color(t.text_dim) + .small(), + ); + egui::ScrollArea::vertical().id_salt((key, "tags")).max_height(170.0).show(ui, |ui| { + egui::Grid::new((key, "tag-grid")).num_columns(3).spacing([10.0, 2.0]).show(ui, |ui| { + for (i, tok) in TOKENS.iter().enumerate() { + let r = ui.add(egui::Button::new(egui::RichText::new(tok.tag).monospace().color(t.text)).small()).on_hover_text( + if tok.aliases.is_empty() { + crate::i18n::tr("Insert at the cursor").to_string() + } else { + crate::i18n::tr_format!("Insert at the cursor (also written {})", tok.aliases.join(", ")) + }, + ); + register(ui, format!("button:{key}Tag-{i}"), r.rect); + if r.clicked() { + insert_at_cursor(ui.ctx(), edit_id, text, tok.tag); + } + ui.label(egui::RichText::new(crate::i18n::tr(tok.meaning)).color(t.text_label)); + ui.label(egui::RichText::new(token_example(tok.tag)).monospace().color(t.text_dim)); + ui.end_row(); + } + }); + let directives: Vec = DATE_DIRECTIVES.iter().map(|(d, m)| format!("{d} {}", crate::i18n::tr(m))).collect(); + ui.label(egui::RichText::new(crate::i18n::tr_format!("{{date:…}} directives: {}", directives.join(" · "))).color(t.text_dim).small()); + for n in TEMPLATE_NOTES { + ui.label(egui::RichText::new(format!("• {}", crate::i18n::tr(n))).color(t.text_dim).small()); + } + }); + }); +} diff --git a/crates/ui-egui/src/dialogs/import/task.rs b/crates/ui-egui/src/dialogs/import/task.rs new file mode 100644 index 000000000..bcef55b11 --- /dev/null +++ b/crates/ui-egui/src/dialogs/import/task.rs @@ -0,0 +1,544 @@ +use super::scan::{busy_synchronizing, running}; +use super::*; + +/// Files per batch in the browser build (each batch joins the catalog as it is ready, so the +/// progress window updates); the desktop's worker uses [`lightcraft_engine::import::batch_size`]. +#[cfg(target_arch = "wasm32")] +const BATCH: usize = 8; + +/// 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 +/// UI thread between frames, so a slow drive never stalls the window. +#[derive(Debug, Default)] +pub struct ImportTask { + /// Files and folders to import (handed to the worker when the task starts). + queue: Vec, + pub total: usize, + pub done: usize, + params: Value, + pub imported: usize, + /// Brought back from Recently Deleted (`onDeleted: restore`). + pub restored: usize, + pub duplicates: usize, + /// The library photos the skipped duplicates match (shown when nothing new was added). + existing: Vec, + pub failed: usize, + /// Move: originals moved, and sources left in place (reported by the engine with a reason). + pub moved: usize, + pub kept: usize, + /// Structured diagnostics from foreign XMP develop settings. + pub warnings: Vec, + undo0: usize, + first: Option, + /// Reading a folder for the Local view: the photos stay out of the library, and nothing is + /// selected or announced as added. + pub(super) browse: bool, + /// Cancel was pressed: no further files are started; batches already readied are added. + pub cancelled: 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. + keep_selection: Option, + /// The selection and source as the import last left them: a change between frames is the + /// user choosing another photo or view, which the end of the import doesn't take away. + left: Option<(lightcraft_engine::Selection, lightcraft_engine::LibrarySource)>, + /// The user chose another photo or view while the import ran. + moved_on: bool, + run: Option, + /// The import's row in the activity stack (its ✕ and `activity.cancel` set the run's cancel flag). + guard: Option, +} + +impl ImportTask { + /// An import of `paths` (files or folders) with `library.import` params (without `paths`). + pub fn new(paths: Vec, params: Value, undo0: usize, browse: bool) -> Self { + ImportTask { total: paths.len(), queue: paths, params, undo0, browse, ..Default::default() } + } + + /// An Auto Import (see [`ImportTask::auto`]). + pub fn auto(mut self) -> Self { + self.auto = true; + self + } + + /// `{done, total, imported, cancelled}` for `ui.inspect`. + pub fn status(&self) -> Value { + json!({"done": self.done, "total": self.total, "imported": self.imported, "cancelled": self.cancelled}) + } +} + +/// 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 { + #[cfg(not(target_arch = "wasm32"))] + Thread(std::sync::mpsc::Receiver), + #[cfg(target_arch = "wasm32")] + Inline { job: Box, queue: Vec, files: Option> }, +} + +struct ImportRun { + runner: Runner, + cancel: std::sync::Arc, + total: std::sync::Arc, + done: std::sync::Arc, + opts: lightcraft_engine::import::ImportOptions, + now: String, + album: Option, + album_name: Option, +} + +impl std::fmt::Debug for ImportRun { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ImportRun").field("cancel", &self.cancel.load(Ordering::Relaxed)).finish() + } +} + +impl Drop for ImportRun { + /// A dropped import (replaced, or the app closing) stops its worker at the next file. + fn drop(&mut self) { + self.cancel.store(true, Ordering::Relaxed); + } +} + +impl ImportRun { + fn start( + job: lightcraft_engine::import::ImportJob, + queue: Vec, + album: Option, + album_name: Option, + ctx: &egui::Context, + ) -> Result { + let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let (total, done) = (job.total.clone(), job.done.clone()); + let (opts, now) = (job.opts.clone(), job.now().to_string()); + let runner = Self::spawn(job, queue, cancel.clone(), ctx)?; + Ok(ImportRun { runner, cancel, total, done, opts, now, album, album_name }) + } + + #[cfg(not(target_arch = "wasm32"))] + fn spawn( + mut job: lightcraft_engine::import::ImportJob, + queue: Vec, + cancel: std::sync::Arc, + ctx: &egui::Context, + ) -> Result { + let (tx, rx) = std::sync::mpsc::channel(); + 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(); + }; + std::thread::Builder::new().name("lc-import".into()).spawn(work).map(|_| Runner::Thread(rx)).map_err(|e| format!("could not start: {e}")) + } + + #[cfg(target_arch = "wasm32")] + fn spawn( + job: lightcraft_engine::import::ImportJob, + queue: Vec, + _cancel: std::sync::Arc, + _ctx: &egui::Context, + ) -> Result { + Ok(Runner::Inline { job: Box::new(job), queue, files: None }) + } + + /// Readied batches (at most a few per frame). `None` once the worker is done. + fn next_batches(&mut self) -> Option> { + match &mut self.runner { + #[cfg(not(target_arch = "wasm32"))] + Runner::Thread(rx) => { + let mut out = Vec::new(); + while out.len() < 4 { + match rx.try_recv() { + Ok(p) => out.push(p), + Err(std::sync::mpsc::TryRecvError::Empty) => return Some(out), + Err(std::sync::mpsc::TryRecvError::Disconnected) => return if out.is_empty() { None } else { Some(out) }, + } + } + Some(out) + } + #[cfg(target_arch = "wasm32")] + Runner::Inline { job, queue, files } => { + if self.cancel.load(Ordering::Relaxed) { + return None; + } + if files.is_none() { + *files = Some(job.expand(queue).into()); + return Some(Vec::new()); + } + let Some(files) = files.as_mut() else { return None }; + if files.is_empty() { + return None; + } + let n = files.len().min(BATCH); + let batch: Vec = files.drain(..n).collect(); + Some(vec![job.prepare_files(batch, &self.cancel)]) + } + } + } +} + +/// Start importing the dialog's checked files (the dialog's OK / `ui.dialog.confirm`). Refused while another +/// import runs: replacing `app.import` would stop its worker. +pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result { + busy_synchronizing(app)?; + if !d.in_library(app) { + return Err("another library was opened since the photos were scanned: nothing was imported".into()); + } + // a browse scan that finishes starts its own import, replacing this one + if app.import.is_some() || app.scan.as_ref().is_some_and(|t| t.browse) { + return Err(crate::i18n::tr("An import is running").to_string()); + } + let queue = d.selected_paths(); + if queue.is_empty() { + return Err("no photos selected".into()); + } + let undo0 = app.session.undo.len(); + let mut album = d.album; + if album.is_none() && !d.new_album.trim().is_empty() { + let r = app.session.execute("album.create", &json!({"name": d.new_album.trim()})).map_err(|e| e.to_string())?; + album = r["id"].as_u64(); + } + let mode = match (d.copy, d.move_files) { + (false, _) => "add", + (true, false) => "copy", + (true, true) => "move", + }; + let mut params = json!({"mode": mode, "keywords": d.keywords()}); + if !d.on_deleted.is_empty() { + params["onDeleted"] = json!(d.on_deleted); + } + if let Some(a) = album { + params["album"] = json!(a); + } + if !d.preset.is_empty() { + params["preset"] = json!(d.preset); + } + if !d.metadata_preset.is_empty() { + params["metadataPreset"] = json!(d.metadata_preset); + } + if d.copy { + if !d.destination.trim().is_empty() { + params["destination"] = json!(d.destination.trim()); + } + if let Some(o) = d.organize_param()? { + params["organize"] = json!(o); + } + if !d.rename.trim().is_empty() { + params["rename"] = json!(d.rename.trim()); + params["renameStart"] = json!(1); + } + if d.dng && !d.move_files { + params["dng"] = json!(true); + } + } + let total = queue.len(); + app.import = Some(ImportTask::new(queue, params, undo0, false)); + app.renderer.forget_imports(); + Ok(json!({"importing": total})) +} + +/// Start importing `paths` (files or folders) in the background, e.g. dropped on the window. +pub fn start_paths(app: &mut LightcraftApp, paths: Vec) -> Result { + busy_synchronizing(app)?; + if running(app) { + return Err(crate::i18n::tr("An import is running").to_string()); + } + let undo0 = app.session.undo.len(); + let total = paths.len(); + app.import = Some(ImportTask::new(paths, json!({"mode": "add"}), undo0, false)); + Ok(json!({"importing": total})) +} + +/// Advance the import in progress (called every frame): start its worker, then add the batches it +/// has readied to the catalog. +pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { + let Some(mut task) = app.import.take() else { return }; + if task.run.is_none() { + let mut p = task.params.clone(); + p["paths"] = json!(task.queue); + let started = lightcraft_engine::cmd::library::import_params(&app.session, &p) + .and_then(|req| Ok((lightcraft_engine::import::ImportJob::new(&mut app.session, req.opts)?, req.album, req.album_name))) + .map_err(|e| e.to_string()) + .and_then(|(job, album, name)| ImportRun::start(job, std::mem::take(&mut task.queue), album, name, ctx)); + if task.auto { + task.keep_selection = Some(app.session.selection.clone()); + } + match started { + Ok(run) => { + let label = if task.browse { "Reading folder" } else { "Importing" }; + task.guard = Some(app.session.activity.start("import", label, lightcraft_engine::activity::Cancel::Flag(run.cancel.clone()))); + task.run = Some(run); + } + Err(e) => { + log::warn!("import: {e}"); + app.toast(ctx, crate::i18n::tr_format!("Import failed: {e}", e = e)); + return; + } + } + } + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + let Some(run) = task.run.as_mut() else { return }; + let batches = run.next_batches(); + let finished = batches.is_none(); + if task.left.as_ref().is_some_and(|(sel, source)| *sel != app.session.selection || *source != app.session.source) { + task.moved_on = true; + } + for prepared in batches.into_iter().flatten() { + commit_batch(app, &mut task, prepared); + // arriving photos don't take over the selection + if let Some(sel) = &task.keep_selection { + app.session.selection = sel.clone(); + } + // a Local folder still on screen: its first photo becomes active once there is one (#1447) + if task.browse && app.session.source == lightcraft_engine::LibrarySource::Folder { + app.session.keep_active_in_view(); + } + } + task.left = Some((app.session.selection.clone(), app.session.source)); + if let Some(run) = &task.run { + task.total = run.total.load(Ordering::Relaxed).max(task.total); + task.done = run.done.load(Ordering::Relaxed); + } + if let Some(guard) = &task.guard { + guard.progress(task.done as u64, task.total as u64); + // ✕ in the stack (or `activity.cancel`): no further files are started + if guard.is_cancelled() { + task.cancelled = true; + } + } + if !finished { + app.import = Some(task); + return; + } + finish(app, ctx, task); +} + +/// Add one readied batch to the catalog (an undo step merged into the import's at the end). +fn commit_batch(app: &mut LightcraftApp, task: &mut ImportTask, prepared: lightcraft_engine::import::Prepared) { + let Some(run) = task.run.as_ref() else { return }; + let n = prepared.len(); + let (opts, now, album, album_name) = (&run.opts, run.now.as_str(), run.album, run.album_name.clone()); + let r = app.session.execute_fn("library.import", |s| { + let report = lightcraft_engine::import::commit_prepared(s, opts, now, prepared)?; + lightcraft_engine::cmd::library::import_batch_done(s, report, album, album_name.as_deref()) + }); + match r { + Ok(v) => { + // a new album (named in the params) is created with the first photos; later batches join it + if let (Some(a), Some(run)) = (v["album"].as_u64(), task.run.as_mut()) { + run.album = Some(a); + } + let len = |k: &str| v[k].as_array().map_or(0, Vec::len); + task.imported += len("imported"); + task.restored += len("restored"); + 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"); + 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())); + for k in v["kept"].as_array().into_iter().flatten() { + log::warn!("import: kept {}: {}", k["path"].as_str().unwrap_or(""), k["reason"].as_str().unwrap_or("")); + } + if task.first.is_none() { + task.first = ["imported", "restored"].iter().find_map(|k| v[*k].as_array().and_then(|a| a.first()).and_then(Value::as_u64)); + } + } + Err(e) => { + log::warn!("import: {e}"); + task.failed += n; + } + } +} + +fn xmp_warning_text(warning: &ImportWarning) -> String { + let path = &warning.path; + match warning.code { + ImportWarningCode::CameraProfile => crate::i18n::tr_format!( + "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.", + profile = warning.profile.as_deref().unwrap_or_default(), + path = path, + ), + ImportWarningCode::UprightGeometry => match warning.mode.unwrap_or(ImportUprightMode::Unknown) { + ImportUprightMode::Off => crate::i18n::tr_format!( + "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.", + path = path, + ), + ImportUprightMode::Guided => crate::i18n::tr_format!( + "Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.", + path = path, + ), + ImportUprightMode::Auto => crate::i18n::tr_format!( + "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.", + path = path, + mode = crate::i18n::tr("Auto") + ), + ImportUprightMode::Level => crate::i18n::tr_format!( + "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.", + path = path, + mode = crate::i18n::tr("Level") + ), + ImportUprightMode::Vertical => crate::i18n::tr_format!( + "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.", + path = path, + mode = crate::i18n::tr("Vertical") + ), + ImportUprightMode::Full => crate::i18n::tr_format!( + "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.", + path = path, + mode = crate::i18n::tr("Full") + ), + ImportUprightMode::Unknown => { + crate::i18n::tr_format!("Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.", path = path,) + } + }, + ImportWarningCode::UnmappedXmp => { + crate::i18n::tr_format!("XMP adjustments were not imported for {path}: {fields}.", path = path, fields = warning.fields.join(", "),) + } + } +} + +fn xmp_warnings_text(warnings: &[ImportWarning]) -> String { + crate::i18n::tr_format!( + "Import warnings ({n}):\n{warnings}", + n = warnings.len(), + warnings = warnings.iter().map(xmp_warning_text).collect::>().join("\n"), + ) +} + +/// 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) { + 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 { + crate::i18n::tr_format!("Restore {} Photo{}", task.restored, plural(task.restored)) + } else if task.restored > 0 { + crate::i18n::tr_format!("Add {} Photo{}, restore {}", task.imported, plural(task.imported), task.restored) + } else { + crate::i18n::tr_format!("Add {} Photo{}", task.imported, if task.imported == 1 { "" } else { "s" }) + }; + app.session.merge_undo(steps, &label); + if task.auto { + if !task.warnings.is_empty() { + app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0); + } else if task.imported > 0 { + app.toast(ctx, crate::i18n::tr_format!("Auto Import: added {} photo{}", task.imported, if task.imported == 1 { "" } else { "s" })); + } + return; + } + if task.browse { + if !task.warnings.is_empty() { + app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0); + } else if task.failed > 0 { + app.toast(ctx, crate::i18n::tr_format!("{} photo{} not readable", task.failed, if task.failed == 1 { "" } else { "s" })); + } + return; + } + if let Some(f) = task.first.filter(|_| !task.moved_on) { + // a view the new photos aren't in (an album, Picks, a Local folder) would leave the active + // photo off screen: show All Photos, as Lightroom shows the import's own collection (#1446) + if !app.session.visible().contains(&lightcraft_catalog::PhotoId(f)) { + let _ = app.run("library.source", json!({"kind": "all"})); + } + let _ = app.run("library.select", json!({"ids": [f]})); + } + 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 + && task.restored == 0 + && task.failed == 0 + && task.warnings.is_empty() + && !task.cancelled + && show_existing(app, ctx, &task.existing) + { + return; + } + 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 { + crate::i18n::tr_format!("Restored {} photo{} from Recently Deleted", task.restored, plural(task.restored)) + } else { + crate::i18n::tr_format!("Imported {} photo{}", task.imported, plural(task.imported)) + }; + if task.restored > 0 && (task.imported > 0 || task.cancelled) { + msg.push_str(&crate::i18n::tr_format!(" · {} restored from Recently Deleted", task.restored)); + } + if task.params["mode"] == "move" { + msg.push_str(&crate::i18n::tr_format!(" · {} moved", task.moved)); + if task.kept > 0 { + msg.push_str(&crate::i18n::tr_format!(" · {} original{} left at the source", task.kept, plural(task.kept))); + } + } + if task.duplicates > 0 { + msg.push_str(&crate::i18n::tr_format!(" · {} duplicate{} skipped", task.duplicates, if task.duplicates == 1 { "" } else { "s" })); + let deleted = deleted_of(app, &task.existing).len(); + if deleted > 0 { + msg.push_str(&crate::i18n::tr_format!(" ({} in Recently Deleted)", deleted)); + } + } + if task.failed > 0 { + msg.push_str(&crate::i18n::tr_format!(" · {} not readable", task.failed)); + } + if task.warnings.is_empty() { + app.toast(ctx, msg); + } else { + msg.push_str("\n\n"); + msg.push_str(&xmp_warnings_text(&task.warnings)); + app.toast_for(ctx, msg, 8.0); + } +} + +/// The photos among `ids` that are in Recently Deleted (each once). +fn deleted_of(app: &LightcraftApp, ids: &[u64]) -> Vec { + let mut v: Vec = + ids.iter().copied().filter(|&id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted)).collect(); + v.sort_unstable(); + v.dedup(); + v +} + +/// Select the library photos an import skipped as duplicates of, in Recently Deleted (side panel +/// opened, with how to get them back) or in All Photos, and say so. `false` when there are none. +fn show_existing(app: &mut LightcraftApp, ctx: &egui::Context, existing: &[u64]) -> bool { + let deleted = deleted_of(app, existing); + let (kind, mut ids) = if deleted.is_empty() { ("all", existing.to_vec()) } else { ("recentlyDeleted", deleted) }; + ids.sort_unstable(); + ids.dedup(); + ids.retain(|&id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some()); + if ids.is_empty() || app.run("library.source", json!({"kind": kind})).is_err() { + return false; + } + let _ = app.run("library.select", json!({"ids": ids})); + app.ui.left_panel = true; + let n = ids.len(); + let plural = if n == 1 { "" } else { "s" }; + let msg = if kind == "all" { + crate::i18n::tr_format!("Already in the library: {} photo{} selected in All Photos", n, plural) + } else { + crate::i18n::tr_format!( + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh", + n, + plural + ) + }; + app.toast_for(ctx, msg, 6.0); + true +}