//! Synchronize Folder in the app: the folder is scanned on a worker thread (a network share can //! take minutes; no frame waits for it) while its dialog is open, and the dialog then says what //! changed and what to do about it. Synchronize runs `folder.synchronize`, which acts on exactly //! the scan the dialog showed (see `lightcraft_engine::sync`). Both the scan and the run show a row in the activity //! stack (issue #345); its ✕ stops them like the dialog's Cancel. use std::sync::Arc; use std::sync::atomic::Ordering; use lightcraft_engine::activity::{Cancel, TaskGuard}; use lightcraft_engine::sync::{FolderChanges, SyncChoice, SyncCommit, SyncInput, SyncJob, SyncProgress, SyncStep, scan_with}; use serde_json::{Value, json}; use crate::LightcraftApp; use crate::state::{Dialog, SyncCounts}; use crate::theme::Tokens; /// A running scan of the dialog's folder. pub struct SyncTask { path: String, /// What the dialog calls the folder (its row's detail). name: String, progress: Arc, rx: std::sync::mpsc::Receiver, /// Its row in the activity stack; ✕ cancels the scan and closes the dialog. guard: TaskGuard, } /// What a scan's row shows: (done, of how many, detail). While the folder is listed, which comes /// first and on a network share can take minutes, a bar that moves on its own and how many /// folders and files were listed (a count stuck at "0 of 64085" looks like a scan that stopped); /// then the files and photos checked. pub(crate) fn scan_row(progress: &SyncProgress, name: &str) -> (u64, u64, String) { match progress.listing() { Some((folders, files)) => (0, 0, format!("{} · {name}", listing_text(folders, files))), None => { let (done, total) = progress.counts(); (done as u64, total as u64, name.to_string()) } } } fn listing_text(folders: usize, files: usize) -> String { crate::i18n::tr_format!("Listing folders — folders: {folders}, files: {files}", folders = folders, files = files) } /// Open the dialog for folder `path` (called `name` in it) and start scanning. pub fn open(app: &mut LightcraftApp, path: &str, name: &str, disk: bool) -> Result<(), String> { cancel(app); let input = SyncInput::new(&mut app.session, path, disk).map_err(|e| e.to_string())?; let progress = Arc::new(SyncProgress::default()); let (tx, rx) = std::sync::mpsc::channel(); let p = progress.clone(); let job = move || { let _ = tx.send(scan_with(input, &p)); }; #[cfg(not(target_arch = "wasm32"))] std::thread::spawn(job); #[cfg(target_arch = "wasm32")] job(); let guard = app.session.activity.start("sync", "Looking for changes", Cancel::Flag(progress.files.cancel.clone())); guard.detail(name); app.sync = Some(SyncTask { path: path.to_string(), name: name.to_string(), progress, rx, guard }); let d = SyncChoice::default(); app.ui.dialog = Some(Dialog::SynchronizeFolder { path: path.to_string(), name: name.to_string(), disk, counts: None, import_new: d.import_new, relink_moved: d.relink_moved, remove_missing: d.remove_missing, read_metadata: d.read_metadata, }); Ok(()) } /// Stop a running scan and let go of a finished one the dialog made (one an agent made with /// `folder.scanChanges` is the agent's). fn cancel(app: &mut LightcraftApp) { if let Some(t) = app.sync.take() { t.progress.files.cancel.store(true, Ordering::Relaxed); } if std::mem::take(&mut app.sync_owns_changes) { app.session.folder_changes = None; } } /// Collect a finished scan into the dialog (called every frame). A scan whose dialog was closed /// is cancelled. pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { let open_for = match &app.ui.dialog { Some(Dialog::SynchronizeFolder { path, .. }) => Some(path.clone()), _ => None, }; let Some(open_for) = open_for else { if app.sync.is_some() || app.sync_owns_changes { cancel(app); } return; }; let Some(task) = app.sync.as_ref() else { return }; if task.path != open_for { cancel(app); return; } if task.guard.is_cancelled() { // ✕ on its row: a scan stopped part-way has nothing to show cancel(app); app.ui.dialog = None; return; } let (done, total, detail) = scan_row(&task.progress, &task.name); task.guard.progress(done, total); task.guard.detail(&detail); ctx.request_repaint_after(std::time::Duration::from_millis(100)); let changes = match task.rx.try_recv() { Ok(c) => c, Err(std::sync::mpsc::TryRecvError::Empty) => return, Err(_) => { app.sync = None; app.ui.dialog = None; app.toast(ctx, crate::i18n::tr("Scan failed")); return; } }; app.sync = None; let c = SyncCounts { offline: changes.offline, new: changes.new.len(), duplicates: changes.duplicates, unreadable: changes.unreadable.len(), missing: changes.missing.len(), metadata: changes.metadata.len(), moved: changes.moved.len(), }; app.session.folder_changes = Some(changes); app.sync_owns_changes = true; if let Some(Dialog::SynchronizeFolder { counts, .. }) = &mut app.ui.dialog { *counts = Some(c); } } /// How far the scan is: the folders and files listed while it lists the folder, else the files /// and photos checked (done, of how many). enum ScanStage { Listing(usize, usize), Checking(usize, usize), } fn progress(app: &LightcraftApp) -> Option { app.sync.as_ref().map(|t| match t.progress.listing() { Some((folders, files)) => ScanStage::Listing(folders, files), None => { let (done, total) = t.progress.counts(); ScanStage::Checking(done, total) } }) } /// The dialog's body. pub fn body(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) { let Dialog::SynchronizeFolder { path, name, counts, import_new, relink_moved, remove_missing, read_metadata, .. } = dlg else { return }; let t = Tokens::get(ui.ctx()); ui.set_min_width(420.0); ui.label(crate::i18n::tr("Bring the library up to date with this folder and the folders inside it:")); ui.label(egui::RichText::new(name.as_str()).strong()); ui.label(egui::RichText::new(path.as_str()).color(t.text_dim)); ui.add_space(6.0); let Some(c) = counts.as_ref() else { ui.horizontal(|ui| { crate::progress::spinner(ui, 14.0); match progress(app) { Some(ScanStage::Listing(folders, files)) => ui.label(listing_text(folders, files)), Some(ScanStage::Checking(done, total)) if total > 0 => ui.label(format!("{} {done} / {total}", crate::i18n::tr("Scanning…"))), _ => ui.label(crate::i18n::tr("Scanning…")), } }); return; }; if c.offline { ui.label(crate::i18n::tr("This folder isn't there: it was moved or renamed, or its disk isn't connected.")); return; } let choice = |ui: &mut egui::Ui, on: &mut bool, n: usize, text: String, widget: &str| { let r = ui.add_enabled(n > 0, egui::Checkbox::new(on, text)); crate::widgets::register(ui.ctx(), widget, r.rect); }; let count = |text: &str, n: usize| format!("{} ({n})", crate::i18n::tr(text)); let dim = |ui: &mut egui::Ui, s: &str| ui.label(egui::RichText::new(s).color(t.text_dim)); let skipped = |ui: &mut egui::Ui| { if c.duplicates > 0 { dim(ui, &count("Skipped: files already in the library elsewhere", c.duplicates)); } if c.unreadable > 0 { dim(ui, &count("Skipped: files that can't be read", c.unreadable)); } }; if c.up_to_date() { // nothing to choose: the answer takes the choices' place, and the one button closes // (`only_shows`) ui.label(crate::i18n::tr("The library is up to date with this folder.")); skipped(ui); return; } choice(ui, import_new, c.new, count("Import new photos", c.new), "syncImportNew"); choice(ui, relink_moved, c.moved, count("Relink photos whose file was renamed or moved", c.moved), "syncRelinkMoved"); choice(ui, remove_missing, c.missing, count("Remove missing photos from the library", c.missing), "syncRemoveMissing"); choice(ui, read_metadata, c.metadata, count("Read metadata updates from XMP sidecars", c.metadata), "syncReadMetadata"); ui.add_space(6.0); if c.missing > 0 { dim(ui, crate::i18n::tr("Missing photos move to Recently Deleted and can be restored; no file on disk is touched.")); } if c.metadata > 0 { dim(ui, crate::i18n::tr("Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.")); } skipped(ui); } /// Synchronize with the dialog's choices: the scan the dialog showed is handed to a worker /// thread at once ([`SyncRun`]); nothing is read from disk on the UI thread. A scan that went /// stale (the folder's photos changed meanwhile) is refused and made again, keeping the choices. /// A folder that is up to date has nothing to hand over: confirming it only closes the dialog. pub fn confirm(app: &mut LightcraftApp, dlg: &Dialog) -> Result { let Dialog::SynchronizeFolder { path, name, disk, counts: Some(counts), import_new, relink_moved, remove_missing, read_metadata, .. } = dlg else { return Err("the folder is still being scanned".into()); }; // (before the busy checks: nothing is imported or written, so a running import or // synchronize can't clash with it, and the dialog closes either way. Like every result the // dialog shows, it is the scan's: a file added since is found by the next Synchronize) if counts.up_to_date() { return Ok(json!({"upToDate": true})); } if app.sync_run.is_some() { return Err(crate::i18n::tr("A folder is already being synchronized").to_string()); } // (an import readies its files against the library as it was: both could add the same file) if app.import.is_some() { return Err(crate::i18n::tr("Wait for the import to finish").to_string()); } let choice = SyncChoice { import_new: *import_new, relink_moved: *relink_moved, remove_missing: *remove_missing, read_metadata: *read_metadata }; app.sync_owns_changes = false; let started = match app.session.take_folder_changes(path) { Some(changes) => SyncJob::start(&mut app.session, changes, choice).map_err(|e| e.to_string()), None => Err(format!("{path} changed since it was scanned: scan again")), }; let (work, commit) = match started { Ok(halves) => halves, Err(e) => { // look again, in the background, keeping what was ticked if open(app, path, name, *disk).is_ok() && let Some(Dialog::SynchronizeFolder { import_new: i, relink_moved: l, remove_missing: m, read_metadata: x, .. }) = &mut app.ui.dialog { (*i, *l, *m, *x) = (*import_new, *relink_moved, *remove_missing, *read_metadata); } return Err(e); } }; let progress = Arc::new(SyncProgress::default()); let crashed = Arc::new(std::sync::atomic::AtomicBool::new(false)); let (tx, rx) = std::sync::mpsc::channel(); let (p, c) = (progress.clone(), crashed.clone()); let job = move || { // (a panic in the work ends it; what was handed over is still committed, and the end // says it failed) if lightcraft_engine::guard::catch("synchronize", || work.run(&p, |step| tx.send(step).is_ok())).is_err() { c.store(true, Ordering::Relaxed); } }; #[cfg(not(target_arch = "wasm32"))] std::thread::spawn(job); #[cfg(target_arch = "wasm32")] job(); let guard = app.session.activity.start("sync", "Synchronizing folder", Cancel::Flag(progress.files.cancel.clone())); guard.detail(name); app.sync_run = Some(SyncRun { name: name.clone(), progress, crashed, rx, commit: Some(commit), stopping: false, not_saved: Vec::new(), guard }); Ok(json!({"started": true})) } /// A synchronize at work: the file-system half on a worker thread, its steps committed here /// between frames ([`poll_run`]). pub struct SyncRun { name: String, progress: Arc, /// The work ended in a panic (logged). crashed: Arc, rx: std::sync::mpsc::Receiver, /// Steps that went into the catalog but could not be saved (a full disk…), and why. not_saved: Vec, commit: Option, /// Cancel was pressed: nothing new is started; what was readied is still committed. stopping: bool, /// Its row in the activity stack (its ✕ is Cancel). guard: TaskGuard, } impl SyncRun { /// (done, of how many) — for a progress row. pub fn counts(&self) -> (usize, usize) { self.progress.counts() } /// Stop after the piece in progress. pub fn cancel(&mut self) { self.stopping = true; self.progress.files.cancel.store(true, Ordering::Relaxed); } } impl Drop for SyncRun { /// A dropped run (the app closing) stops its worker at the next piece. fn drop(&mut self) { self.progress.files.cancel.store(true, Ordering::Relaxed); } } /// Commit what the worker readied (called every frame); when it is done, one undo step and a /// word on what happened. pub fn poll_run(app: &mut LightcraftApp, ctx: &egui::Context) { let Some(run) = app.sync_run.as_mut() else { return }; ctx.request_repaint_after(std::time::Duration::from_millis(100)); if run.guard.is_cancelled() { run.stopping = true; } let (done, total) = run.counts(); run.guard.progress(done as u64, total as u64); let mut steps = Vec::new(); let finished = loop { match run.rx.try_recv() { Ok(step) => steps.push(step), Err(std::sync::mpsc::TryRecvError::Empty) => break false, Err(std::sync::mpsc::TryRecvError::Disconnected) => break true, } }; let Some(mut commit) = run.commit.take() else { return }; let mut not_saved = Vec::new(); for step in steps { // (a step that went in but couldn't be saved — a full disk — is said at the end) if let Err(e) = app.session.execute_fn("folder.synchronize", |s| { commit.apply(s, step); Ok(Value::Null) }) { not_saved.push(e.to_string()); } } let Some(run) = app.sync_run.as_mut() else { return }; run.not_saved.extend(not_saved); if !finished { run.commit = Some(commit); return; } let (name, stopped, crashed, mut not_saved) = (run.name.clone(), run.stopping, run.crashed.load(Ordering::Relaxed), std::mem::take(&mut run.not_saved)); app.sync_run = None; let mut report = None; if let Err(e) = app.session.execute_fn("folder.synchronize", |s| { report = Some(commit.finish(s)); Ok(Value::Null) }) { not_saved.push(e.to_string()); } let r = report.unwrap_or_default(); for (path, e) in &r.failed { log::warn!("synchronize {name}: {path}: {e}"); } for e in ¬_saved { log::warn!("synchronize {name}: not saved: {e}"); } let mut parts: Vec = [("Imported", r.imported), ("Relinked", r.relinked), ("Removed", r.removed), ("Metadata read", r.read), ("Failed", r.failed.len())] .into_iter() .filter(|(_, n)| *n > 0) .map(|(what, n)| format!("{} {n}", crate::i18n::tr(what))) .collect(); // how it ended comes first: never "up to date" for a run that was stopped or failed if crashed { parts.insert(0, crate::i18n::tr("Stopped by an error (see the log)").to_string()); } else if stopped { parts.insert(0, crate::i18n::tr("Stopped").to_string()); } if let Some(e) = not_saved.first() { parts.push(format!("{}: {e}", crate::i18n::tr("Not saved"))); } let text = if parts.is_empty() { crate::i18n::tr("The library is up to date with this folder.").to_string() } else { parts.join(" · ") }; if not_saved.is_empty() && !crashed { app.toast(ctx, format!("{name}: {text}")); } else { app.toast_for(ctx, format!("{name}: {text}"), 12.0); } }