From eaa8828bb8105c6c53c86f293526b6eb0836e62a Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 17:49:11 -0400 Subject: [PATCH 01/28] Engine: Synchronize Folder (folder.scanChanges, folder.synchronize) A scan compares a library folder (and the folders inside it) with the disk without changing anything: new files (the import scan's duplicate rules), missing photos (the Missing Photos rule) and metadata updates (XMP sidecars saved after the photo came in or was last edited here that say something the library doesn't). It runs without the session (SyncInput + scan_with) so the app can scan on a worker thread. folder.synchronize imports the new files in place and, when asked, moves missing photos to Recently Deleted and reads the updated sidecars, all as one "Synchronize Folder" undo step. Only folders the library holds photos in are accepted, never the startup disk. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/sync.rs | 53 ++++++++ crates/engine/src/lib.rs | 3 + crates/engine/src/sync.rs | 232 ++++++++++++++++++++++++++++++++ crates/engine/src/tests_sync.rs | 211 +++++++++++++++++++++++++++++ 5 files changed, 501 insertions(+) create mode 100644 crates/engine/src/cmd/sync.rs create mode 100644 crates/engine/src/sync.rs create mode 100644 crates/engine/src/tests_sync.rs diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ef838fe02..9d6d1006f 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -31,6 +31,7 @@ mod prefs; mod preset_files; pub mod previews; mod query; +mod sync; mod xmp; use serde::Serialize; @@ -142,6 +143,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(before::specs()); v.extend(browse::specs()); v.extend(missing::specs()); + v.extend(sync::specs()); v.extend(metadata::specs()); v.extend(filters::specs()); v.extend(denoise::specs()); diff --git a/crates/engine/src/cmd/sync.rs b/crates/engine/src/cmd/sync.rs new file mode 100644 index 000000000..1aefe94b7 --- /dev/null +++ b/crates/engine/src/cmd/sync.rs @@ -0,0 +1,53 @@ +//! Synchronize Folder commands (see [`crate::sync`]). + +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, bool_or, cmd, str_param}; +use crate::sync::{SyncChoice, scan, synchronize}; +use crate::{Result, Session}; + +fn path(p: &Value, c: &str) -> Result { + str_param(p, "path").filter(|d| !d.trim().is_empty()).map(str::to_string).ok_or_else(|| bad(c, "missing `path`")) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + query "folder.scanChanges", + "Scan Folder for Changes", + [], + None, + "{path} — compare a folder of the library (and the folders inside it) with the disk; changes nothing → {path, new: [{path, name, format, …}] files the library doesn't have, duplicates: files whose content it already has, unreadable: [{path, error}], missing: [{id, path}] photos whose file is gone, metadata: [{id, path}] photos whose XMP sidecar was saved after they came in or were last edited here and says something the library doesn't}", + always, + |s, p| { + const C: &str = "folder.scanChanges"; + let path = path(p, C)?; + let changes = scan(s, &path).map_err(|e| bad(C, e.to_string()))?; + Ok(serde_json::to_value(changes).unwrap_or_default()) + } + ), + cmd!( + "folder.synchronize", + "Synchronize Folder", + [], + None, + "{path, importNew?: true, removeMissing?: false, readMetadata?: false} — bring a folder of the library up to date with the disk (see folder.scanChanges): import its new files in place, move photos whose file is gone to Recently Deleted, read XMP sidecars saved by other apps (the sidecar wins); one undo step, no file is touched → {imported, removed, read, failed: [[path, error]]}", + always, + sync + ), + ] +} + +fn sync(s: &mut Session, p: &Value) -> Result { + const C: &str = "folder.synchronize"; + let path = path(p, C)?; + let d = SyncChoice::default(); + let choice = SyncChoice { + import_new: bool_or(p, "importNew", d.import_new), + remove_missing: bool_or(p, "removeMissing", d.remove_missing), + read_metadata: bool_or(p, "readMetadata", d.read_metadata), + }; + let changes = scan(s, &path).map_err(|e| bad(C, e.to_string()))?; + let r = synchronize(s, changes, choice)?; + Ok(json!({"imported": r.imported, "removed": r.removed, "read": r.read, "failed": r.failed})) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a6c5f6420..feeeccc60 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -53,6 +53,7 @@ pub mod rename; pub mod segment; pub mod sidecar; pub mod smart; +pub mod sync; mod view; pub mod walk; @@ -877,4 +878,6 @@ mod tests_settings_files; #[cfg(test)] mod tests_spots; #[cfg(test)] +mod tests_sync; +#[cfg(test)] mod tests_xmp; diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs new file mode 100644 index 000000000..c91e47fa0 --- /dev/null +++ b/crates/engine/src/sync.rs @@ -0,0 +1,232 @@ +//! Synchronize Folder: bring a folder of the library up to date with what is on disk, as +//! Lightroom Classic's Synchronize Folder does. +//! +//! A [scan](scan_with) compares the folder (and the folders inside it) with the library and finds +//! three kinds of change, touching nothing: +//! +//! - **new** files: supported files the library doesn't have, by path or by content (the import +//! scan's duplicate rules, [`crate::import::scan_with`]); +//! - **missing** photos: library photos in the folder whose file is gone (the Missing Photos rule, +//! [`crate::cmd::missing::checked_path`]); +//! - **metadata** updates: photos whose XMP sidecar file was saved after the photo came into the +//! library or was last edited here, and says something the library doesn't (rating, flag, +//! label, metadata, capture time or develop settings). Reading one makes the sidecar win, as +//! Read Metadata from File does. Embedded XMP in a raw is not a sidecar and is not looked at. +//! +//! [`synchronize`] then imports the new files (in place), moves the missing photos to Recently +//! Deleted and reads the metadata updates — each only when chosen — as one undo step. +//! +//! The scan runs without the session ([`SyncInput`] + [`scan_with`]), so the app can run it on a +//! worker thread: a folder on a sleeping network share must never stall a frame. +//! +//! Limits: a sidecar the library itself wrote (auto-write on) agrees with it and is never an +//! update; one written here before auto-write was turned off, followed by changes made only in +//! the library, is listed as an update even though the library is newer (there is no record of +//! when a photo's metadata last changed here). Reading updates is therefore never the default. + +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +use lightcraft_catalog::{Catalog, Filter, Op, Photo, PhotoId, Sort, Source}; +use serde::Serialize; + +use crate::import::{ImportCandidate, ImportMode, ImportOptions, ScanInput, ScanProgress}; +use crate::media::ProbeInfo; +use crate::sidecar::SidecarNaming; +use crate::{EngineError, Result, Session}; + +/// The largest XMP sidecar read when looking for metadata updates; a bigger one is skipped. +const MAX_SIDECAR_BYTES: u64 = 16 * 1024 * 1024; + +/// What a scan of a folder needs, taken from the session so it can run on a worker thread. +pub struct SyncInput { + folder: String, + scan: ScanInput, + /// The library's photos in the folder, each with how its sidecar is named. + photos: Vec<(Arc, SidecarNaming)>, + /// For the colour label names a sidecar may use. + labels: Catalog, +} + +/// What changed in a folder since it came into the library. +#[derive(Clone, Debug, Default, Serialize)] +pub struct FolderChanges { + /// The folder, as asked for. + pub path: String, + /// Files to import. + pub new: Vec, + /// Files whose content the library already has (under another path). + pub duplicates: usize, + /// Supported files that couldn't be read. + pub unreadable: Vec, + /// Photos whose file is gone. + pub missing: Vec, + /// Photos whose sidecar has news. + pub metadata: Vec, + /// The probes of the new files, kept for the import that follows. + #[serde(skip)] + probes: HashMap, +} + +/// A photo of the folder and its file. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct FolderPhoto { + pub id: u64, + pub path: String, +} + +/// What [`synchronize`] does with the changes found. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SyncChoice { + pub import_new: bool, + pub remove_missing: bool, + pub read_metadata: bool, +} + +impl Default for SyncChoice { + /// Lightroom Classic's defaults: import the new files, leave the rest to be looked at. + fn default() -> Self { + SyncChoice { import_new: true, remove_missing: false, read_metadata: false } + } +} + +/// What [`synchronize`] did. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +pub struct SyncReport { + pub imported: usize, + pub removed: usize, + pub read: usize, + /// (path or photo, why) for what couldn't be done. + pub failed: Vec<(String, String)>, +} + +fn invalid(msg: impl Into) -> EngineError { + EngineError::Other(msg.into()) +} + +impl SyncInput { + /// The scan of folder `path`: a folder the library holds photos in, not the startup disk. + pub fn new(s: &mut Session, path: &str) -> Result { + let path = path.trim(); + if path.is_empty() { + return Err(invalid("missing `path`")); + } + if lightcraft_catalog::folders::is_startup_disk(path) { + return Err(invalid("choose a folder, not the whole startup disk")); + } + let f = Filter { library_folder: Some(path.to_string()), ..Default::default() }; + let ids = s.catalog.query(&f, &Sort::default()); + if ids.is_empty() { + return Err(invalid(format!("{path}: no photo in the library was imported from it"))); + } + let photos = ids.iter().filter_map(|id| s.catalog.photo(*id).map(|p| (Arc::clone(p), s.sidecar_naming(*id)))).collect(); + let (scan, _) = ScanInput::new(s, &[path.to_string()]); + Ok(SyncInput { folder: path.to_string(), scan, photos, labels: s.catalog.clone() }) + } +} + +/// Find what changed in the folder (see the module docs). Reads the disk, never the session. +pub fn scan_with(input: SyncInput, progress: &ScanProgress) -> FolderChanges { + let SyncInput { folder, scan, photos, labels } = input; + let out = crate::import::scan_with(scan, std::slice::from_ref(&folder), progress); + let mut changes = FolderChanges { path: folder, probes: out.probes, ..Default::default() }; + for c in out.candidates { + match (&c.duplicate, &c.error) { + (Some(d), _) if d == "path" => {} + (Some(_), _) => changes.duplicates += 1, + (None, Some(_)) => changes.unreadable.push(c), + (None, None) => changes.new.push(c), + } + } + for (p, naming) in &photos { + let Some(file) = crate::cmd::missing::checked_path(p) else { continue }; + if cfg!(target_arch = "wasm32") { + continue; + } + if !Path::new(file).exists() { + changes.missing.push(FolderPhoto { id: p.id.0, path: file.to_string() }); + } else if p.copy_of.is_none() && sidecar_has_news(&labels, p, *naming) { + changes.metadata.push(FolderPhoto { id: p.id.0, path: file.to_string() }); + } + } + changes +} + +/// [`scan_with`] on the calling thread. +pub fn scan(s: &mut Session, path: &str) -> Result { + let input = SyncInput::new(s, path)?; + Ok(scan_with(input, &ScanProgress::default())) +} + +/// Whether `p`'s XMP sidecar file was saved after the library last had its say about the photo +/// (imported, or edited here) and says something the library doesn't. +fn sidecar_has_news(labels: &Catalog, p: &Photo, naming: SidecarNaming) -> bool { + let Source::File { path } = &p.source else { return false }; + let Some(file) = crate::sidecar::find_sidecar(path, naming) else { return false }; + let Ok(meta) = std::fs::metadata(&file) else { return false }; + if meta.len() > MAX_SIDECAR_BYTES { + return false; + } + let saved = meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs()); + let Some(saved) = saved.and_then(|s| i64::try_from(s).ok()) else { return false }; + let seen = [Some(p.imported.as_str()), p.edited.as_deref()].into_iter().flatten().filter_map(lightcraft_catalog::stacks::iso_seconds).max(); + if seen.is_some_and(|t| saved <= t) { + return false; + } + let Ok(text) = std::fs::read_to_string(&file) else { return false }; + let Ok(sc) = crate::sidecar::parse_sidecar(&text, p.kind == lightcraft_catalog::MediaKind::Raw) else { return false }; + let sc = sc.resolve_label(labels); + let mut q = p.clone(); + crate::sidecar::merge_into(&mut q, &sc, ""); + (q.rating, q.flag, q.label, &q.meta, &q.captured, &q.develop) != (p.rating, p.flag, p.label, &p.meta, &p.captured, &p.develop) +} + +/// Act on `changes` as `choice` says, as one undo step ("Synchronize Folder"). A missing photo +/// whose file is back by now is left alone. +pub fn synchronize(s: &mut Session, changes: FolderChanges, choice: SyncChoice) -> Result { + let mark = s.undo.len(); + let mut report = SyncReport::default(); + let FolderChanges { new, missing, metadata, probes, .. } = changes; + if choice.import_new && !new.is_empty() { + s.import_probes = probes; + let files: Vec = new.into_iter().map(|c| c.path).collect(); + let r = crate::import::import_with(s, &files, &ImportOptions { mode: ImportMode::Add, ..Default::default() })?; + report.imported = r.imported.len(); + report.failed.extend(r.failed); + } + if choice.remove_missing { + let gone: Vec = missing.iter().filter(|m| !Path::new(&m.path).exists()).map(|m| PhotoId(m.id)).collect(); + let ops: Vec = gone + .iter() + .filter(|id| s.catalog.photo(**id).is_some_and(|p| p.in_library())) + .map(|id| Op::SetDeleted { id: *id, deleted: true }) + .collect(); + if !ops.is_empty() { + report.removed = ops.len(); + s.commit("Remove Missing Photos", Op::Batch { ops })?; + } + } + if choice.read_metadata { + let mut ops = Vec::new(); + for m in &metadata { + match s.read_sidecar_op(PhotoId(m.id)) { + Ok(Some((op, _))) => { + ops.push(op); + report.read += 1; + } + Ok(None) => report.failed.push((m.path.clone(), "no XMP sidecar".into())), + Err(e) => report.failed.push((m.path.clone(), e.to_string())), + } + } + if !ops.is_empty() { + s.commit("Read Metadata from File", Op::Batch { ops })?; + } + } + let steps = s.undo.len().saturating_sub(mark); + s.merge_undo(steps, "Synchronize Folder"); + if let Some(last) = s.undo.last_mut().filter(|_| steps == 1) { + last.label = "Synchronize Folder".into(); + } + Ok(report) +} diff --git a/crates/engine/src/tests_sync.rs b/crates/engine/src/tests_sync.rs new file mode 100644 index 000000000..4822f1172 --- /dev/null +++ b/crates/engine/src/tests_sync.rs @@ -0,0 +1,211 @@ +//! Synchronize Folder: bring a library folder up to date with what is on disk (see +//! [`crate::sync`]). +//! +//! Scenarios, in the words of someone whose folder changed outside LightCraft: +//! +//! * Given a folder of the library, when files were added to it (or to a folder inside it), the +//! scan lists them as new; files the library already has, by path or by content, are not new. +//! * When a photo's file was deleted or moved away, the scan lists the photo as missing. +//! * When another app saved a photo's XMP sidecar after the photo came into the library, and the +//! sidecar says something the library doesn't, the scan lists a metadata update; a sidecar older +//! than that, or one that agrees with the library, is not an update. +//! * Scanning changes nothing. +//! * Synchronizing imports the new files; it removes missing photos (to Recently Deleted) and +//! reads metadata updates only when asked. Whatever it does is one undo step. +//! * Only a folder of the library can be synchronized, never the whole startup disk. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +use serde_json::{Value, json}; + +use crate::Session; + +/// A scratch folder that goes away with the test, however it ends. +struct Scratch(PathBuf); + +impl Scratch { + fn new(tag: &str) -> Scratch { + let dir = std::env::temp_dir().join(format!("lc-sync-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Scratch(dir) + } + fn path(&self, rel: &str) -> String { + self.0.join(rel).to_string_lossy().to_string() + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// A small procedural PNG (distinct per seed). +fn write_png(path: &str, seed: u8) { + let (w, h) = (24usize, 16usize); + let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 9) as u8, (i / w * 13) as u8, seed, 255]).collect(); + let img = lightcraft_raster::Rgba8 { width: w, height: h, data }; + let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::create_dir_all(Path::new(path).parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); +} + +fn sidecar(rating: u8) -> String { + format!( + r#" + +"# + ) +} + +/// Write `a.xmp` beside `a.png`, dated `age` before now. +fn write_sidecar(png: &str, rating: u8, age: Duration) { + let xmp = Path::new(png).with_extension("xmp"); + std::fs::write(&xmp, sidecar(rating)).unwrap(); + let f = std::fs::File::options().write(true).open(&xmp).unwrap(); + f.set_modified(SystemTime::now() - age).unwrap(); +} + +/// A library holding `trip/a.png` and `trip/day1/b.png`, the session clock at a fixed past time. +fn library(dir: &Scratch) -> Session { + write_png(&dir.path("trip/a.png"), 1); + write_png(&dir.path("trip/day1/b.png"), 2); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [dir.path("trip")]})).unwrap(); + assert_eq!(s.catalog.len(), 2); + s +} + +fn paths(v: &Value, key: &str) -> Vec { + let mut out: Vec = v[key].as_array().unwrap().iter().map(|c| c["path"].as_str().unwrap().to_string()).collect(); + out.sort(); + out +} + +/// What the library holds: each photo's file, whether it is in the library, and its rating. +fn holdings(s: &Session) -> Vec<(String, bool, u8)> { + let mut v: Vec<(String, bool, u8)> = s.catalog.photos().map(|p| (p.file_name.clone(), p.in_library(), p.rating)).collect(); + v.sort(); + v +} + +fn scan(s: &mut Session, path: &str) -> Value { + s.execute("folder.scanChanges", &json!({"path": path})).unwrap() +} + +#[test] +fn an_unchanged_folder_has_no_changes() { + let dir = Scratch::new("none"); + let mut s = library(&dir); + let r = scan(&mut s, &dir.path("trip")); + assert!(paths(&r, "new").is_empty() && paths(&r, "missing").is_empty() && paths(&r, "metadata").is_empty(), "{r}"); +} + +#[test] +fn files_added_to_the_folder_or_a_folder_inside_it_are_new() { + let dir = Scratch::new("new"); + let mut s = library(&dir); + write_png(&dir.path("trip/c.png"), 3); + write_png(&dir.path("trip/day2/d.png"), 4); + // the same bytes as a photo the library has: not new + std::fs::copy(dir.path("trip/a.png"), dir.path("trip/a-copy.png")).unwrap(); + let before = s.catalog.to_snapshot(); + let r = scan(&mut s, &dir.path("trip")); + assert_eq!(paths(&r, "new"), vec![dir.path("trip/c.png"), dir.path("trip/day2/d.png")], "{r}"); + assert_eq!(r["duplicates"], 1, "{r}"); + assert_eq!(s.catalog.to_snapshot(), before, "scanning changes nothing"); +} + +#[test] +fn a_photo_whose_file_is_gone_is_missing() { + let dir = Scratch::new("missing"); + let mut s = library(&dir); + std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap(); + let r = scan(&mut s, &dir.path("trip")); + assert_eq!(paths(&r, "missing"), vec![dir.path("trip/day1/b.png")], "{r}"); + assert!(r["missing"][0]["id"].is_u64()); +} + +#[test] +fn a_sidecar_saved_by_another_app_is_a_metadata_update() { + let dir = Scratch::new("meta"); + let mut s = library(&dir); + // saved now: after the photo came in (the session clock says 2026-09-30) + write_sidecar(&dir.path("trip/a.png"), 4, Duration::ZERO); + let r = scan(&mut s, &dir.path("trip")); + assert_eq!(paths(&r, "metadata"), vec![dir.path("trip/a.png")], "{r}"); +} + +#[test] +fn an_old_sidecar_or_one_that_agrees_is_no_update() { + let dir = Scratch::new("meta-old"); + let mut s = library(&dir); + // older than the import: whatever it says, the library has had its say since + write_sidecar(&dir.path("trip/a.png"), 4, Duration::from_secs(400 * 24 * 3600)); + // new, but says what the library already knows + write_sidecar(&dir.path("trip/day1/b.png"), 0, Duration::ZERO); + let r = scan(&mut s, &dir.path("trip")); + assert!(paths(&r, "metadata").is_empty(), "{r}"); +} + +#[test] +fn synchronizing_imports_new_files_and_leaves_the_rest_unless_asked() { + let dir = Scratch::new("sync"); + let mut s = library(&dir); + write_png(&dir.path("trip/c.png"), 3); + std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap(); + write_sidecar(&dir.path("trip/a.png"), 5, Duration::ZERO); + let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap(); + assert_eq!((r["imported"].as_u64(), r["removed"].as_u64(), r["read"].as_u64()), (Some(1), Some(0), Some(0)), "{r}"); + assert_eq!(s.catalog.photos().filter(|p| p.in_library()).count(), 3, "the missing photo stays"); + let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap(); + assert_eq!(a.rating, 0, "metadata is read only when asked"); +} + +#[test] +fn synchronizing_everything_is_one_undo_step() { + let dir = Scratch::new("sync-all"); + let mut s = library(&dir); + let before = holdings(&s); + write_png(&dir.path("trip/c.png"), 3); + std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap(); + write_sidecar(&dir.path("trip/a.png"), 5, Duration::ZERO); + let r = + s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "importNew": true, "removeMissing": true, "readMetadata": true})).unwrap(); + assert_eq!((r["imported"].as_u64(), r["removed"].as_u64(), r["read"].as_u64()), (Some(1), Some(1), Some(1)), "{r}"); + let library: Vec = s.catalog.photos().filter(|p| p.in_library()).map(|p| p.file_name.clone()).collect(); + assert_eq!(library.len(), 2, "b went to Recently Deleted: {library:?}"); + assert_eq!(s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().rating, 5, "the sidecar was read"); + let undo = s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(undo["undone"], "Synchronize Folder"); + assert_eq!(holdings(&s), before, "one undo step puts everything back"); +} + +#[test] +fn a_folder_with_no_changes_synchronizes_without_an_undo_step() { + let dir = Scratch::new("sync-none"); + let mut s = library(&dir); + let steps = s.undo.len(); + let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "removeMissing": true, "readMetadata": true})).unwrap(); + assert_eq!((r["imported"].as_u64(), r["removed"].as_u64(), r["read"].as_u64()), (Some(0), Some(0), Some(0)), "{r}"); + assert_eq!(s.undo.len(), steps); +} + +#[test] +fn only_a_folder_of_the_library_is_synchronized() { + let dir = Scratch::new("refused"); + let mut s = library(&dir); + std::fs::create_dir_all(dir.0.join("elsewhere")).unwrap(); + for (cmd, p, why) in [ + ("folder.scanChanges", json!({}), "missing path"), + ("folder.scanChanges", json!({"path": " "}), "blank path"), + ("folder.scanChanges", json!({"path": dir.path("elsewhere")}), "no photo was imported from it"), + ("folder.scanChanges", json!({"path": "/"}), "the startup disk"), + ("folder.synchronize", json!({"path": dir.path("elsewhere")}), "no photo was imported from it"), + ("folder.synchronize", json!({"path": "/"}), "the startup disk"), + ] { + assert!(s.execute(cmd, &p).is_err(), "{cmd}: {why}"); + } +} From 5345f0a3cf442fa064437969b28753d9eadb848f Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 17:50:33 -0400 Subject: [PATCH 02/28] Engine: folder.synchronize acts on the scan the person saw folder.scanChanges keeps its result in the session; folder.synchronize uses it (once) while it is of the same folder and the catalog has not changed since, so a scan made on a worker thread is not repeated on the UI thread and files that arrive after the dialog was shown are left for the next synchronize. Otherwise it scans again. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/sync.rs | 11 ++++++++--- crates/engine/src/lib.rs | 4 ++++ crates/engine/src/sync.rs | 20 +++++++++++++++++--- crates/engine/src/tests_sync.rs | 28 ++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/sync.rs b/crates/engine/src/cmd/sync.rs index 1aefe94b7..ecb252df6 100644 --- a/crates/engine/src/cmd/sync.rs +++ b/crates/engine/src/cmd/sync.rs @@ -23,7 +23,9 @@ pub fn specs() -> Vec { const C: &str = "folder.scanChanges"; let path = path(p, C)?; let changes = scan(s, &path).map_err(|e| bad(C, e.to_string()))?; - Ok(serde_json::to_value(changes).unwrap_or_default()) + let v = serde_json::to_value(&changes).unwrap_or_default(); + s.folder_changes = Some(changes); + Ok(v) } ), cmd!( @@ -31,7 +33,7 @@ pub fn specs() -> Vec { "Synchronize Folder", [], None, - "{path, importNew?: true, removeMissing?: false, readMetadata?: false} — bring a folder of the library up to date with the disk (see folder.scanChanges): import its new files in place, move photos whose file is gone to Recently Deleted, read XMP sidecars saved by other apps (the sidecar wins); one undo step, no file is touched → {imported, removed, read, failed: [[path, error]]}", + "{path, importNew?: true, removeMissing?: false, readMetadata?: false} — bring a folder of the library up to date with the disk (see folder.scanChanges): import its new files in place, move photos whose file is gone to Recently Deleted, read XMP sidecars saved by other apps (the sidecar wins); acts on the last folder.scanChanges of the folder while nothing changed in the library since (else scans again); one undo step, no file is touched → {imported, removed, read, failed: [[path, error]]}", always, sync ), @@ -47,7 +49,10 @@ fn sync(s: &mut Session, p: &Value) -> Result { remove_missing: bool_or(p, "removeMissing", d.remove_missing), read_metadata: bool_or(p, "readMetadata", d.read_metadata), }; - let changes = scan(s, &path).map_err(|e| bad(C, e.to_string()))?; + let changes = match s.take_folder_changes(&path) { + Some(c) => c, + None => scan(s, &path).map_err(|e| bad(C, e.to_string()))?, + }; let r = synchronize(s, changes, choice)?; Ok(json!({"imported": r.imported, "removed": r.removed, "read": r.read, "failed": r.failed})) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index feeeccc60..02c3cd9c1 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -253,6 +253,9 @@ pub struct Session { pub before: std::collections::HashMap>, /// File probes from the last import review (`library.importPreview`), reused by the import. pub import_probes: std::collections::HashMap, + /// The last Synchronize Folder scan, which `folder.synchronize` acts on while it is current + /// (see [`Session::take_folder_changes`]). + pub folder_changes: Option, /// The last (or running) Build Previews. pub preview_build: Option>, /// Develop defaults applied on import (persisted in prefs.json). @@ -335,6 +338,7 @@ impl Session { recent_keywords: Vec::new(), before: Default::default(), import_probes: Default::default(), + folder_changes: None, preview_build: None, import_defaults: import::ImportDefaults::default(), cache_mb: 0, diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs index c91e47fa0..2012381fa 100644 --- a/crates/engine/src/sync.rs +++ b/crates/engine/src/sync.rs @@ -42,6 +42,7 @@ const MAX_SIDECAR_BYTES: u64 = 16 * 1024 * 1024; /// What a scan of a folder needs, taken from the session so it can run on a worker thread. pub struct SyncInput { folder: String, + revision: u64, scan: ScanInput, /// The library's photos in the folder, each with how its sidecar is named. photos: Vec<(Arc, SidecarNaming)>, @@ -67,6 +68,9 @@ pub struct FolderChanges { /// The probes of the new files, kept for the import that follows. #[serde(skip)] probes: HashMap, + /// The catalog revision the scan was made against. + #[serde(skip)] + revision: u64, } /// A photo of the folder and its file. @@ -122,15 +126,15 @@ impl SyncInput { } let photos = ids.iter().filter_map(|id| s.catalog.photo(*id).map(|p| (Arc::clone(p), s.sidecar_naming(*id)))).collect(); let (scan, _) = ScanInput::new(s, &[path.to_string()]); - Ok(SyncInput { folder: path.to_string(), scan, photos, labels: s.catalog.clone() }) + Ok(SyncInput { folder: path.to_string(), revision: s.catalog.revision, scan, photos, labels: s.catalog.clone() }) } } /// Find what changed in the folder (see the module docs). Reads the disk, never the session. pub fn scan_with(input: SyncInput, progress: &ScanProgress) -> FolderChanges { - let SyncInput { folder, scan, photos, labels } = input; + let SyncInput { folder, revision, scan, photos, labels } = input; let out = crate::import::scan_with(scan, std::slice::from_ref(&folder), progress); - let mut changes = FolderChanges { path: folder, probes: out.probes, ..Default::default() }; + let mut changes = FolderChanges { path: folder, probes: out.probes, revision, ..Default::default() }; for c in out.candidates { match (&c.duplicate, &c.error) { (Some(d), _) if d == "path" => {} @@ -159,6 +163,16 @@ pub fn scan(s: &mut Session, path: &str) -> Result { Ok(scan_with(input, &ScanProgress::default())) } +impl Session { + /// The last scan ([`Session::folder_changes`]) if it is of folder `path` and nothing changed + /// in the library since: what the person was shown is what gets done. Used once. + pub fn take_folder_changes(&mut self, path: &str) -> Option { + let c = self.folder_changes.take()?; + let same = lightcraft_catalog::query::folder_key(&c.path) == lightcraft_catalog::query::folder_key(path); + (same && c.revision == self.catalog.revision).then_some(c) + } +} + /// Whether `p`'s XMP sidecar file was saved after the library last had its say about the photo /// (imported, or edited here) and says something the library doesn't. fn sidecar_has_news(labels: &Catalog, p: &Photo, naming: SidecarNaming) -> bool { diff --git a/crates/engine/src/tests_sync.rs b/crates/engine/src/tests_sync.rs index 4822f1172..46a77fd21 100644 --- a/crates/engine/src/tests_sync.rs +++ b/crates/engine/src/tests_sync.rs @@ -209,3 +209,31 @@ fn only_a_folder_of_the_library_is_synchronized() { assert!(s.execute(cmd, &p).is_err(), "{cmd}: {why}"); } } + +#[test] +fn synchronizing_acts_on_the_changes_just_scanned() { + let dir = Scratch::new("cached"); + let mut s = library(&dir); + write_png(&dir.path("trip/c.png"), 3); + let r = scan(&mut s, &dir.path("trip")); + assert_eq!(paths(&r, "new").len(), 1); + // arrived after the scan the person was shown: not part of what they agreed to + write_png(&dir.path("trip/d.png"), 4); + let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap(); + assert_eq!(r["imported"], 1, "{r}"); + // the scan is used once; the next synchronize looks again + let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap(); + assert_eq!(r["imported"], 1, "d.png now: {r}"); +} + +#[test] +fn a_scan_is_stale_once_the_library_changed() { + let dir = Scratch::new("stale"); + let mut s = library(&dir); + write_png(&dir.path("trip/c.png"), 3); + scan(&mut s, &dir.path("trip")); + // c.png comes in some other way between the scan and the synchronize + s.execute("library.import", &json!({"paths": [dir.path("trip/c.png")]})).unwrap(); + let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap(); + assert_eq!(r["imported"], 0, "the library changed: it looked again: {r}"); +} From 1e580466b562a0e0eded88f81ae4a6d0b05a6f1c Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 17:57:32 -0400 Subject: [PATCH 03/28] Engine: scan a folder for changes concurrently and in parallel The scan waits on the disk, not the CPU, so its two halves now run at once: the look for new files (which probes on its own threads) beside the checks of the library's photos (missing file, newer sidecar), which run on several threads like the import probes do. A cancelled scan stops checking. Results keep the photos' order. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/import.rs | 2 +- crates/engine/src/sync.rs | 80 +++++++++++++++++++++++++++++---- crates/engine/src/tests_sync.rs | 33 ++++++++++++++ 3 files changed, 105 insertions(+), 10 deletions(-) diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 833d92e8e..124ac3903 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -1044,7 +1044,7 @@ fn name_family(name: &str) -> String { /// Threads for `jobs` file reads (probes, copies) at once. #[cfg(not(target_arch = "wasm32"))] -fn workers(jobs: usize) -> usize { +pub(crate) fn workers(jobs: usize) -> usize { std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(1, 8).min(jobs.max(1)) } diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs index 2012381fa..dd2b6b9b5 100644 --- a/crates/engine/src/sync.rs +++ b/crates/engine/src/sync.rs @@ -131,9 +131,20 @@ impl SyncInput { } /// Find what changed in the folder (see the module docs). Reads the disk, never the session. +/// +/// Both halves wait on the disk (a network share answers each file slowly), so they run at once: +/// the look for new files (which probes them on its own threads) beside the checks of the +/// library's photos, which run on several threads too. Stops early when `progress.cancel` is set. pub fn scan_with(input: SyncInput, progress: &ScanProgress) -> FolderChanges { let SyncInput { folder, revision, scan, photos, labels } = input; - let out = crate::import::scan_with(scan, std::slice::from_ref(&folder), progress); + let (out, checks) = std::thread::scope(|sc| { + let photos = &photos; + let labels = &labels; + let checks = sc.spawn(move || check_photos(photos, labels, progress)); + let out = crate::import::scan_with(scan, std::slice::from_ref(&folder), progress); + // (a check that panicked counts as nothing found; the scan still reports the rest) + (out, checks.join().unwrap_or_default()) + }); let mut changes = FolderChanges { path: folder, probes: out.probes, revision, ..Default::default() }; for c in out.candidates { match (&c.duplicate, &c.error) { @@ -143,20 +154,71 @@ pub fn scan_with(input: SyncInput, progress: &ScanProgress) -> FolderChanges { (None, None) => changes.new.push(c), } } - for (p, naming) in &photos { + for ((p, _), check) in photos.iter().zip(checks) { let Some(file) = crate::cmd::missing::checked_path(p) else { continue }; - if cfg!(target_arch = "wasm32") { - continue; - } - if !Path::new(file).exists() { - changes.missing.push(FolderPhoto { id: p.id.0, path: file.to_string() }); - } else if p.copy_of.is_none() && sidecar_has_news(&labels, p, *naming) { - changes.metadata.push(FolderPhoto { id: p.id.0, path: file.to_string() }); + let found = FolderPhoto { id: p.id.0, path: file.to_string() }; + match check { + Check::Missing => changes.missing.push(found), + Check::News => changes.metadata.push(found), + Check::Nothing => {} } } changes } +/// What checking one photo of the folder found. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum Check { + #[default] + Nothing, + Missing, + News, +} + +fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog) -> Check { + let Some(file) = crate::cmd::missing::checked_path(p) else { return Check::Nothing }; + if !Path::new(file).exists() { + Check::Missing + } else if p.copy_of.is_none() && sidecar_has_news(labels, p, naming) { + Check::News + } else { + Check::Nothing + } +} + +/// [`check_photo`] for each photo, in order, on several threads (each file is a wait on the +/// disk, not work for the CPU). Photos not reached before a cancel count as nothing found. +fn check_photos(photos: &[(Arc, SidecarNaming)], labels: &Catalog, progress: &ScanProgress) -> Vec { + use std::sync::atomic::Ordering::Relaxed; + if cfg!(target_arch = "wasm32") { + return vec![Check::Nothing; photos.len()]; + } + #[cfg(not(target_arch = "wasm32"))] + { + let n = crate::import::workers(photos.len()); + if n > 1 { + let next = std::sync::atomic::AtomicUsize::new(0); + let found = std::sync::Mutex::new(vec![Check::Nothing; photos.len()]); + std::thread::scope(|sc| { + for _ in 0..n { + sc.spawn(|| { + while !progress.cancel.load(Relaxed) { + let i = next.fetch_add(1, Relaxed); + let Some((p, naming)) = photos.get(i) else { break }; + let c = check_photo(p, *naming, labels); + if let Some(slot) = found.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_mut(i) { + *slot = c; + } + } + }); + } + }); + return found.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner); + } + } + photos.iter().map(|(p, naming)| if progress.cancel.load(Relaxed) { Check::Nothing } else { check_photo(p, *naming, labels) }).collect() +} + /// [`scan_with`] on the calling thread. pub fn scan(s: &mut Session, path: &str) -> Result { let input = SyncInput::new(s, path)?; diff --git a/crates/engine/src/tests_sync.rs b/crates/engine/src/tests_sync.rs index 46a77fd21..3cda16b36 100644 --- a/crates/engine/src/tests_sync.rs +++ b/crates/engine/src/tests_sync.rs @@ -237,3 +237,36 @@ fn a_scan_is_stale_once_the_library_changed() { let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap(); assert_eq!(r["imported"], 0, "the library changed: it looked again: {r}"); } + +#[test] +fn a_cancelled_scan_stops_checking_files() { + let dir = Scratch::new("cancel"); + let mut s = library(&dir); + std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap(); + let input = crate::sync::SyncInput::new(&mut s, &dir.path("trip")).unwrap(); + let progress = crate::import::ScanProgress::default(); + progress.cancel.store(true, std::sync::atomic::Ordering::Relaxed); + let c = crate::sync::scan_with(input, &progress); + assert!(c.missing.is_empty() && c.metadata.is_empty(), "{c:?}"); +} + +#[test] +fn many_photos_are_checked_in_parallel_with_the_same_answer() { + let dir = Scratch::new("many"); + for i in 0..40u8 { + write_png(&dir.path(&format!("big/{i:02}.png")), i); + } + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [dir.path("big")]})).unwrap(); + for i in (0..40u8).step_by(3) { + std::fs::remove_file(dir.path(&format!("big/{i:02}.png"))).unwrap(); + } + for i in (1..40u8).step_by(5) { + write_sidecar(&dir.path(&format!("big/{i:02}.png")), 2, Duration::ZERO); + } + let r = scan(&mut s, &dir.path("big")); + let expect_missing: Vec = (0..40u8).step_by(3).map(|i| dir.path(&format!("big/{i:02}.png"))).collect(); + let expect_meta: Vec = (1..40u8).step_by(5).filter(|i| i % 3 != 0).map(|i| dir.path(&format!("big/{i:02}.png"))).collect(); + assert_eq!(paths(&r, "missing"), expect_missing); + assert_eq!(paths(&r, "metadata"), expect_meta); +} From c520cf45746e5794bc0ad14c9b8d1de735149301 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 17:57:32 -0400 Subject: [PATCH 04/28] =?UTF-8?q?UI:=20Synchronize=20Folder=E2=80=A6=20dia?= =?UTF-8?q?log=20on=20Folders=20rows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-click a folder (or a disk other than the startup disk) in the sidebar's Folders section ▸ Synchronize Folder…: the dialog opens at once and scans on a worker thread (with progress), then offers Import new photos / Remove missing photos / Read metadata updates with their counts (Lightroom Classic's defaults: import only). Synchronize runs folder.synchronize on exactly the scan shown; closing the dialog cancels a running scan and lets go of the result. New strings are translated in every shipped language. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de.json | 16 ++- crates/ui-egui/locales/es.json | 16 ++- crates/ui-egui/locales/ja.json | 16 ++- crates/ui-egui/locales/pt-br.json | 16 ++- crates/ui-egui/locales/ru.json | 16 ++- crates/ui-egui/locales/zh-hans.json | 16 ++- crates/ui-egui/locales/zh-hant.json | 16 ++- crates/ui-egui/src/lib.rs | 7 ++ crates/ui-egui/src/panels/dialogs.rs | 11 +- crates/ui-egui/src/panels/left.rs | 17 +++ crates/ui-egui/src/state.rs | 23 ++++ crates/ui-egui/src/sync.rs | 162 +++++++++++++++++++++++++++ crates/ui-egui/src/tests_sync.rs | 151 +++++++++++++++++++++++++ 13 files changed, 475 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/src/sync.rs create mode 100644 crates/ui-egui/src/tests_sync.rs diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index c24b59cc0..e747ad2f2 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1889,5 +1889,19 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Synchronize Folder…": "Ordner synchronisieren…", + "Synchronize Folder": "Ordner synchronisieren", + "Synchronize": "Synchronisieren", + "Scanning…": "Wird durchsucht…", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Fotos finden, die in diesem Ordner auf dem Datenträger hinzugekommen sind oder fehlen, und von anderen Apps geänderte XMP-Filialdateien", + "Bring the library up to date with this folder and the folders inside it:": "Die Bibliothek mit diesem Ordner und den Ordnern darin abgleichen:", + "Import new photos": "Neue Fotos importieren", + "Remove missing photos from the library": "Fehlende Fotos aus der Bibliothek entfernen", + "Read metadata updates from XMP sidecars": "Metadaten-Änderungen aus XMP-Filialdateien lesen", + "The library is up to date with this folder.": "Die Bibliothek ist mit diesem Ordner auf dem neuesten Stand.", + "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Fehlende Fotos werden nach „Zuletzt gelöscht“ verschoben und können wiederhergestellt werden; keine Datei auf dem Datenträger wird verändert.", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Beim Lesen einer Filialdatei werden Bewertung, Markierungen, Metadaten und Bearbeitungen des Fotos durch ihren Inhalt ersetzt.", + "Skipped: files already in the library elsewhere": "Übersprungen: Dateien, die bereits an anderer Stelle in der Bibliothek sind", + "Skipped: files that can't be read": "Übersprungen: Dateien, die nicht gelesen werden können" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 387ac9717..0b29f570b 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1734,5 +1734,19 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Synchronize Folder…": "Sincronizar carpeta…", + "Synchronize Folder": "Sincronizar carpeta", + "Synchronize": "Sincronizar", + "Scanning…": "Explorando…", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Buscar fotos añadidas a esta carpeta del disco o que faltan en ella, y archivos XMP complementarios cambiados por otras apps", + "Bring the library up to date with this folder and the folders inside it:": "Poner la biblioteca al día con esta carpeta y las carpetas que contiene:", + "Import new photos": "Importar fotos nuevas", + "Remove missing photos from the library": "Quitar de la biblioteca las fotos que faltan", + "Read metadata updates from XMP sidecars": "Leer cambios de metadatos de los archivos XMP complementarios", + "The library is up to date with this folder.": "La biblioteca está al día con esta carpeta.", + "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Las fotos que faltan pasan a Eliminadas recientemente y se pueden restaurar; no se toca ningún archivo del disco.", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Leer un archivo complementario sustituye la valoración, las etiquetas, los metadatos y las ediciones de la foto por lo que contiene.", + "Skipped: files already in the library elsewhere": "Omitidos: archivos que ya están en la biblioteca en otro lugar", + "Skipped: files that can't be read": "Omitidos: archivos que no se pueden leer" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index b05baac24..ae2023ff7 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1734,5 +1734,19 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Synchronize Folder…": "フォルダを同期…", + "Synchronize Folder": "フォルダを同期", + "Synchronize": "同期", + "Scanning…": "スキャン中…", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "ディスク上のこのフォルダに追加された写真や見つからない写真、ほかのアプリで変更された XMP サイドカーを探します", + "Bring the library up to date with this folder and the folders inside it:": "このフォルダとその中のフォルダに合わせてライブラリを最新にします:", + "Import new photos": "新しい写真を読み込む", + "Remove missing photos from the library": "見つからない写真をライブラリから除去", + "Read metadata updates from XMP sidecars": "XMP サイドカーからメタデータの更新を読み込む", + "The library is up to date with this folder.": "ライブラリはこのフォルダと一致しています。", + "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "見つからない写真は「最近削除した写真」に移動し、復元できます。ディスク上のファイルには手を加えません", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "サイドカーを読み込むと、写真のレーティング、ラベル、メタデータ、編集内容がその内容に置き換わります。", + "Skipped: files already in the library elsewhere": "スキップ: 別の場所ですでにライブラリにあるファイル", + "Skipped: files that can't be read": "スキップ: 読み込めないファイル" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index ab7f39734..71369387f 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1734,5 +1734,19 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Synchronize Folder…": "Sincronizar pasta…", + "Synchronize Folder": "Sincronizar pasta", + "Synchronize": "Sincronizar", + "Scanning…": "Verificando…", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Encontrar fotos adicionadas a esta pasta no disco ou ausentes dela, e arquivos XMP auxiliares alterados por outros apps", + "Bring the library up to date with this folder and the folders inside it:": "Atualizar a biblioteca com esta pasta e as pastas dentro dela:", + "Import new photos": "Importar fotos novas", + "Remove missing photos from the library": "Remover fotos ausentes da biblioteca", + "Read metadata updates from XMP sidecars": "Ler atualizações de metadados dos arquivos XMP auxiliares", + "The library is up to date with this folder.": "A biblioteca está atualizada com esta pasta.", + "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "As fotos ausentes vão para Excluídos recentemente e podem ser restauradas; nenhum arquivo no disco é alterado.", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Ler um arquivo auxiliar substitui a classificação, os rótulos, os metadados e as edições da foto pelo que ele contém.", + "Skipped: files already in the library elsewhere": "Ignorados: arquivos que já estão na biblioteca em outro lugar", + "Skipped: files that can't be read": "Ignorados: arquivos que não podem ser lidos" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 517e2613f..dc2b35756 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1407,5 +1407,19 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Synchronize Folder…": "Синхронизировать папку…", + "Synchronize Folder": "Синхронизация папки", + "Synchronize": "Синхронизировать", + "Scanning…": "Сканирование…", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Найти фото, добавленные в эту папку на диске или пропавшие из неё, и файлы XMP, изменённые другими приложениями", + "Bring the library up to date with this folder and the folders inside it:": "Привести медиатеку в соответствие с этой папкой и вложенными папками:", + "Import new photos": "Импортировать новые фото", + "Remove missing photos from the library": "Убрать отсутствующие фото из медиатеки", + "Read metadata updates from XMP sidecars": "Прочитать изменения метаданных из файлов XMP", + "The library is up to date with this folder.": "Медиатека соответствует этой папке.", + "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Отсутствующие фото перемещаются в «Недавно удалённые» и могут быть восстановлены; файлы на диске не затрагиваются.", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Чтение файла XMP заменяет рейтинг, метки, метаданные и правки фото его содержимым.", + "Skipped: files already in the library elsewhere": "Пропущено: файлы, которые уже есть в медиатеке в другом месте", + "Skipped: files that can't be read": "Пропущено: файлы, которые не удаётся прочитать" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 46728b628..2123391d6 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1734,5 +1734,19 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Synchronize Folder…": "同步文件夹…", + "Synchronize Folder": "同步文件夹", + "Synchronize": "同步", + "Scanning…": "正在扫描…", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "查找磁盘上此文件夹中新增或缺失的照片,以及被其他应用更改的 XMP 附属文件", + "Bring the library up to date with this folder and the folders inside it:": "让图库与此文件夹及其中的文件夹保持一致:", + "Import new photos": "导入新照片", + "Remove missing photos from the library": "从图库中移除缺失的照片", + "Read metadata updates from XMP sidecars": "从 XMP 附属文件读取元数据更新", + "The library is up to date with this folder.": "图库与此文件夹一致。", + "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "缺失的照片会移到“最近删除”,可以恢复;磁盘上的文件不会被改动。", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "读取附属文件会用其内容替换照片的评级、标签、元数据和编辑。", + "Skipped: files already in the library elsewhere": "已跳过:已在图库其他位置的文件", + "Skipped: files that can't be read": "已跳过:无法读取的文件" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 083c98c2e..8fa254cdf 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1734,5 +1734,19 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Synchronize Folder…": "同步資料夾…", + "Synchronize Folder": "同步資料夾", + "Synchronize": "同步", + "Scanning…": "正在掃描…", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "尋找磁碟上此資料夾中新增或遺失的照片,以及被其他 App 更改的 XMP 附屬檔案", + "Bring the library up to date with this folder and the folders inside it:": "讓照片庫與此資料夾及其中的資料夾保持一致:", + "Import new photos": "匯入新照片", + "Remove missing photos from the library": "從照片庫移除遺失的照片", + "Read metadata updates from XMP sidecars": "從 XMP 附屬檔案讀取中繼資料更新", + "The library is up to date with this folder.": "照片庫與此資料夾一致。", + "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "遺失的照片會移至「最近刪除」,可以還原;磁碟上的檔案不會被更動。", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "讀取附屬檔案會以其內容取代照片的評等、標籤、中繼資料和編輯。", + "Skipped: files already in the library elsewhere": "已略過:已在照片庫其他位置的檔案", + "Skipped: files that can't be read": "已略過:無法讀取的檔案" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 705818911..61c226f1c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -27,6 +27,7 @@ pub mod render; pub mod shortcuts; pub mod softpaint; pub mod state; +pub mod sync; pub mod tasks; pub mod theme; pub mod titlebar; @@ -63,6 +64,8 @@ mod tests_scroll; #[cfg(test)] mod tests_switch_library; #[cfg(test)] +mod tests_sync; +#[cfg(test)] mod tests_titlebar; #[cfg(test)] mod tests_unsaved; @@ -240,6 +243,8 @@ pub struct LightcraftApp { pub import: Option, /// A folder scan in progress (feeds the import review). pub scan: Option, + /// A Synchronize Folder scan in progress (feeds its dialog). + pub sync: Option, /// A Lightroom catalog inspect/import in progress. pub lightroom: Option, /// Last terminal Lightroom result, exposed by the command's status/wait response. @@ -311,6 +316,7 @@ impl LightcraftApp { merge: merge::MergeState::default(), import: None, scan: None, + sync: None, lightroom: None, lightroom_last: None, export: None, @@ -734,6 +740,7 @@ impl LightcraftApp { self.renderer.poll(ctx, &mut self.session); merge::poll(self, ctx); import::poll_scan(self, ctx); + sync::poll(self, ctx); import::tick(self, ctx); lightroom_import::tick(self, ctx); tasks::poll(self, ctx); diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index fa2e6ad9e..2890ae1fc 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -103,6 +103,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::ConfirmDelete { .. } => "Delete Photos", Dialog::RemoveFolder { disk: true, .. } => "Remove Disk from Library", Dialog::RemoveFolder { .. } => "Remove Folder from Library", + Dialog::SynchronizeFolder { .. } => "Synchronize Folder", // (nothing to download from in this build: the dialog explains the manual install) Dialog::SamModel { .. } if sam_by_hand(&app.session.segmenter) => "Install the SAM 3 Model", Dialog::SamModel { .. } => "Download the SAM 3 Model?", @@ -728,6 +729,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.label(crate::i18n::tr_format!("Move {what} to Recently Deleted?", what = what)); ui.label(egui::RichText::new(crate::i18n::tr("They can be restored from Recently Deleted until it is emptied.")).color(t.text_dim)); } + d @ Dialog::SynchronizeFolder { .. } => crate::sync::body(app, ui, d), Dialog::RemoveFolder { name, count, path, disk } => { ui.label(crate::i18n::tr_format!( "Remove “{name}” and its {count} photo{} from the library?", @@ -828,6 +830,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::FaceModel { .. } if !informational => "Install", Dialog::ConfirmDelete { .. } => "Delete", Dialog::RemoveFolder { .. } => "Remove", + Dialog::SynchronizeFolder { .. } => "Synchronize", Dialog::SamModel { then: Some(_), .. } if sam_installed => "Continue", Dialog::SamModel { .. } if sam_installed || sam_running || sam_nowhere => "", Dialog::SamModel { error, .. } if error.is_some() || sam_failed => "Try Again", @@ -836,7 +839,12 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { _ => "OK", }; // installing waits for the licence to be accepted - let can_confirm = informational || !matches!(&dlg, Dialog::DenoiseModel { accepted: false, .. } | Dialog::FaceModel { accepted: false, .. }); + // (and Synchronize waits for the scan) + let can_confirm = informational + || !matches!( + &dlg, + Dialog::DenoiseModel { accepted: false, .. } | Dialog::FaceModel { accepted: false, .. } | Dialog::SynchronizeFolder { counts: None, .. } + ); let r = (!ok.is_empty()).then(|| ui.add_enabled(can_confirm, egui::Button::new(crate::i18n::tr(ok)))); if let Some(r) = &r { crate::widgets::register(ui.ctx(), "button:dialogOk", r.rect); @@ -1112,6 +1120,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result crate::import::start(app, opts), Dialog::ConfirmDelete { .. } => app.run("photo.delete", json!({})), Dialog::RemoveFolder { path, disk, .. } => app.run("library.removeFolder", json!({"path": path, "disk": disk})), + d @ Dialog::SynchronizeFolder { .. } => crate::sync::confirm(app, d), Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } => Ok(serde_json::Value::Null), } } diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 7312bfc1a..3c0a0c216 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -1225,6 +1225,7 @@ fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) { } else if n.path != "/" { resp.context_menu(|ui| { folder_label_menu(app, ui, n); + synchronize_item(app, ui, n, &n.name); ui.separator(); if ui .button(crate::i18n::tr("Remove Disk from Library…")) @@ -1238,6 +1239,21 @@ fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) { } } +/// Synchronize Folder… of a Folders row (widget `folderSynchronize`): opens its dialog, which +/// scans the folder in the background. +fn synchronize_item(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, name: &str) { + let r = ui + .button(crate::i18n::tr("Synchronize Folder…")) + .on_hover_text(crate::i18n::tr("Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps")); + crate::widgets::register(ui.ctx(), "folderSynchronize", r.rect); + if r.clicked() { + if let Err(e) = crate::sync::open(app, &n.path, name) { + app.toast(ui.ctx(), e); + } + ui.close(); + } +} + /// Set Color Label ▸ of a Folders row (`folder.label`; the submenu is widget `folderLabelMenu`, /// its colours `folderLabel:`). fn folder_label_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode) { @@ -1289,6 +1305,7 @@ fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &F ui.close(); } folder_label_menu(app, ui, n); + synchronize_item(app, ui, n, &label); ui.separator(); if ui .button(crate::i18n::tr("Remove from Library…")) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 83b3df292..e1a754747 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -665,10 +665,33 @@ pub enum Dialog { /// A whole disk or share (`library.removeFolder` takes it only on request). disk: bool, }, + /// Synchronize Folder: what changed in a library folder on disk, and what to do about it + /// (`folder.synchronize`; the scan runs in [`crate::sync`]). + SynchronizeFolder { + path: String, + /// What the dialog calls it (a folder's last two names). + name: String, + /// What the scan found (`None` while it runs). + counts: Option, + import_new: bool, + remove_missing: bool, + read_metadata: bool, + }, About, Shortcuts, } +/// How many changes a Synchronize Folder scan found, by kind. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SyncCounts { + pub new: usize, + pub duplicates: usize, + pub unreadable: usize, + pub missing: usize, + pub metadata: usize, +} + impl Default for UiState { fn default() -> Self { UiState { diff --git a/crates/ui-egui/src/sync.rs b/crates/ui-egui/src/sync.rs new file mode 100644 index 000000000..24bad291a --- /dev/null +++ b/crates/ui-egui/src/sync.rs @@ -0,0 +1,162 @@ +//! 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`). + +use std::sync::Arc; +use std::sync::atomic::Ordering; + +use lightcraft_engine::import::ScanProgress; +use lightcraft_engine::sync::{FolderChanges, SyncChoice, SyncInput, 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, + progress: Arc, + rx: std::sync::mpsc::Receiver, +} + +/// Open the dialog for folder `path` (called `name` in it) and start scanning. +pub fn open(app: &mut LightcraftApp, path: &str, name: &str) -> Result<(), String> { + cancel(app); + let input = SyncInput::new(&mut app.session, path).map_err(|e| e.to_string())?; + let progress = Arc::new(ScanProgress::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(); + app.sync = Some(SyncTask { path: path.to_string(), progress, rx }); + let d = SyncChoice::default(); + app.ui.dialog = Some(Dialog::SynchronizeFolder { + path: path.to_string(), + name: name.to_string(), + counts: None, + import_new: d.import_new, + remove_missing: d.remove_missing, + read_metadata: d.read_metadata, + }); + Ok(()) +} + +/// Stop a running scan and let go of a finished one. +fn cancel(app: &mut LightcraftApp) { + if let Some(t) = app.sync.take() { + t.progress.cancel.store(true, Ordering::Relaxed); + } + 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.session.folder_changes.is_some() { + cancel(app); + } + return; + }; + let Some(task) = app.sync.as_ref() else { return }; + if task.path != open_for { + cancel(app); + return; + } + 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 { + new: changes.new.len(), + duplicates: changes.duplicates, + unreadable: changes.unreadable.len(), + missing: changes.missing.len(), + metadata: changes.metadata.len(), + }; + app.session.folder_changes = Some(changes); + if let Some(Dialog::SynchronizeFolder { counts, .. }) = &mut app.ui.dialog { + *counts = Some(c); + } +} + +/// How far the scan is (files probed, of how many). +fn progress(app: &LightcraftApp) -> Option<(usize, usize)> { + app.sync.as_ref().map(|t| (t.progress.done.load(Ordering::Relaxed), t.progress.total.load(Ordering::Relaxed))) +} + +/// The dialog's body. +pub fn body(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) { + let Dialog::SynchronizeFolder { path, name, counts, import_new, 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| { + ui.spinner(); + match progress(app) { + Some((done, total)) if total > 0 => ui.label(format!("{} {done} / {total}", crate::i18n::tr("Scanning…"))), + _ => ui.label(crate::i18n::tr("Scanning…")), + } + }); + 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)); + choice(ui, import_new, c.new, count("Import new photos", c.new), "syncImportNew"); + 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); + let dim = |ui: &mut egui::Ui, s: &str| ui.label(egui::RichText::new(s).color(t.text_dim)); + if c.new + c.missing + c.metadata == 0 { + dim(ui, crate::i18n::tr("The library is up to date with this folder.")); + } + 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.")); + } + 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)); + } +} + +/// Synchronize: `folder.synchronize` with the dialog's choices. +pub fn confirm(app: &mut LightcraftApp, dlg: &Dialog) -> Result { + let Dialog::SynchronizeFolder { path, counts: Some(_), import_new, remove_missing, read_metadata, .. } = dlg else { + return Err("the folder is still being scanned".into()); + }; + let r = + app.run("folder.synchronize", json!({"path": path, "importNew": import_new, "removeMissing": remove_missing, "readMetadata": read_metadata})); + app.session.folder_changes = None; + r +} diff --git a/crates/ui-egui/src/tests_sync.rs b/crates/ui-egui/src/tests_sync.rs new file mode 100644 index 000000000..bc831ca00 --- /dev/null +++ b/crates/ui-egui/src/tests_sync.rs @@ -0,0 +1,151 @@ +//! Synchronize Folder in the app (see `lightcraft_engine::sync`). +//! +//! Scenarios, in the words of someone whose folder changed outside LightCraft: +//! +//! * Right-click a folder in the sidebar's Folders section ▸ Synchronize Folder…: a dialog opens +//! at once and scans the folder without holding up the app, then says how many photos are new, +//! missing, or have metadata updates. +//! * Synchronize with the defaults imports the new photos and leaves missing ones alone. +//! * Ticking "Remove missing photos" also moves those to Recently Deleted. +//! * Cancel leaves the library as it was. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::state::Dialog; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +/// A scratch folder that goes away with the test, however it ends. +struct Scratch(std::path::PathBuf); + +impl Scratch { + fn new(tag: &str) -> Scratch { + let dir = std::env::temp_dir().join(format!("lc-ui-sync-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Scratch(dir) + } + /// The path as the folder tree writes it (forward slashes; widget ids carry it). + fn path(&self, rel: &str) -> String { + self.0.join(rel).to_string_lossy().replace('\\', "/") + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn write_png(path: &str, seed: u8) { + let (w, h) = (24usize, 16usize); + let data: Vec<[u8; 4]> = (0..w * h).map(|i| [(i % w * 9) as u8, (i / w * 13) as u8, seed, 255]).collect(); + let img = lightcraft_raster::Rgba8 { width: w, height: h, data }; + let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::create_dir_all(std::path::Path::new(path).parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); +} + +/// The app with `trip/a.png` and `trip/b.png` imported, then `c.png` added and `b.png` deleted +/// on disk behind its back. +fn changed_folder(dir: &Scratch) -> Headless { + write_png(&dir.path("trip/a.png"), 1); + write_png(&dir.path("trip/b.png"), 2); + let mut session = lightcraft_engine::Session::new().with_fs(); + session.execute("library.import", &json!({"paths": [dir.path("trip")]})).unwrap(); + write_png(&dir.path("trip/c.png"), 3); + std::fs::remove_file(dir.path("trip/b.png")).unwrap(); + let app = LightcraftApp::new(session, Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "photoGrid", "leftPanel": true}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + h +} + +fn in_library(h: &Headless) -> Vec { + let mut v: Vec = h.app.session.catalog.photos().filter(|p| p.in_library()).map(|p| p.file_name.clone()).collect(); + v.sort(); + v +} + +/// Open the dialog from the folder's menu and wait for the scan. +fn open_and_scan(h: &mut Headless, folder: &str) { + // the rows above it hold one folder each and open by themselves + let row = format!("source:libfolder:{folder}"); + assert!(h.step_until(T, |h| h.app.widgets.iter().any(|(w, _)| *w == row)), "no row {row}"); + let rect = h.app.widgets.iter().find(|(w, _)| *w == row).map(|(_, r)| *r).unwrap_or_else(|| panic!("no row {row}")); + let c = rect.center(); + let r = h.request("ui.click", json!({"x": c.x, "y": c.y, "button": "right"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "folderSynchronize"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(matches!(h.app.ui.dialog, Some(Dialog::SynchronizeFolder { .. })), "the dialog opens at once"); + let scanned = h.step_until(T, |h| matches!(&h.app.ui.dialog, Some(Dialog::SynchronizeFolder { counts: Some(_), .. }))); + assert!(scanned, "the scan finished: {:?}", h.app.ui.dialog); +} + +fn counts(h: &Headless) -> crate::state::SyncCounts { + match &h.app.ui.dialog { + Some(Dialog::SynchronizeFolder { counts: Some(c), .. }) => c.clone(), + d => panic!("no scanned dialog: {d:?}"), + } +} + +#[test] +fn the_dialog_says_what_changed_in_the_folder() { + let dir = Scratch::new("counts"); + let mut h = changed_folder(&dir); + open_and_scan(&mut h, &dir.path("trip")); + let c = counts(&h); + assert_eq!((c.new, c.missing, c.metadata), (1, 1, 0), "{c:?}"); + assert_eq!(in_library(&h), vec!["a.png", "b.png"], "scanning changed nothing"); +} + +#[test] +fn synchronizing_with_the_defaults_imports_the_new_photos_only() { + let dir = Scratch::new("defaults"); + let mut h = changed_folder(&dir); + open_and_scan(&mut h, &dir.path("trip")); + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(in_library(&h), vec!["a.png", "b.png", "c.png"], "c came in; the missing b stays"); + assert!(h.app.ui.dialog.is_none()); +} + +#[test] +fn ticking_remove_missing_also_removes_the_missing_photos() { + let dir = Scratch::new("remove"); + let mut h = changed_folder(&dir); + open_and_scan(&mut h, &dir.path("trip")); + let r = h.request("ui.clickWidget", json!({"id": "syncRemoveMissing"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(in_library(&h), vec!["a.png", "c.png"], "b went to Recently Deleted"); + let r = h.request("engine.execute", json!({"command": "edit.undo", "params": {}}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(in_library(&h), vec!["a.png", "b.png"], "one undo step"); +} + +#[test] +fn cancel_leaves_the_library_as_it_was() { + let dir = Scratch::new("cancel"); + let mut h = changed_folder(&dir); + open_and_scan(&mut h, &dir.path("trip")); + h.request("ui.key", json!({"key": "escape"}), T); + h.step(); + h.step(); + assert!(h.app.ui.dialog.is_none()); + assert_eq!(in_library(&h), vec!["a.png", "b.png"]); + assert!(h.app.session.folder_changes.is_none(), "the scan is let go"); +} From dd3f860300f3b40fa178df4bac36cb68a1f9e047 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 17:58:58 -0400 Subject: [PATCH 05/28] Docs: Synchronize Folder (parity, what's new) Co-Authored-By: Claude Opus 5.5 --- docs/parity.md | 2 +- docs/whats-new.md | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/parity.md b/docs/parity.md index ce7b6afc1..d6f9aa296 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -743,7 +743,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages, in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs | | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`) | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | -| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `crates/catalog/src/folders.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸ and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records) | +| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown and is one undo step; Esc cancels the scan) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize imports on the UI thread once confirmed (its scan does not), has no “show import dialog first” or “show missing photos” option, and can't tell a sidecar written here before auto-write was turned off from one written by another app | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | | LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 30 fields (rating, flag, label, type, edits, keywords, any text, filename, file path, format, title, caption, camera, lens, location, creator, copyright, copyright status, capture / import / edit date, ISO, aperture, focal length, megapixels, GPS, virtual copy, album) with text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`) | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | diff --git a/docs/whats-new.md b/docs/whats-new.md index 7afeb130b..386528c8f 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,13 @@ ## October 2026 +### Synchronize Folder +- Right-click a folder (or a disk other than the startup disk) in the sidebar's Folders section ▸ Synchronize + Folder…. The folder and the folders inside it are scanned in the background for photos added on disk, photos whose + file is gone, and XMP sidecars changed by other apps; then choose what to do: import the new photos (the default), + move the missing ones to Recently Deleted, read the metadata updates. Everything is one undo step, and no file on + disk is touched. Agents use `folder.scanChanges` and `folder.synchronize`. + ### Folder colour labels - Right-click a folder (or a disk other than the startup disk) in the sidebar's Folders section ▸ Set Color Label ▸ a colour or None. The row shows the label's dot before its photo count. Agents use `folder.label` (`path`, `label`); `library.folders` From 832d3c8d3dc9251574a8354cbb5784915d5efa50 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 18:06:14 -0400 Subject: [PATCH 06/28] Synchronize Folder: no threads on the web (review fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scan_with ran the import scan beside the photo checks in a thread scope on every target; on wasm32 a thread can't be spawned and Scope::spawn panics, so Synchronize Folder… crashed the web build. There the two run one after the other, like the other file work in the engine. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/sync.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs index dd2b6b9b5..3803abd81 100644 --- a/crates/engine/src/sync.rs +++ b/crates/engine/src/sync.rs @@ -137,6 +137,10 @@ impl SyncInput { /// library's photos, which run on several threads too. Stops early when `progress.cancel` is set. pub fn scan_with(input: SyncInput, progress: &ScanProgress) -> FolderChanges { let SyncInput { folder, revision, scan, photos, labels } = input; + // (no threads on the web: one after the other there) + #[cfg(target_arch = "wasm32")] + let (out, checks) = (crate::import::scan_with(scan, std::slice::from_ref(&folder), progress), check_photos(&photos, &labels, progress)); + #[cfg(not(target_arch = "wasm32"))] let (out, checks) = std::thread::scope(|sc| { let photos = &photos; let labels = &labels; From 1259506edd2ffbcab8f963f254abcc7b6a4c7a39 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 18:08:36 -0400 Subject: [PATCH 07/28] Synchronize Folder: an absent folder is offline, disks only on request (review fix) A folder that isn't there (moved whole, or on a disk that isn't connected) used to scan as "every photo missing", one tick away from moving a whole disk to Recently Deleted. The scan (on its worker, so an asleep share never stalls the UI) now reports it as offline and both commands refuse it; the dialog says so. A whole disk or a folder holding disks needs `disk: true`, as Remove Disk from Library does; the disk rows pass it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/library.rs | 2 +- crates/engine/src/cmd/sync.rs | 8 +++--- crates/engine/src/sync.rs | 32 +++++++++++++++++++---- crates/engine/src/tests_sync.rs | 39 ++++++++++++++++++++++++++++- crates/ui-egui/locales/de.json | 3 ++- crates/ui-egui/locales/es.json | 3 ++- crates/ui-egui/locales/ja.json | 3 ++- crates/ui-egui/locales/pt-br.json | 3 ++- crates/ui-egui/locales/ru.json | 3 ++- crates/ui-egui/locales/zh-hans.json | 3 ++- crates/ui-egui/locales/zh-hant.json | 3 ++- crates/ui-egui/src/panels/left.rs | 3 ++- crates/ui-egui/src/state.rs | 6 +++++ crates/ui-egui/src/sync.rs | 20 ++++++++++----- 14 files changed, 106 insertions(+), 25 deletions(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 569716596..4732bab7b 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -1323,7 +1323,7 @@ fn merge_rules(base: &lightcraft_catalog::Filter, patch: &Value, c: &str) -> Res /// Whether `path` lies above a disk: some of its `ids` photos are on a disk (not the startup /// disk) that the folder does not lie in. Such a folder (`/Volumes`, `/mnt`…) is not a folder of /// one disk but a way to reach several. -fn covers_other_disks(s: &Session, path: &str, ids: &[PhotoId]) -> bool { +pub(crate) fn covers_other_disks(s: &Session, path: &str, ids: &[PhotoId]) -> bool { ids.iter() .filter_map(|id| s.catalog.photo(*id)) .filter_map(|p| lightcraft_catalog::folders::volume_of(p)) diff --git a/crates/engine/src/cmd/sync.rs b/crates/engine/src/cmd/sync.rs index ecb252df6..31c0af6b2 100644 --- a/crates/engine/src/cmd/sync.rs +++ b/crates/engine/src/cmd/sync.rs @@ -17,12 +17,12 @@ pub fn specs() -> Vec { "Scan Folder for Changes", [], None, - "{path} — compare a folder of the library (and the folders inside it) with the disk; changes nothing → {path, new: [{path, name, format, …}] files the library doesn't have, duplicates: files whose content it already has, unreadable: [{path, error}], missing: [{id, path}] photos whose file is gone, metadata: [{id, path}] photos whose XMP sidecar was saved after they came in or were last edited here and says something the library doesn't}", + "{path, disk?: bool (a whole disk or share, or a folder holding disks)} — compare a folder of the library (and the folders inside it) with the disk; changes nothing → {path, new: [{path, name, format, …}] files the library doesn't have, duplicates: files whose content it already has, unreadable: [{path, error}], missing: [{id, path}] photos whose file is gone, metadata: [{id, path}] photos whose XMP sidecar was saved after they came in or were last edited here and says something the library doesn't}", always, |s, p| { const C: &str = "folder.scanChanges"; let path = path(p, C)?; - let changes = scan(s, &path).map_err(|e| bad(C, e.to_string()))?; + let changes = scan(s, &path, bool_or(p, "disk", false)).map_err(|e| bad(C, e.to_string()))?; let v = serde_json::to_value(&changes).unwrap_or_default(); s.folder_changes = Some(changes); Ok(v) @@ -33,7 +33,7 @@ pub fn specs() -> Vec { "Synchronize Folder", [], None, - "{path, importNew?: true, removeMissing?: false, readMetadata?: false} — bring a folder of the library up to date with the disk (see folder.scanChanges): import its new files in place, move photos whose file is gone to Recently Deleted, read XMP sidecars saved by other apps (the sidecar wins); acts on the last folder.scanChanges of the folder while nothing changed in the library since (else scans again); one undo step, no file is touched → {imported, removed, read, failed: [[path, error]]}", + "{path, disk?: bool, importNew?: true, removeMissing?: false, readMetadata?: false} — bring a folder of the library up to date with the disk (see folder.scanChanges): import its new files in place, move photos whose file is gone to Recently Deleted, read XMP sidecars saved by other apps (the sidecar wins); acts on the last folder.scanChanges of the folder while nothing changed in the library since (else scans again); one undo step, no file is touched → {imported, removed, read, failed: [[path, error]]}", always, sync ), @@ -51,7 +51,7 @@ fn sync(s: &mut Session, p: &Value) -> Result { }; let changes = match s.take_folder_changes(&path) { Some(c) => c, - None => scan(s, &path).map_err(|e| bad(C, e.to_string()))?, + None => scan(s, &path, bool_or(p, "disk", false)).map_err(|e| bad(C, e.to_string()))?, }; let r = synchronize(s, changes, choice)?; Ok(json!({"imported": r.imported, "removed": r.removed, "read": r.read, "failed": r.failed})) diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs index 3803abd81..95b4951c9 100644 --- a/crates/engine/src/sync.rs +++ b/crates/engine/src/sync.rs @@ -65,6 +65,9 @@ pub struct FolderChanges { pub missing: Vec, /// Photos whose sidecar has news. pub metadata: Vec, + /// The folder itself is not there (moved or renamed whole, or on a disk that isn't + /// connected): nothing else is reported, rather than every photo as missing. + pub offline: bool, /// The probes of the new files, kept for the import that follows. #[serde(skip)] probes: HashMap, @@ -110,8 +113,10 @@ fn invalid(msg: impl Into) -> EngineError { } impl SyncInput { - /// The scan of folder `path`: a folder the library holds photos in, not the startup disk. - pub fn new(s: &mut Session, path: &str) -> Result { + /// The scan of folder `path`: a folder the library holds photos in, not the startup disk. A + /// whole disk or share, or a folder that holds disks (`/Volumes`, `/mnt`…), only with `disk` + /// (as for Remove Disk from Library: its missing photos can be a whole disk's). + pub fn new(s: &mut Session, path: &str, disk: bool) -> Result { let path = path.trim(); if path.is_empty() { return Err(invalid("missing `path`")); @@ -124,6 +129,9 @@ impl SyncInput { if ids.is_empty() { return Err(invalid(format!("{path}: no photo in the library was imported from it"))); } + if !disk && (lightcraft_catalog::folders::is_disk_root(path) || crate::cmd::library::covers_other_disks(s, path, &ids)) { + return Err(invalid(format!("{path} is a whole disk or holds disks: pass `disk: true` to synchronize it"))); + } let photos = ids.iter().filter_map(|id| s.catalog.photo(*id).map(|p| (Arc::clone(p), s.sidecar_naming(*id)))).collect(); let (scan, _) = ScanInput::new(s, &[path.to_string()]); Ok(SyncInput { folder: path.to_string(), revision: s.catalog.revision, scan, photos, labels: s.catalog.clone() }) @@ -137,6 +145,9 @@ impl SyncInput { /// library's photos, which run on several threads too. Stops early when `progress.cancel` is set. pub fn scan_with(input: SyncInput, progress: &ScanProgress) -> FolderChanges { let SyncInput { folder, revision, scan, photos, labels } = input; + if !cfg!(target_arch = "wasm32") && !Path::new(&folder).is_dir() { + return FolderChanges { path: folder, revision, offline: true, ..Default::default() }; + } // (no threads on the web: one after the other there) #[cfg(target_arch = "wasm32")] let (out, checks) = (crate::import::scan_with(scan, std::slice::from_ref(&folder), progress), check_photos(&photos, &labels, progress)); @@ -224,9 +235,17 @@ fn check_photos(photos: &[(Arc, SidecarNaming)], labels: &Catalog, progre } /// [`scan_with`] on the calling thread. -pub fn scan(s: &mut Session, path: &str) -> Result { - let input = SyncInput::new(s, path)?; - Ok(scan_with(input, &ScanProgress::default())) +pub fn scan(s: &mut Session, path: &str, disk: bool) -> Result { + let input = SyncInput::new(s, path, disk)?; + let changes = scan_with(input, &ScanProgress::default()); + if changes.offline { + return Err(not_there(&changes.path)); + } + Ok(changes) +} + +fn not_there(path: &str) -> EngineError { + invalid(format!("{path} is not there (moved, renamed, or on a disk that isn't connected)")) } impl Session { @@ -265,6 +284,9 @@ fn sidecar_has_news(labels: &Catalog, p: &Photo, naming: SidecarNaming) -> bool /// Act on `changes` as `choice` says, as one undo step ("Synchronize Folder"). A missing photo /// whose file is back by now is left alone. pub fn synchronize(s: &mut Session, changes: FolderChanges, choice: SyncChoice) -> Result { + if changes.offline { + return Err(not_there(&changes.path)); + } let mark = s.undo.len(); let mut report = SyncReport::default(); let FolderChanges { new, missing, metadata, probes, .. } = changes; diff --git a/crates/engine/src/tests_sync.rs b/crates/engine/src/tests_sync.rs index 3cda16b36..58055a70e 100644 --- a/crates/engine/src/tests_sync.rs +++ b/crates/engine/src/tests_sync.rs @@ -243,7 +243,7 @@ fn a_cancelled_scan_stops_checking_files() { let dir = Scratch::new("cancel"); let mut s = library(&dir); std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap(); - let input = crate::sync::SyncInput::new(&mut s, &dir.path("trip")).unwrap(); + let input = crate::sync::SyncInput::new(&mut s, &dir.path("trip"), false).unwrap(); let progress = crate::import::ScanProgress::default(); progress.cancel.store(true, std::sync::atomic::Ordering::Relaxed); let c = crate::sync::scan_with(input, &progress); @@ -270,3 +270,40 @@ fn many_photos_are_checked_in_parallel_with_the_same_answer() { assert_eq!(paths(&r, "missing"), expect_missing); assert_eq!(paths(&r, "metadata"), expect_meta); } + +#[test] +fn a_folder_that_is_not_there_is_not_scanned_as_if_everything_went_missing() { + let dir = Scratch::new("offline"); + let mut s = library(&dir); + // an unplugged disk, or a folder moved away whole + std::fs::rename(dir.0.join("trip"), dir.0.join("trip-elsewhere")).unwrap(); + for cmd in ["folder.scanChanges", "folder.synchronize"] { + let e = s.execute(cmd, &json!({"path": dir.path("trip"), "removeMissing": true})).unwrap_err().to_string(); + assert!(e.contains("not there"), "{cmd}: {e}"); + } + assert_eq!(s.catalog.photos().filter(|p| p.in_library()).count(), 2, "nothing was removed"); +} + +#[test] +fn a_whole_disk_or_a_folder_holding_disks_is_synchronized_only_on_request() { + let dir = Scratch::new("disks"); + let mut s = library(&dir); + // a library photo on another disk makes the scratch folder's root look like it holds disks + let id = s.catalog.alloc_photo_id(); + let p = lightcraft_catalog::Photo::new( + id, + lightcraft_catalog::Source::File { path: "/Volumes/nas/x.jpg".into() }, + "x.jpg", + "JPEG", + 6, + 4, + "2026-01-01", + ); + s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap(); + if std::path::Path::new("/Volumes").is_dir() { + let e = s.execute("folder.scanChanges", &json!({"path": "/Volumes"})).unwrap_err().to_string(); + assert!(e.contains("disk: true"), "{e}"); + } + // a folder inside one disk needs nothing more + assert!(s.execute("folder.scanChanges", &json!({"path": dir.path("trip")})).is_ok()); +} diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index e747ad2f2..31525843f 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1903,5 +1903,6 @@ "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Fehlende Fotos werden nach „Zuletzt gelöscht“ verschoben und können wiederhergestellt werden; keine Datei auf dem Datenträger wird verändert.", "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Beim Lesen einer Filialdatei werden Bewertung, Markierungen, Metadaten und Bearbeitungen des Fotos durch ihren Inhalt ersetzt.", "Skipped: files already in the library elsewhere": "Übersprungen: Dateien, die bereits an anderer Stelle in der Bibliothek sind", - "Skipped: files that can't be read": "Übersprungen: Dateien, die nicht gelesen werden können" + "Skipped: files that can't be read": "Übersprungen: Dateien, die nicht gelesen werden können", + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Dieser Ordner ist nicht vorhanden: Er wurde verschoben oder umbenannt, oder sein Datenträger ist nicht verbunden." } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 0b29f570b..b6adc732d 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1748,5 +1748,6 @@ "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Las fotos que faltan pasan a Eliminadas recientemente y se pueden restaurar; no se toca ningún archivo del disco.", "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Leer un archivo complementario sustituye la valoración, las etiquetas, los metadatos y las ediciones de la foto por lo que contiene.", "Skipped: files already in the library elsewhere": "Omitidos: archivos que ya están en la biblioteca en otro lugar", - "Skipped: files that can't be read": "Omitidos: archivos que no se pueden leer" + "Skipped: files that can't be read": "Omitidos: archivos que no se pueden leer", + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Esta carpeta no está: se movió o se le cambió el nombre, o su disco no está conectado." } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index ae2023ff7..242a687a6 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1748,5 +1748,6 @@ "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "見つからない写真は「最近削除した写真」に移動し、復元できます。ディスク上のファイルには手を加えません", "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "サイドカーを読み込むと、写真のレーティング、ラベル、メタデータ、編集内容がその内容に置き換わります。", "Skipped: files already in the library elsewhere": "スキップ: 別の場所ですでにライブラリにあるファイル", - "Skipped: files that can't be read": "スキップ: 読み込めないファイル" + "Skipped: files that can't be read": "スキップ: 読み込めないファイル", + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "このフォルダが見つかりません。移動または名前の変更がされたか、ディスクが接続されていません。" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 71369387f..00f720119 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1748,5 +1748,6 @@ "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "As fotos ausentes vão para Excluídos recentemente e podem ser restauradas; nenhum arquivo no disco é alterado.", "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Ler um arquivo auxiliar substitui a classificação, os rótulos, os metadados e as edições da foto pelo que ele contém.", "Skipped: files already in the library elsewhere": "Ignorados: arquivos que já estão na biblioteca em outro lugar", - "Skipped: files that can't be read": "Ignorados: arquivos que não podem ser lidos" + "Skipped: files that can't be read": "Ignorados: arquivos que não podem ser lidos", + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Esta pasta não está lá: foi movida ou renomeada, ou o disco dela não está conectado." } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index dc2b35756..16c6f7cdf 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1421,5 +1421,6 @@ "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Отсутствующие фото перемещаются в «Недавно удалённые» и могут быть восстановлены; файлы на диске не затрагиваются.", "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Чтение файла XMP заменяет рейтинг, метки, метаданные и правки фото его содержимым.", "Skipped: files already in the library elsewhere": "Пропущено: файлы, которые уже есть в медиатеке в другом месте", - "Skipped: files that can't be read": "Пропущено: файлы, которые не удаётся прочитать" + "Skipped: files that can't be read": "Пропущено: файлы, которые не удаётся прочитать", + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Этой папки нет: её переместили или переименовали, или её диск не подключён." } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 2123391d6..0206938d5 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1748,5 +1748,6 @@ "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "缺失的照片会移到“最近删除”,可以恢复;磁盘上的文件不会被改动。", "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "读取附属文件会用其内容替换照片的评级、标签、元数据和编辑。", "Skipped: files already in the library elsewhere": "已跳过:已在图库其他位置的文件", - "Skipped: files that can't be read": "已跳过:无法读取的文件" + "Skipped: files that can't be read": "已跳过:无法读取的文件", + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "此文件夹不存在:它已被移动或重命名,或其磁盘未连接。" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 8fa254cdf..ffc873730 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1748,5 +1748,6 @@ "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "遺失的照片會移至「最近刪除」,可以還原;磁碟上的檔案不會被更動。", "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "讀取附屬檔案會以其內容取代照片的評等、標籤、中繼資料和編輯。", "Skipped: files already in the library elsewhere": "已略過:已在照片庫其他位置的檔案", - "Skipped: files that can't be read": "已略過:無法讀取的檔案" + "Skipped: files that can't be read": "已略過:無法讀取的檔案", + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "此資料夾不存在:它已被移動或重新命名,或其磁碟未連接。" } diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 3c0a0c216..dff42c1e0 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -1247,7 +1247,8 @@ fn synchronize_item(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, .on_hover_text(crate::i18n::tr("Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps")); crate::widgets::register(ui.ctx(), "folderSynchronize", r.rect); if r.clicked() { - if let Err(e) = crate::sync::open(app, &n.path, name) { + // a disk, or a folder holding disks, was chosen as such + if let Err(e) = crate::sync::open(app, &n.path, name, n.volume || !n.selectable) { app.toast(ui.ctx(), e); } ui.close(); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index e1a754747..0857263df 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -671,6 +671,9 @@ pub enum Dialog { path: String, /// What the dialog calls it (a folder's last two names). name: String, + /// A whole disk, or a folder holding disks (`folder.synchronize` `disk`). + #[serde(default)] + disk: bool, /// What the scan found (`None` while it runs). counts: Option, import_new: bool, @@ -685,6 +688,9 @@ pub enum Dialog { #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct SyncCounts { + /// The folder isn't there (moved, renamed, or on a disk that isn't connected). + #[serde(default)] + pub offline: bool, pub new: usize, pub duplicates: usize, pub unreadable: usize, diff --git a/crates/ui-egui/src/sync.rs b/crates/ui-egui/src/sync.rs index 24bad291a..26c1ed680 100644 --- a/crates/ui-egui/src/sync.rs +++ b/crates/ui-egui/src/sync.rs @@ -22,9 +22,9 @@ pub struct SyncTask { } /// Open the dialog for folder `path` (called `name` in it) and start scanning. -pub fn open(app: &mut LightcraftApp, path: &str, name: &str) -> Result<(), String> { +pub fn open(app: &mut LightcraftApp, path: &str, name: &str, disk: bool) -> Result<(), String> { cancel(app); - let input = SyncInput::new(&mut app.session, path).map_err(|e| e.to_string())?; + let input = SyncInput::new(&mut app.session, path, disk).map_err(|e| e.to_string())?; let progress = Arc::new(ScanProgress::default()); let (tx, rx) = std::sync::mpsc::channel(); let p = progress.clone(); @@ -40,6 +40,7 @@ pub fn open(app: &mut LightcraftApp, path: &str, name: &str) -> Result<(), Strin app.ui.dialog = Some(Dialog::SynchronizeFolder { path: path.to_string(), name: name.to_string(), + disk, counts: None, import_new: d.import_new, remove_missing: d.remove_missing, @@ -87,6 +88,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { }; app.sync = None; let c = SyncCounts { + offline: changes.offline, new: changes.new.len(), duplicates: changes.duplicates, unreadable: changes.unreadable.len(), @@ -106,7 +108,7 @@ fn progress(app: &LightcraftApp) -> Option<(usize, usize)> { /// The dialog's body. pub fn body(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) { - let Dialog::SynchronizeFolder { path, name, counts, import_new, remove_missing, read_metadata } = dlg else { return }; + let Dialog::SynchronizeFolder { path, name, counts, import_new, 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:")); @@ -123,6 +125,10 @@ pub fn body(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) { }); 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); @@ -152,11 +158,13 @@ pub fn body(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) { /// Synchronize: `folder.synchronize` with the dialog's choices. pub fn confirm(app: &mut LightcraftApp, dlg: &Dialog) -> Result { - let Dialog::SynchronizeFolder { path, counts: Some(_), import_new, remove_missing, read_metadata, .. } = dlg else { + let Dialog::SynchronizeFolder { path, disk, counts: Some(_), import_new, remove_missing, read_metadata, .. } = dlg else { return Err("the folder is still being scanned".into()); }; - let r = - app.run("folder.synchronize", json!({"path": path, "importNew": import_new, "removeMissing": remove_missing, "readMetadata": read_metadata})); + let r = app.run( + "folder.synchronize", + json!({"path": path, "disk": disk, "importNew": import_new, "removeMissing": remove_missing, "readMetadata": read_metadata}), + ); app.session.folder_changes = None; r } From 5c7b655fe66fb1f6e79bf26e950f9b871a2f159c Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 18:10:48 -0400 Subject: [PATCH 08/28] Synchronize Folder: Local files are new; moved files are relinked (review fixes) A file only browsed in Local was counted as already in the library and silently dropped; it is now new, and synchronizing brings it into the library, as an import does. A photo whose file was renamed or moved within the folder showed as missing while its file counted as a skipped duplicate, so "Remove missing" sent the photo and its edits to Recently Deleted and imported nothing. The scan now matches a missing photo's content hash among the folder's files and lists it as moved; synchronizing relinks it (on by default, "Relink photos whose file was renamed or moved"), in the same undo step. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/missing.rs | 2 +- crates/engine/src/cmd/sync.rs | 7 +-- crates/engine/src/sync.rs | 77 ++++++++++++++++++++++++----- crates/engine/src/tests_sync.rs | 35 +++++++++++++ crates/ui-egui/locales/de.json | 3 +- crates/ui-egui/locales/es.json | 3 +- crates/ui-egui/locales/ja.json | 3 +- crates/ui-egui/locales/pt-br.json | 3 +- crates/ui-egui/locales/ru.json | 3 +- crates/ui-egui/locales/zh-hans.json | 3 +- crates/ui-egui/locales/zh-hant.json | 3 +- crates/ui-egui/src/state.rs | 4 ++ crates/ui-egui/src/sync.rs | 11 +++-- 13 files changed, 130 insertions(+), 27 deletions(-) diff --git a/crates/engine/src/cmd/missing.rs b/crates/engine/src/cmd/missing.rs index 3d5fb49a3..6f194c652 100644 --- a/crates/engine/src/cmd/missing.rs +++ b/crates/engine/src/cmd/missing.rs @@ -51,7 +51,7 @@ pub fn is_missing(cat: &Catalog, avail: &crate::availability::Availability, id: !cfg!(target_arch = "wasm32") && cat.photo(id).and_then(|p| checked_path(p)).is_some_and(|f| avail.is_offline(f)) } -fn relink_op(id: PhotoId, path: &str) -> Op { +pub(crate) fn relink_op(id: PhotoId, path: &str) -> Op { let file_name = Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); Op::Relink { id, file_name, source: Source::File { path: path.to_string() }, format: None } } diff --git a/crates/engine/src/cmd/sync.rs b/crates/engine/src/cmd/sync.rs index 31c0af6b2..505377a7e 100644 --- a/crates/engine/src/cmd/sync.rs +++ b/crates/engine/src/cmd/sync.rs @@ -17,7 +17,7 @@ pub fn specs() -> Vec { "Scan Folder for Changes", [], None, - "{path, disk?: bool (a whole disk or share, or a folder holding disks)} — compare a folder of the library (and the folders inside it) with the disk; changes nothing → {path, new: [{path, name, format, …}] files the library doesn't have, duplicates: files whose content it already has, unreadable: [{path, error}], missing: [{id, path}] photos whose file is gone, metadata: [{id, path}] photos whose XMP sidecar was saved after they came in or were last edited here and says something the library doesn't}", + "{path, disk?: bool (a whole disk or share, or a folder holding disks)} — compare a folder of the library (and the folders inside it) with the disk; changes nothing → {path, new: [{path, name, format, …}] files the library doesn't have, duplicates: files whose content it already has, unreadable: [{path, error}], missing: [{id, path}] photos whose file is gone, moved: [{id, from, to}] photos whose file is elsewhere in the folder (same content), metadata: [{id, path}] photos whose XMP sidecar was saved after they came in or were last edited here and says something the library doesn't}", always, |s, p| { const C: &str = "folder.scanChanges"; @@ -33,7 +33,7 @@ pub fn specs() -> Vec { "Synchronize Folder", [], None, - "{path, disk?: bool, importNew?: true, removeMissing?: false, readMetadata?: false} — bring a folder of the library up to date with the disk (see folder.scanChanges): import its new files in place, move photos whose file is gone to Recently Deleted, read XMP sidecars saved by other apps (the sidecar wins); acts on the last folder.scanChanges of the folder while nothing changed in the library since (else scans again); one undo step, no file is touched → {imported, removed, read, failed: [[path, error]]}", + "{path, disk?: bool, importNew?: true, relinkMoved?: true, removeMissing?: false, readMetadata?: false} — bring a folder of the library up to date with the disk (see folder.scanChanges): import its new files in place, relink photos whose file was renamed or moved within it, move photos whose file is gone to Recently Deleted, read XMP sidecars saved by other apps (the sidecar wins); acts on the last folder.scanChanges of the folder while nothing changed in the library since (else scans again); one undo step, no file is touched → {imported, relinked, removed, read, failed: [[path, error]]}", always, sync ), @@ -46,6 +46,7 @@ fn sync(s: &mut Session, p: &Value) -> Result { let d = SyncChoice::default(); let choice = SyncChoice { import_new: bool_or(p, "importNew", d.import_new), + relink_moved: bool_or(p, "relinkMoved", d.relink_moved), remove_missing: bool_or(p, "removeMissing", d.remove_missing), read_metadata: bool_or(p, "readMetadata", d.read_metadata), }; @@ -54,5 +55,5 @@ fn sync(s: &mut Session, p: &Value) -> Result { None => scan(s, &path, bool_or(p, "disk", false)).map_err(|e| bad(C, e.to_string()))?, }; let r = synchronize(s, changes, choice)?; - Ok(json!({"imported": r.imported, "removed": r.removed, "read": r.read, "failed": r.failed})) + Ok(json!({"imported": r.imported, "relinked": r.relinked, "removed": r.removed, "read": r.read, "failed": r.failed})) } diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs index 95b4951c9..602296049 100644 --- a/crates/engine/src/sync.rs +++ b/crates/engine/src/sync.rs @@ -65,6 +65,8 @@ pub struct FolderChanges { pub missing: Vec, /// Photos whose sidecar has news. pub metadata: Vec, + /// Photos whose file was renamed or moved within the folder (found by its content). + pub moved: Vec, /// The folder itself is not there (moved or renamed whole, or on a disk that isn't /// connected): nothing else is reported, rather than every photo as missing. pub offline: bool, @@ -83,18 +85,28 @@ pub struct FolderPhoto { pub path: String, } +/// A photo whose file is now somewhere else in the folder. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct FolderMoved { + pub id: u64, + pub from: String, + pub to: String, +} + /// What [`synchronize`] does with the changes found. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct SyncChoice { pub import_new: bool, + pub relink_moved: bool, pub remove_missing: bool, pub read_metadata: bool, } impl Default for SyncChoice { - /// Lightroom Classic's defaults: import the new files, leave the rest to be looked at. + /// Lightroom Classic's defaults: import the new files, leave the rest to be looked at; and + /// photos whose file moved follow it (nothing is lost, and undo puts them back). fn default() -> Self { - SyncChoice { import_new: true, remove_missing: false, read_metadata: false } + SyncChoice { import_new: true, relink_moved: true, remove_missing: false, read_metadata: false } } } @@ -102,6 +114,7 @@ impl Default for SyncChoice { #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] pub struct SyncReport { pub imported: usize, + pub relinked: usize, pub removed: usize, pub read: usize, /// (path or photo, why) for what couldn't be done. @@ -160,15 +173,7 @@ pub fn scan_with(input: SyncInput, progress: &ScanProgress) -> FolderChanges { // (a check that panicked counts as nothing found; the scan still reports the rest) (out, checks.join().unwrap_or_default()) }); - let mut changes = FolderChanges { path: folder, probes: out.probes, revision, ..Default::default() }; - for c in out.candidates { - match (&c.duplicate, &c.error) { - (Some(d), _) if d == "path" => {} - (Some(_), _) => changes.duplicates += 1, - (None, Some(_)) => changes.unreadable.push(c), - (None, None) => changes.new.push(c), - } - } + let mut changes = FolderChanges { path: folder, revision, ..Default::default() }; for ((p, _), check) in photos.iter().zip(checks) { let Some(file) = crate::cmd::missing::checked_path(p) else { continue }; let found = FolderPhoto { id: p.id.0, path: file.to_string() }; @@ -178,6 +183,42 @@ pub fn scan_with(input: SyncInput, progress: &ScanProgress) -> FolderChanges { Check::Nothing => {} } } + // a missing photo's content found under another name in the folder: the file was renamed or + // moved, and the photo (with its edits) follows it rather than going missing + let mut gone_by_hash: HashMap<&str, &str> = HashMap::new(); + for m in &changes.missing { + if let Some(h) = labels.photo(PhotoId(m.id)).and_then(|p| p.content_hash.as_deref()) { + gone_by_hash.entry(h).or_insert(m.path.as_str()); + } + } + let mut moved_from: HashMap = HashMap::new(); + for c in out.candidates { + let hash = out.probes.get(&c.path).and_then(|i| i.content_hash.as_deref()); + if c.error.is_none() + && c.duplicate.as_deref() != Some("path") + && let Some(from) = hash.and_then(|h| gone_by_hash.remove(h)) + { + moved_from.insert(from.to_string(), c.path.clone()); + continue; + } + let browsed = c.existing.and_then(|id| labels.photo(PhotoId(id))).is_some_and(|p| p.local && !p.deleted); + match (c.duplicate.as_deref(), &c.error) { + // a file only looked at in Local is not in the library yet: it is new + (Some("path"), _) if browsed => changes.new.push(c), + (Some("path"), _) => {} + (Some(_), _) => changes.duplicates += 1, + (None, Some(_)) => changes.unreadable.push(c), + (None, None) => changes.new.push(c), + } + } + let missing = std::mem::take(&mut changes.missing); + for m in missing { + match moved_from.get(&m.path) { + Some(to) => changes.moved.push(FolderMoved { id: m.id, from: m.path, to: to.clone() }), + None => changes.missing.push(m), + } + } + changes.probes = out.probes; changes } @@ -289,7 +330,7 @@ pub fn synchronize(s: &mut Session, changes: FolderChanges, choice: SyncChoice) } let mark = s.undo.len(); let mut report = SyncReport::default(); - let FolderChanges { new, missing, metadata, probes, .. } = changes; + let FolderChanges { new, missing, metadata, moved, probes, .. } = changes; if choice.import_new && !new.is_empty() { s.import_probes = probes; let files: Vec = new.into_iter().map(|c| c.path).collect(); @@ -297,6 +338,18 @@ pub fn synchronize(s: &mut Session, changes: FolderChanges, choice: SyncChoice) report.imported = r.imported.len(); report.failed.extend(r.failed); } + if choice.relink_moved { + // still where the scan found it, and still gone from where the photo says + let ops: Vec = moved + .iter() + .filter(|m| Path::new(&m.to).is_file() && !Path::new(&m.from).exists() && s.catalog.photo(PhotoId(m.id)).is_some()) + .map(|m| crate::cmd::missing::relink_op(PhotoId(m.id), &m.to)) + .collect(); + if !ops.is_empty() { + report.relinked = ops.len(); + s.commit("Relink Moved Photos", Op::Batch { ops })?; + } + } if choice.remove_missing { let gone: Vec = missing.iter().filter(|m| !Path::new(&m.path).exists()).map(|m| PhotoId(m.id)).collect(); let ops: Vec = gone diff --git a/crates/engine/src/tests_sync.rs b/crates/engine/src/tests_sync.rs index 58055a70e..daebda739 100644 --- a/crates/engine/src/tests_sync.rs +++ b/crates/engine/src/tests_sync.rs @@ -307,3 +307,38 @@ fn a_whole_disk_or_a_folder_holding_disks_is_synchronized_only_on_request() { // a folder inside one disk needs nothing more assert!(s.execute("folder.scanChanges", &json!({"path": dir.path("trip")})).is_ok()); } + +#[test] +fn a_file_only_browsed_in_local_is_new_and_joins_the_library() { + let dir = Scratch::new("local"); + let mut s = library(&dir); + write_png(&dir.path("trip/c.png"), 3); + // looking at the folder in Local catalogues c.png as a browse record, not a library photo + s.execute("library.browse", &json!({"path": dir.path("trip")})).unwrap(); + assert!(s.catalog.photos().any(|p| p.file_name == "c.png" && p.local)); + let r = scan(&mut s, &dir.path("trip")); + assert_eq!(paths(&r, "new"), vec![dir.path("trip/c.png")], "{r}"); + let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap(); + assert_eq!(r["imported"], 1, "{r}"); + assert!(s.catalog.photos().any(|p| p.file_name == "c.png" && p.in_library())); +} + +#[test] +fn a_file_renamed_or_moved_inside_the_folder_is_relinked_not_lost() { + let dir = Scratch::new("moved"); + let mut s = library(&dir); + let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id; + s.execute("photo.rate", &json!({"ids": [a.0], "rating": 4})).unwrap(); + std::fs::rename(dir.path("trip/a.png"), dir.path("trip/day1/a-renamed.png")).unwrap(); + let r = scan(&mut s, &dir.path("trip")); + assert!(paths(&r, "missing").is_empty() && paths(&r, "new").is_empty(), "{r}"); + assert_eq!(r["moved"][0]["id"], a.0, "{r}"); + assert_eq!(r["moved"][0]["to"], dir.path("trip/day1/a-renamed.png"), "{r}"); + let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "removeMissing": true})).unwrap(); + assert_eq!((r["relinked"].as_u64(), r["removed"].as_u64(), r["imported"].as_u64()), (Some(1), Some(0), Some(0)), "{r}"); + let p = s.catalog.photo(a).unwrap(); + assert!(p.in_library() && p.rating == 4, "the same photo, edits and all"); + assert_eq!(p.source, lightcraft_catalog::Source::File { path: dir.path("trip/day1/a-renamed.png") }); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.photo(a).unwrap().source, lightcraft_catalog::Source::File { path: dir.path("trip/a.png") }); +} diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 31525843f..baadd1afa 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1904,5 +1904,6 @@ "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Beim Lesen einer Filialdatei werden Bewertung, Markierungen, Metadaten und Bearbeitungen des Fotos durch ihren Inhalt ersetzt.", "Skipped: files already in the library elsewhere": "Übersprungen: Dateien, die bereits an anderer Stelle in der Bibliothek sind", "Skipped: files that can't be read": "Übersprungen: Dateien, die nicht gelesen werden können", - "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Dieser Ordner ist nicht vorhanden: Er wurde verschoben oder umbenannt, oder sein Datenträger ist nicht verbunden." + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Dieser Ordner ist nicht vorhanden: Er wurde verschoben oder umbenannt, oder sein Datenträger ist nicht verbunden.", + "Relink photos whose file was renamed or moved": "Fotos neu verknüpfen, deren Datei umbenannt oder verschoben wurde" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index b6adc732d..037e0644b 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1749,5 +1749,6 @@ "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Leer un archivo complementario sustituye la valoración, las etiquetas, los metadatos y las ediciones de la foto por lo que contiene.", "Skipped: files already in the library elsewhere": "Omitidos: archivos que ya están en la biblioteca en otro lugar", "Skipped: files that can't be read": "Omitidos: archivos que no se pueden leer", - "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Esta carpeta no está: se movió o se le cambió el nombre, o su disco no está conectado." + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Esta carpeta no está: se movió o se le cambió el nombre, o su disco no está conectado.", + "Relink photos whose file was renamed or moved": "Volver a vincular fotos cuyo archivo se renombró o movió" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 242a687a6..dfd14f8ca 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1749,5 +1749,6 @@ "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "サイドカーを読み込むと、写真のレーティング、ラベル、メタデータ、編集内容がその内容に置き換わります。", "Skipped: files already in the library elsewhere": "スキップ: 別の場所ですでにライブラリにあるファイル", "Skipped: files that can't be read": "スキップ: 読み込めないファイル", - "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "このフォルダが見つかりません。移動または名前の変更がされたか、ディスクが接続されていません。" + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "このフォルダが見つかりません。移動または名前の変更がされたか、ディスクが接続されていません。", + "Relink photos whose file was renamed or moved": "ファイル名の変更や移動があった写真をリンクし直す" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 00f720119..7c7769ad3 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1749,5 +1749,6 @@ "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Ler um arquivo auxiliar substitui a classificação, os rótulos, os metadados e as edições da foto pelo que ele contém.", "Skipped: files already in the library elsewhere": "Ignorados: arquivos que já estão na biblioteca em outro lugar", "Skipped: files that can't be read": "Ignorados: arquivos que não podem ser lidos", - "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Esta pasta não está lá: foi movida ou renomeada, ou o disco dela não está conectado." + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Esta pasta não está lá: foi movida ou renomeada, ou o disco dela não está conectado.", + "Relink photos whose file was renamed or moved": "Vincular de novo fotos cujo arquivo foi renomeado ou movido" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 16c6f7cdf..8bcab8f60 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1422,5 +1422,6 @@ "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Чтение файла XMP заменяет рейтинг, метки, метаданные и правки фото его содержимым.", "Skipped: files already in the library elsewhere": "Пропущено: файлы, которые уже есть в медиатеке в другом месте", "Skipped: files that can't be read": "Пропущено: файлы, которые не удаётся прочитать", - "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Этой папки нет: её переместили или переименовали, или её диск не подключён." + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Этой папки нет: её переместили или переименовали, или её диск не подключён.", + "Relink photos whose file was renamed or moved": "Заново связать фото, файл которых переименован или перемещён" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 0206938d5..ef6ab58c3 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1749,5 +1749,6 @@ "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "读取附属文件会用其内容替换照片的评级、标签、元数据和编辑。", "Skipped: files already in the library elsewhere": "已跳过:已在图库其他位置的文件", "Skipped: files that can't be read": "已跳过:无法读取的文件", - "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "此文件夹不存在:它已被移动或重命名,或其磁盘未连接。" + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "此文件夹不存在:它已被移动或重命名,或其磁盘未连接。", + "Relink photos whose file was renamed or moved": "重新链接文件被重命名或移动的照片" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index ffc873730..a59bc8fb7 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1749,5 +1749,6 @@ "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "讀取附屬檔案會以其內容取代照片的評等、標籤、中繼資料和編輯。", "Skipped: files already in the library elsewhere": "已略過:已在照片庫其他位置的檔案", "Skipped: files that can't be read": "已略過:無法讀取的檔案", - "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "此資料夾不存在:它已被移動或重新命名,或其磁碟未連接。" + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "此資料夾不存在:它已被移動或重新命名,或其磁碟未連接。", + "Relink photos whose file was renamed or moved": "重新連結檔案被重新命名或移動的照片" } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 0857263df..d08402ba4 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -677,6 +677,8 @@ pub enum Dialog { /// What the scan found (`None` while it runs). counts: Option, import_new: bool, + #[serde(default)] + relink_moved: bool, remove_missing: bool, read_metadata: bool, }, @@ -696,6 +698,8 @@ pub struct SyncCounts { pub unreadable: usize, pub missing: usize, pub metadata: usize, + #[serde(default)] + pub moved: usize, } impl Default for UiState { diff --git a/crates/ui-egui/src/sync.rs b/crates/ui-egui/src/sync.rs index 26c1ed680..d774076ae 100644 --- a/crates/ui-egui/src/sync.rs +++ b/crates/ui-egui/src/sync.rs @@ -43,6 +43,7 @@ pub fn open(app: &mut LightcraftApp, path: &str, name: &str, disk: bool) -> Resu disk, counts: None, import_new: d.import_new, + relink_moved: d.relink_moved, remove_missing: d.remove_missing, read_metadata: d.read_metadata, }); @@ -94,6 +95,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { unreadable: changes.unreadable.len(), missing: changes.missing.len(), metadata: changes.metadata.len(), + moved: changes.moved.len(), }; app.session.folder_changes = Some(changes); if let Some(Dialog::SynchronizeFolder { counts, .. }) = &mut app.ui.dialog { @@ -108,7 +110,7 @@ fn progress(app: &LightcraftApp) -> Option<(usize, usize)> { /// The dialog's body. pub fn body(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) { - let Dialog::SynchronizeFolder { path, name, counts, import_new, remove_missing, read_metadata, .. } = dlg else { return }; + 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:")); @@ -135,11 +137,12 @@ pub fn body(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) { }; let count = |text: &str, n: usize| format!("{} ({n})", crate::i18n::tr(text)); 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); let dim = |ui: &mut egui::Ui, s: &str| ui.label(egui::RichText::new(s).color(t.text_dim)); - if c.new + c.missing + c.metadata == 0 { + if c.new + c.moved + c.missing + c.metadata == 0 { dim(ui, crate::i18n::tr("The library is up to date with this folder.")); } if c.missing > 0 { @@ -158,12 +161,12 @@ pub fn body(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) { /// Synchronize: `folder.synchronize` with the dialog's choices. pub fn confirm(app: &mut LightcraftApp, dlg: &Dialog) -> Result { - let Dialog::SynchronizeFolder { path, disk, counts: Some(_), import_new, remove_missing, read_metadata, .. } = dlg else { + let Dialog::SynchronizeFolder { path, disk, counts: Some(_), import_new, relink_moved, remove_missing, read_metadata, .. } = dlg else { return Err("the folder is still being scanned".into()); }; let r = app.run( "folder.synchronize", - json!({"path": path, "disk": disk, "importNew": import_new, "removeMissing": remove_missing, "readMetadata": read_metadata}), + json!({"path": path, "disk": disk, "importNew": import_new, "relinkMoved": relink_moved, "removeMissing": remove_missing, "readMetadata": read_metadata}), ); app.session.folder_changes = None; r From f9d4892c23150e36702fe5ec095b89592e46e658 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 18:12:27 -0400 Subject: [PATCH 09/28] Synchronize Folder: one undo step with a full history; partial failures reported (review fixes) The steps to merge were counted from the undo history's length, which stops growing at its 1000-step limit: with a full history nothing merged, or only the last part was relabelled "Synchronize Folder" while the import stayed behind it. Session now counts committed steps (Session::commits) and Synchronize merges by that count. Once the import is in, a later part that fails is listed in `failed` instead of aborting with the import left as a separate step. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 11 +++++++++++ crates/engine/src/sync.rs | 29 ++++++++++++++++++++--------- crates/engine/src/tests_sync.rs | 19 +++++++++++++++++++ 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 02c3cd9c1..5607bda49 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -167,6 +167,9 @@ pub struct Session { /// Photos in the current source with no filter on (cached like `visible`). total: Option<((u64, String), usize)>, pub undo: Vec, + /// Undo steps committed in this session, ever (the history drops its oldest past 1000, so + /// its length can't tell how many steps a command just made; see [`Session::commits`]). + commits: u64, pub redo: Vec, pub interaction: Option, /// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on @@ -294,6 +297,7 @@ impl Session { visible_gen: 0, total: None, undo: Vec::new(), + commits: 0, redo: Vec::new(), interaction: None, skip_auto_write: false, @@ -427,6 +431,7 @@ impl Session { let inv = self.catalog.apply(op)?; self.pending_log.push(fwd); self.undo.push(UndoEntry { label: label.to_string(), op: inv, folder: None }); + self.commits = self.commits.wrapping_add(1); if self.undo.len() > 1000 { self.undo.remove(0); } @@ -491,6 +496,12 @@ impl Session { /// Fold the last `n` undo steps into one (commands that commit step by step because each op /// depends on the state the previous one left). + /// How many undo steps were committed in this session so far: what a command that commits + /// several steps compares before and after to merge them ([`Session::merge_undo`]). + pub fn commits(&self) -> u64 { + self.commits + } + pub fn merge_undo(&mut self, n: usize, label: &str) { if n < 2 || n > self.undo.len() { return; diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs index 602296049..0b5cc73db 100644 --- a/crates/engine/src/sync.rs +++ b/crates/engine/src/sync.rs @@ -323,12 +323,13 @@ fn sidecar_has_news(labels: &Catalog, p: &Photo, naming: SidecarNaming) -> bool } /// Act on `changes` as `choice` says, as one undo step ("Synchronize Folder"). A missing photo -/// whose file is back by now is left alone. +/// whose file is back by now is left alone. Once the import is in, a part that fails is +/// reported in `failed` and the rest is still done (and still one step). pub fn synchronize(s: &mut Session, changes: FolderChanges, choice: SyncChoice) -> Result { if changes.offline { return Err(not_there(&changes.path)); } - let mark = s.undo.len(); + let mark = s.commits(); let mut report = SyncReport::default(); let FolderChanges { new, missing, metadata, moved, probes, .. } = changes; if choice.import_new && !new.is_empty() { @@ -346,8 +347,11 @@ pub fn synchronize(s: &mut Session, changes: FolderChanges, choice: SyncChoice) .map(|m| crate::cmd::missing::relink_op(PhotoId(m.id), &m.to)) .collect(); if !ops.is_empty() { - report.relinked = ops.len(); - s.commit("Relink Moved Photos", Op::Batch { ops })?; + let n = ops.len(); + match s.commit("Relink Moved Photos", Op::Batch { ops }) { + Ok(()) => report.relinked = n, + Err(e) => report.failed.push((String::new(), e.to_string())), + } } } if choice.remove_missing { @@ -358,8 +362,11 @@ pub fn synchronize(s: &mut Session, changes: FolderChanges, choice: SyncChoice) .map(|id| Op::SetDeleted { id: *id, deleted: true }) .collect(); if !ops.is_empty() { - report.removed = ops.len(); - s.commit("Remove Missing Photos", Op::Batch { ops })?; + let n = ops.len(); + match s.commit("Remove Missing Photos", Op::Batch { ops }) { + Ok(()) => report.removed = n, + Err(e) => report.failed.push((String::new(), e.to_string())), + } } } if choice.read_metadata { @@ -374,11 +381,15 @@ pub fn synchronize(s: &mut Session, changes: FolderChanges, choice: SyncChoice) Err(e) => report.failed.push((m.path.clone(), e.to_string())), } } - if !ops.is_empty() { - s.commit("Read Metadata from File", Op::Batch { ops })?; + if !ops.is_empty() + && let Err(e) = s.commit("Read Metadata from File", Op::Batch { ops }) + { + report.read = 0; + report.failed.push((String::new(), e.to_string())); } } - let steps = s.undo.len().saturating_sub(mark); + // (what was done stays one step even when a later part failed: the report says which) + let steps = usize::try_from(s.commits().wrapping_sub(mark)).unwrap_or(usize::MAX).min(s.undo.len()); s.merge_undo(steps, "Synchronize Folder"); if let Some(last) = s.undo.last_mut().filter(|_| steps == 1) { last.label = "Synchronize Folder".into(); diff --git a/crates/engine/src/tests_sync.rs b/crates/engine/src/tests_sync.rs index daebda739..411ded3ab 100644 --- a/crates/engine/src/tests_sync.rs +++ b/crates/engine/src/tests_sync.rs @@ -342,3 +342,22 @@ fn a_file_renamed_or_moved_inside_the_folder_is_relinked_not_lost() { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.catalog.photo(a).unwrap().source, lightcraft_catalog::Source::File { path: dir.path("trip/a.png") }); } + +#[test] +fn synchronizing_is_one_undo_step_even_with_a_full_undo_history() { + let dir = Scratch::new("full-undo"); + let mut s = library(&dir); + let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id; + // a long session: the history is at its limit and drops its oldest steps + for i in 0..1005u32 { + s.execute("photo.rate", &json!({"ids": [a.0], "rating": i % 5})).unwrap(); + } + let before = holdings(&s); + write_png(&dir.path("trip/c.png"), 3); + std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap(); + let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "removeMissing": true})).unwrap(); + assert_eq!((r["imported"].as_u64(), r["removed"].as_u64()), (Some(1), Some(1)), "{r}"); + let undo = s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(undo["undone"], "Synchronize Folder"); + assert_eq!(holdings(&s), before, "one step undoes the import and the removal"); +} From c98010cffa7b34535d461a5d588909548984fc40 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 18:16:32 -0400 Subject: [PATCH 10/28] Synchronize Folder: act on the scan shown, never rescan on the UI thread (review fixes) A scan went stale on any catalog change anywhere (a background import, a rating elsewhere), and folder.synchronize then scanned again inline: on the UI thread, and acting on results the person never saw. A scan now stays current while the folder's own photos are the same records (Session::take_folder_changes compares them by identity). The dialog asks with `scanned: true`, which refuses a stale scan instead of redoing it; the dialog then stays open and scans again in the background. Agents that just call folder.synchronize still get a fresh scan. The app dropped session.folder_changes every frame no dialog was open, so an agent's folder.scanChanges never reached its folder.synchronize in the running app; it now only lets go of a scan its dialog made. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/sync.rs | 4 +++- crates/engine/src/sync.rs | 32 ++++++++++++++++++---------- crates/engine/src/tests_sync.rs | 32 ++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 +++ crates/ui-egui/src/panels/dialogs.rs | 1 + crates/ui-egui/src/sync.rs | 23 +++++++++++++++----- crates/ui-egui/src/tests_sync.rs | 32 ++++++++++++++++++++++++++++ 7 files changed, 110 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/cmd/sync.rs b/crates/engine/src/cmd/sync.rs index 505377a7e..81693e5ae 100644 --- a/crates/engine/src/cmd/sync.rs +++ b/crates/engine/src/cmd/sync.rs @@ -33,7 +33,7 @@ pub fn specs() -> Vec { "Synchronize Folder", [], None, - "{path, disk?: bool, importNew?: true, relinkMoved?: true, removeMissing?: false, readMetadata?: false} — bring a folder of the library up to date with the disk (see folder.scanChanges): import its new files in place, relink photos whose file was renamed or moved within it, move photos whose file is gone to Recently Deleted, read XMP sidecars saved by other apps (the sidecar wins); acts on the last folder.scanChanges of the folder while nothing changed in the library since (else scans again); one undo step, no file is touched → {imported, relinked, removed, read, failed: [[path, error]]}", + "{path, disk?: bool, scanned?: bool, importNew?: true, relinkMoved?: true, removeMissing?: false, readMetadata?: false} — bring a folder of the library up to date with the disk (see folder.scanChanges): import its new files in place, relink photos whose file was renamed or moved within it, move photos whose file is gone to Recently Deleted, read XMP sidecars saved by other apps (the sidecar wins); acts on the last folder.scanChanges of the folder while it is current (the folder's photos unchanged), else scans again — or, with `scanned: true`, refuses; one undo step, no file is touched → {imported, relinked, removed, read, failed: [[path, error]]}", always, sync ), @@ -52,6 +52,8 @@ fn sync(s: &mut Session, p: &Value) -> Result { }; let changes = match s.take_folder_changes(&path) { Some(c) => c, + // the person agreed to what a scan showed: never act on something else + None if bool_or(p, "scanned", false) => return Err(bad(C, format!("{path} changed since it was scanned: scan again"))), None => scan(s, &path, bool_or(p, "disk", false)).map_err(|e| bad(C, e.to_string()))?, }; let r = synchronize(s, changes, choice)?; diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs index 0b5cc73db..92b60aeab 100644 --- a/crates/engine/src/sync.rs +++ b/crates/engine/src/sync.rs @@ -42,7 +42,6 @@ const MAX_SIDECAR_BYTES: u64 = 16 * 1024 * 1024; /// What a scan of a folder needs, taken from the session so it can run on a worker thread. pub struct SyncInput { folder: String, - revision: u64, scan: ScanInput, /// The library's photos in the folder, each with how its sidecar is named. photos: Vec<(Arc, SidecarNaming)>, @@ -73,9 +72,10 @@ pub struct FolderChanges { /// The probes of the new files, kept for the import that follows. #[serde(skip)] probes: HashMap, - /// The catalog revision the scan was made against. + /// The folder's photos as the scan saw them: the scan is current while they are the same + /// records (any change to one replaces it in the catalog). #[serde(skip)] - revision: u64, + seen: Vec>, } /// A photo of the folder and its file. @@ -147,7 +147,7 @@ impl SyncInput { } let photos = ids.iter().filter_map(|id| s.catalog.photo(*id).map(|p| (Arc::clone(p), s.sidecar_naming(*id)))).collect(); let (scan, _) = ScanInput::new(s, &[path.to_string()]); - Ok(SyncInput { folder: path.to_string(), revision: s.catalog.revision, scan, photos, labels: s.catalog.clone() }) + Ok(SyncInput { folder: path.to_string(), scan, photos, labels: s.catalog.clone() }) } } @@ -157,9 +157,10 @@ impl SyncInput { /// the look for new files (which probes them on its own threads) beside the checks of the /// library's photos, which run on several threads too. Stops early when `progress.cancel` is set. pub fn scan_with(input: SyncInput, progress: &ScanProgress) -> FolderChanges { - let SyncInput { folder, revision, scan, photos, labels } = input; + let SyncInput { folder, scan, photos, labels } = input; + let seen: Vec> = photos.iter().map(|(p, _)| Arc::clone(p)).collect(); if !cfg!(target_arch = "wasm32") && !Path::new(&folder).is_dir() { - return FolderChanges { path: folder, revision, offline: true, ..Default::default() }; + return FolderChanges { path: folder, seen, offline: true, ..Default::default() }; } // (no threads on the web: one after the other there) #[cfg(target_arch = "wasm32")] @@ -173,7 +174,7 @@ pub fn scan_with(input: SyncInput, progress: &ScanProgress) -> FolderChanges { // (a check that panicked counts as nothing found; the scan still reports the rest) (out, checks.join().unwrap_or_default()) }); - let mut changes = FolderChanges { path: folder, revision, ..Default::default() }; + let mut changes = FolderChanges { path: folder, seen, ..Default::default() }; for ((p, _), check) in photos.iter().zip(checks) { let Some(file) = crate::cmd::missing::checked_path(p) else { continue }; let found = FolderPhoto { id: p.id.0, path: file.to_string() }; @@ -290,12 +291,21 @@ fn not_there(path: &str) -> EngineError { } impl Session { - /// The last scan ([`Session::folder_changes`]) if it is of folder `path` and nothing changed - /// in the library since: what the person was shown is what gets done. Used once. + /// The last scan ([`Session::folder_changes`]) if it is of folder `path` and still current: + /// the folder holds the same photos, none of them changed. What the person was shown is what + /// gets done. Used once. (Changes elsewhere in the library don't matter.) pub fn take_folder_changes(&mut self, path: &str) -> Option { let c = self.folder_changes.take()?; - let same = lightcraft_catalog::query::folder_key(&c.path) == lightcraft_catalog::query::folder_key(path); - (same && c.revision == self.catalog.revision).then_some(c) + if lightcraft_catalog::query::folder_key(&c.path) != lightcraft_catalog::query::folder_key(path) { + return None; + } + let f = Filter { library_folder: Some(path.to_string()), ..Default::default() }; + let mut now: Vec = self.catalog.query(&f, &Sort::default()); + let mut then: Vec = c.seen.iter().map(|p| p.id).collect(); + now.sort(); + then.sort(); + let unchanged = now == then && c.seen.iter().all(|p| self.catalog.photo(p.id).is_some_and(|q| Arc::ptr_eq(p, q))); + unchanged.then_some(c) } } diff --git a/crates/engine/src/tests_sync.rs b/crates/engine/src/tests_sync.rs index 411ded3ab..583e448d2 100644 --- a/crates/engine/src/tests_sync.rs +++ b/crates/engine/src/tests_sync.rs @@ -361,3 +361,35 @@ fn synchronizing_is_one_undo_step_even_with_a_full_undo_history() { assert_eq!(undo["undone"], "Synchronize Folder"); assert_eq!(holdings(&s), before, "one step undoes the import and the removal"); } + +#[test] +fn a_scan_stays_current_while_only_other_folders_change() { + let dir = Scratch::new("current"); + let mut s = library(&dir); + write_png(&dir.path("other/x.png"), 9); + s.execute("library.import", &json!({"paths": [dir.path("other")]})).unwrap(); + write_png(&dir.path("trip/c.png"), 3); + scan(&mut s, &dir.path("trip")); + // something elsewhere in the library changes while the dialog is open (a background import…) + let x = s.catalog.photos().find(|p| p.file_name == "x.png").unwrap().id; + s.execute("photo.rate", &json!({"ids": [x.0], "rating": 3})).unwrap(); + let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "scanned": true})).unwrap(); + assert_eq!(r["imported"], 1, "{r}"); +} + +#[test] +fn a_change_to_the_folders_photos_makes_the_scan_stale() { + let dir = Scratch::new("stale-folder"); + let mut s = library(&dir); + write_png(&dir.path("trip/c.png"), 3); + scan(&mut s, &dir.path("trip")); + let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id; + s.execute("photo.rate", &json!({"ids": [a.0], "rating": 2})).unwrap(); + // the dialog asks for exactly what it showed: refused, never redone on the spot + let e = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "scanned": true})).unwrap_err().to_string(); + assert!(e.contains("scan again"), "{e}"); + assert_eq!(s.catalog.photos().filter(|p| p.in_library()).count(), 2, "nothing was done"); + // an agent that just asks to synchronize gets a fresh scan + let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap(); + assert_eq!(r["imported"], 1, "{r}"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 61c226f1c..8f1b41822 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -245,6 +245,8 @@ pub struct LightcraftApp { pub scan: Option, /// A Synchronize Folder scan in progress (feeds its dialog). pub sync: Option, + /// `session.folder_changes` came from the Synchronize Folder dialog (and goes with it). + pub sync_owns_changes: bool, /// A Lightroom catalog inspect/import in progress. pub lightroom: Option, /// Last terminal Lightroom result, exposed by the command's status/wait response. @@ -317,6 +319,7 @@ impl LightcraftApp { import: None, scan: None, sync: None, + sync_owns_changes: false, lightroom: None, lightroom_last: None, export: None, diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 2890ae1fc..9f124ae80 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -881,6 +881,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { | Dialog::NewAlbum { .. } | Dialog::NewSmartAlbum { .. } | Dialog::SmartRules { .. } + | Dialog::SynchronizeFolder { .. } ) => { app.toast(ctx, e) diff --git a/crates/ui-egui/src/sync.rs b/crates/ui-egui/src/sync.rs index d774076ae..456a1d492 100644 --- a/crates/ui-egui/src/sync.rs +++ b/crates/ui-egui/src/sync.rs @@ -50,12 +50,15 @@ pub fn open(app: &mut LightcraftApp, path: &str, name: &str, disk: bool) -> Resu Ok(()) } -/// Stop a running scan and let go of a finished one. +/// 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.cancel.store(true, Ordering::Relaxed); } - app.session.folder_changes = None; + 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 @@ -66,7 +69,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { _ => None, }; let Some(open_for) = open_for else { - if app.sync.is_some() || app.session.folder_changes.is_some() { + if app.sync.is_some() || app.sync_owns_changes { cancel(app); } return; @@ -98,6 +101,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { 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); } @@ -166,8 +170,17 @@ pub fn confirm(app: &mut LightcraftApp, dlg: &Dialog) -> Result { }; let r = app.run( "folder.synchronize", - json!({"path": path, "disk": disk, "importNew": import_new, "relinkMoved": relink_moved, "removeMissing": remove_missing, "readMetadata": read_metadata}), + json!({"path": path, "disk": disk, "scanned": true, "importNew": import_new, "relinkMoved": relink_moved, "removeMissing": remove_missing, "readMetadata": read_metadata}), ); - app.session.folder_changes = None; + app.sync_owns_changes = false; + if r.is_err() { + // (the folder changed since the scan, or went offline): look again, in the background + let (path, disk) = (path.clone(), *disk); + let name = match dlg { + Dialog::SynchronizeFolder { name, .. } => name.clone(), + _ => String::new(), + }; + open(app, &path, &name, disk)?; + } r } diff --git a/crates/ui-egui/src/tests_sync.rs b/crates/ui-egui/src/tests_sync.rs index bc831ca00..631aaa6b5 100644 --- a/crates/ui-egui/src/tests_sync.rs +++ b/crates/ui-egui/src/tests_sync.rs @@ -149,3 +149,35 @@ fn cancel_leaves_the_library_as_it_was() { assert_eq!(in_library(&h), vec!["a.png", "b.png"]); assert!(h.app.session.folder_changes.is_none(), "the scan is let go"); } + +#[test] +fn an_agents_scan_is_kept_between_frames() { + let dir = Scratch::new("agent"); + let mut h = changed_folder(&dir); + let r = h.request("engine.execute", json!({"command": "folder.scanChanges", "params": {"path": dir.path("trip")}}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..5 { + h.step(); + } + assert!(h.app.session.folder_changes.is_some(), "the app doesn't drop a scan it didn't make"); +} + +#[test] +fn a_scan_gone_stale_in_the_dialog_is_made_again_in_the_background() { + let dir = Scratch::new("restale"); + let mut h = changed_folder(&dir); + open_and_scan(&mut h, &dir.path("trip")); + // a photo of the folder changes while the dialog is open + let a = h.app.session.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id; + let r = h.request("engine.execute", json!({"command": "photo.rate", "params": {"ids": [a.0], "rating": 3}}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], false, "not done on a stale scan: {r}"); + assert_eq!(in_library(&h), vec!["a.png", "b.png"]); + assert!(matches!(h.app.ui.dialog, Some(Dialog::SynchronizeFolder { .. })), "the dialog stays open"); + let rescanned = h.step_until(T, |h| matches!(&h.app.ui.dialog, Some(Dialog::SynchronizeFolder { counts: Some(_), .. }))); + assert!(rescanned, "and scans again"); + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(in_library(&h), vec!["a.png", "b.png", "c.png"]); +} From 82d46b14cdfcbbd24bf15291844a6155d27ddd88 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 18:18:33 -0400 Subject: [PATCH 11/28] Synchronize Folder: progress counts the photo checks too (review fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialog's "Scanning… n / m" counted only the files probed, so it could sit at m / m while the library's photos were still being checked. SyncProgress counts both halves (its `files.cancel` still stops the whole scan). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/sync.rs | 44 ++++++++++++++++++++++++++------- crates/engine/src/tests_sync.rs | 17 +++++++++++-- crates/ui-egui/src/sync.rs | 11 ++++----- 3 files changed, 55 insertions(+), 17 deletions(-) diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs index 92b60aeab..596d10958 100644 --- a/crates/engine/src/sync.rs +++ b/crates/engine/src/sync.rs @@ -39,6 +39,24 @@ use crate::{EngineError, Result, Session}; /// The largest XMP sidecar read when looking for metadata updates; a bigger one is skipped. const MAX_SIDECAR_BYTES: u64 = 16 * 1024 * 1024; +/// How far a scan is: the folder's files probed ([`ScanProgress`], whose `cancel` stops the +/// whole scan) and the library's photos checked. +#[derive(Default)] +pub struct SyncProgress { + pub files: ScanProgress, + pub checked: std::sync::atomic::AtomicUsize, + pub to_check: std::sync::atomic::AtomicUsize, +} + +impl SyncProgress { + /// (done, of how many), both halves together. + pub fn counts(&self) -> (usize, usize) { + use std::sync::atomic::Ordering::Relaxed; + let done = self.files.done.load(Relaxed).saturating_add(self.checked.load(Relaxed)); + (done, self.files.total.load(Relaxed).saturating_add(self.to_check.load(Relaxed))) + } +} + /// What a scan of a folder needs, taken from the session so it can run on a worker thread. pub struct SyncInput { folder: String, @@ -155,8 +173,9 @@ impl SyncInput { /// /// Both halves wait on the disk (a network share answers each file slowly), so they run at once: /// the look for new files (which probes them on its own threads) beside the checks of the -/// library's photos, which run on several threads too. Stops early when `progress.cancel` is set. -pub fn scan_with(input: SyncInput, progress: &ScanProgress) -> FolderChanges { +/// library's photos, which run on several threads too. Stops early when `progress.files.cancel` +/// is set. +pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges { let SyncInput { folder, scan, photos, labels } = input; let seen: Vec> = photos.iter().map(|(p, _)| Arc::clone(p)).collect(); if !cfg!(target_arch = "wasm32") && !Path::new(&folder).is_dir() { @@ -164,13 +183,13 @@ pub fn scan_with(input: SyncInput, progress: &ScanProgress) -> FolderChanges { } // (no threads on the web: one after the other there) #[cfg(target_arch = "wasm32")] - let (out, checks) = (crate::import::scan_with(scan, std::slice::from_ref(&folder), progress), check_photos(&photos, &labels, progress)); + let (out, checks) = (crate::import::scan_with(scan, std::slice::from_ref(&folder), &progress.files), check_photos(&photos, &labels, progress)); #[cfg(not(target_arch = "wasm32"))] let (out, checks) = std::thread::scope(|sc| { let photos = &photos; let labels = &labels; let checks = sc.spawn(move || check_photos(photos, labels, progress)); - let out = crate::import::scan_with(scan, std::slice::from_ref(&folder), progress); + let out = crate::import::scan_with(scan, std::slice::from_ref(&folder), &progress.files); // (a check that panicked counts as nothing found; the scan still reports the rest) (out, checks.join().unwrap_or_default()) }); @@ -245,8 +264,15 @@ fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog) -> Check { /// [`check_photo`] for each photo, in order, on several threads (each file is a wait on the /// disk, not work for the CPU). Photos not reached before a cancel count as nothing found. -fn check_photos(photos: &[(Arc, SidecarNaming)], labels: &Catalog, progress: &ScanProgress) -> Vec { +fn check_photos(photos: &[(Arc, SidecarNaming)], labels: &Catalog, progress: &SyncProgress) -> Vec { use std::sync::atomic::Ordering::Relaxed; + progress.to_check.store(photos.len(), Relaxed); + let cancel = &progress.files.cancel; + let check = |p: &Photo, naming: SidecarNaming| { + let c = check_photo(p, naming, labels); + progress.checked.fetch_add(1, Relaxed); + c + }; if cfg!(target_arch = "wasm32") { return vec![Check::Nothing; photos.len()]; } @@ -259,10 +285,10 @@ fn check_photos(photos: &[(Arc, SidecarNaming)], labels: &Catalog, progre std::thread::scope(|sc| { for _ in 0..n { sc.spawn(|| { - while !progress.cancel.load(Relaxed) { + while !cancel.load(Relaxed) { let i = next.fetch_add(1, Relaxed); let Some((p, naming)) = photos.get(i) else { break }; - let c = check_photo(p, *naming, labels); + let c = check(p, *naming); if let Some(slot) = found.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_mut(i) { *slot = c; } @@ -273,13 +299,13 @@ fn check_photos(photos: &[(Arc, SidecarNaming)], labels: &Catalog, progre return found.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner); } } - photos.iter().map(|(p, naming)| if progress.cancel.load(Relaxed) { Check::Nothing } else { check_photo(p, *naming, labels) }).collect() + photos.iter().map(|(p, naming)| if cancel.load(Relaxed) { Check::Nothing } else { check(p, *naming) }).collect() } /// [`scan_with`] on the calling thread. pub fn scan(s: &mut Session, path: &str, disk: bool) -> Result { let input = SyncInput::new(s, path, disk)?; - let changes = scan_with(input, &ScanProgress::default()); + let changes = scan_with(input, &SyncProgress::default()); if changes.offline { return Err(not_there(&changes.path)); } diff --git a/crates/engine/src/tests_sync.rs b/crates/engine/src/tests_sync.rs index 583e448d2..2d5bcc915 100644 --- a/crates/engine/src/tests_sync.rs +++ b/crates/engine/src/tests_sync.rs @@ -244,8 +244,8 @@ fn a_cancelled_scan_stops_checking_files() { let mut s = library(&dir); std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap(); let input = crate::sync::SyncInput::new(&mut s, &dir.path("trip"), false).unwrap(); - let progress = crate::import::ScanProgress::default(); - progress.cancel.store(true, std::sync::atomic::Ordering::Relaxed); + let progress = crate::sync::SyncProgress::default(); + progress.files.cancel.store(true, std::sync::atomic::Ordering::Relaxed); let c = crate::sync::scan_with(input, &progress); assert!(c.missing.is_empty() && c.metadata.is_empty(), "{c:?}"); } @@ -393,3 +393,16 @@ fn a_change_to_the_folders_photos_makes_the_scan_stale() { let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap(); assert_eq!(r["imported"], 1, "{r}"); } + +#[test] +fn the_progress_counts_the_photos_checked_too() { + use std::sync::atomic::Ordering::Relaxed; + let dir = Scratch::new("progress"); + let mut s = library(&dir); + let input = crate::sync::SyncInput::new(&mut s, &dir.path("trip"), false).unwrap(); + let progress = crate::sync::SyncProgress::default(); + crate::sync::scan_with(input, &progress); + assert_eq!((progress.checked.load(Relaxed), progress.to_check.load(Relaxed)), (2, 2)); + let (done, total) = progress.counts(); + assert!(done == total && total >= 2, "{done}/{total}"); +} diff --git a/crates/ui-egui/src/sync.rs b/crates/ui-egui/src/sync.rs index 456a1d492..642de3e5e 100644 --- a/crates/ui-egui/src/sync.rs +++ b/crates/ui-egui/src/sync.rs @@ -6,8 +6,7 @@ use std::sync::Arc; use std::sync::atomic::Ordering; -use lightcraft_engine::import::ScanProgress; -use lightcraft_engine::sync::{FolderChanges, SyncChoice, SyncInput, scan_with}; +use lightcraft_engine::sync::{FolderChanges, SyncChoice, SyncInput, SyncProgress, scan_with}; use serde_json::{Value, json}; use crate::LightcraftApp; @@ -17,7 +16,7 @@ use crate::theme::Tokens; /// A running scan of the dialog's folder. pub struct SyncTask { path: String, - progress: Arc, + progress: Arc, rx: std::sync::mpsc::Receiver, } @@ -25,7 +24,7 @@ pub struct SyncTask { 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(ScanProgress::default()); + let progress = Arc::new(SyncProgress::default()); let (tx, rx) = std::sync::mpsc::channel(); let p = progress.clone(); let job = move || { @@ -54,7 +53,7 @@ pub fn open(app: &mut LightcraftApp, path: &str, name: &str, disk: bool) -> Resu /// `folder.scanChanges` is the agent's). fn cancel(app: &mut LightcraftApp) { if let Some(t) = app.sync.take() { - t.progress.cancel.store(true, Ordering::Relaxed); + t.progress.files.cancel.store(true, Ordering::Relaxed); } if std::mem::take(&mut app.sync_owns_changes) { app.session.folder_changes = None; @@ -109,7 +108,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { /// How far the scan is (files probed, of how many). fn progress(app: &LightcraftApp) -> Option<(usize, usize)> { - app.sync.as_ref().map(|t| (t.progress.done.load(Ordering::Relaxed), t.progress.total.load(Ordering::Relaxed))) + app.sync.as_ref().map(|t| t.progress.counts()) } /// The dialog's body. From f2bb46b7b7a6185b1dd1b7060818b65299a8c815 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 18:19:49 -0400 Subject: [PATCH 12/28] Synchronize Folder: state the metadata-update limits as they are (review fix) The docs claimed only a sidecar written here before auto-write was turned off could be mistaken for an update; in fact the library only knows when a photo came in or was last developed, so any later rating, label or metadata change made here can be. Says so, with the headless fixed clock, the sidecar naming fallback and the walk's limits; the parity row and what's new also cover relinking moved files, Local files, offline folders and the `disk` guard. Adds the test that a sidecar the library auto-wrote is never an update. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/sync.rs | 17 +++++++++++++---- crates/engine/src/tests_sync.rs | 13 +++++++++++++ docs/parity.md | 2 +- docs/whats-new.md | 5 +++-- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs index 596d10958..c91b4c7b3 100644 --- a/crates/engine/src/sync.rs +++ b/crates/engine/src/sync.rs @@ -19,10 +19,19 @@ //! The scan runs without the session ([`SyncInput`] + [`scan_with`]), so the app can run it on a //! worker thread: a folder on a sleeping network share must never stall a frame. //! -//! Limits: a sidecar the library itself wrote (auto-write on) agrees with it and is never an -//! update; one written here before auto-write was turned off, followed by changes made only in -//! the library, is listed as an update even though the library is newer (there is no record of -//! when a photo's metadata last changed here). Reading updates is therefore never the default. +//! Limits of telling an update from a sidecar that is merely older: +//! - a sidecar the library itself wrote (auto-write on) agrees with it and is never an update; +//! - the library records when a photo came in and when its develop settings last changed, not +//! when its rating, flag, label or metadata did: a sidecar another app saved, followed by such +//! a change made here (with auto-write off), is listed as an update, and reading it puts the +//! older value back. Reading updates is therefore never the default; +//! - times are compared as the clock the session runs with; headless sessions (CLI, MCP) start +//! with a fixed clock, so their own times say little; +//! - a sidecar is found as Read Metadata from File finds it, including the other naming +//! scheme's file (`IMG_1.xmp` for `IMG_1.JPG` beside `IMG_1.CR3`). +//! +//! The walk for new files stops at the import's limits on depth and entries (logged), and is +//! not cancelled midway. use std::collections::HashMap; use std::path::Path; diff --git a/crates/engine/src/tests_sync.rs b/crates/engine/src/tests_sync.rs index 2d5bcc915..3ac2030ee 100644 --- a/crates/engine/src/tests_sync.rs +++ b/crates/engine/src/tests_sync.rs @@ -406,3 +406,16 @@ fn the_progress_counts_the_photos_checked_too() { let (done, total) = progress.counts(); assert!(done == total && total >= 2, "{done}/{total}"); } + +#[test] +fn a_sidecar_the_library_wrote_itself_is_no_update() { + let dir = Scratch::new("autowrite"); + let mut s = library(&dir); + s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap(); + let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id; + s.execute("photo.rate", &json!({"ids": [a.0], "rating": 3})).unwrap(); + s.execute("photo.label", &json!({"ids": [a.0], "label": "green"})).unwrap(); + assert!(Path::new(&dir.path("trip/a.xmp")).exists(), "auto-write wrote the sidecar"); + let r = scan(&mut s, &dir.path("trip")); + assert!(paths(&r, "metadata").is_empty(), "{r}"); +} diff --git a/docs/parity.md b/docs/parity.md index d6f9aa296..6be931359 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -743,7 +743,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages, in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs | | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`) | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | -| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown and is one undo step; Esc cancels the scan) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize imports on the UI thread once confirmed (its scan does not), has no “show import dialog first” or “show missing photos” option, and can't tell a sidecar written here before auto-write was turned off from one written by another app | +| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize imports on the UI thread once confirmed (its scan does not), has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files isn't cancelled midway and its depth/entry limits are only logged | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | | LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 30 fields (rating, flag, label, type, edits, keywords, any text, filename, file path, format, title, caption, camera, lens, location, creator, copyright, copyright status, capture / import / edit date, ISO, aperture, focal length, megapixels, GPS, virtual copy, album) with text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`) | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | diff --git a/docs/whats-new.md b/docs/whats-new.md index 386528c8f..e0a1cc836 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -5,8 +5,9 @@ ### Synchronize Folder - Right-click a folder (or a disk other than the startup disk) in the sidebar's Folders section ▸ Synchronize Folder…. The folder and the folders inside it are scanned in the background for photos added on disk, photos whose - file is gone, and XMP sidecars changed by other apps; then choose what to do: import the new photos (the default), - move the missing ones to Recently Deleted, read the metadata updates. Everything is one undo step, and no file on + file is gone, photos whose file was renamed or moved within the folder, and XMP sidecars changed by other apps; then + choose what to do: import the new photos and relink the moved ones (the defaults), move the missing ones to Recently + Deleted, read the metadata updates. A folder on a disk that isn't connected is reported as such. Everything is one undo step, and no file on disk is touched. Agents use `folder.scanChanges` and `folder.synchronize`. ### Folder colour labels From 898ecee27cbcb5586029368725b55800cebdb47b Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 18:28:12 -0400 Subject: [PATCH 13/28] Synchronize Folder: relink renamed photos without a hash, and duplicates (review fixes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moved-file matching only knew photos with a content hash, one per hash: a photo taken over from a Lightroom catalog (no hash on record) that was renamed still showed as missing with its file as new, so Remove missing + Import new swapped it for a bare copy; a Photo › Duplicate (hash ":dup") never matched; and of two missing files with the same content only the first was found. Hashes now match by the content they stand for, each missing file claiming one file; a photo with no hash matches by file size and dimensions, only when exactly one missing file and one unclaimed file agree. A Local record listed as new no longer carries its "duplicate"/"existing" marks in the scan. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/sync.rs | 81 ++++++++++++++++++++++++++------- crates/engine/src/tests_sync.rs | 32 +++++++++++++ 2 files changed, 97 insertions(+), 16 deletions(-) diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs index c91b4c7b3..5e74fb220 100644 --- a/crates/engine/src/sync.rs +++ b/crates/engine/src/sync.rs @@ -212,28 +212,15 @@ pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges { Check::Nothing => {} } } - // a missing photo's content found under another name in the folder: the file was renamed or - // moved, and the photo (with its edits) follows it rather than going missing - let mut gone_by_hash: HashMap<&str, &str> = HashMap::new(); - for m in &changes.missing { - if let Some(h) = labels.photo(PhotoId(m.id)).and_then(|p| p.content_hash.as_deref()) { - gone_by_hash.entry(h).or_insert(m.path.as_str()); - } - } - let mut moved_from: HashMap = HashMap::new(); + let moved_from = match_moved(&changes.missing, &out.candidates, &out.probes, &labels); for c in out.candidates { - let hash = out.probes.get(&c.path).and_then(|i| i.content_hash.as_deref()); - if c.error.is_none() - && c.duplicate.as_deref() != Some("path") - && let Some(from) = hash.and_then(|h| gone_by_hash.remove(h)) - { - moved_from.insert(from.to_string(), c.path.clone()); + if moved_from.values().any(|to| *to == c.path) { continue; } let browsed = c.existing.and_then(|id| labels.photo(PhotoId(id))).is_some_and(|p| p.local && !p.deleted); match (c.duplicate.as_deref(), &c.error) { // a file only looked at in Local is not in the library yet: it is new - (Some("path"), _) if browsed => changes.new.push(c), + (Some("path"), _) if browsed => changes.new.push(ImportCandidate { duplicate: None, existing: None, ..c }), (Some("path"), _) => {} (Some(_), _) => changes.duplicates += 1, (None, Some(_)) => changes.unreadable.push(c), @@ -251,6 +238,68 @@ pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges { changes } +/// The content a hash stands for: a Duplicate's own hash is the original's plus `:dup`. +fn base_hash(h: &str) -> &str { + h.split(':').next().unwrap_or(h) +} + +/// Missing photos' files found elsewhere in the folder (old path → new path): the file was +/// renamed or moved, and the photo (with its edits) follows it rather than going missing. +/// +/// By content first: each missing file's hash claims one file with that content (two missing +/// files with the same content claim two). A photo with no hash on record (taken over from a +/// Lightroom catalog) is matched by file size and dimensions, and only when exactly one missing +/// file and exactly one unclaimed file of the folder agree on them. +fn match_moved( + missing: &[FolderPhoto], + candidates: &[ImportCandidate], + probes: &HashMap, + labels: &Catalog, +) -> HashMap { + // distinct missing files (a photo's virtual copies share its file), with what is known of them + let mut files: Vec<(&str, Option<&Photo>)> = Vec::new(); + for m in missing { + if !files.iter().any(|(p, _)| *p == m.path) { + files.push((m.path.as_str(), labels.photo(PhotoId(m.id)).map(|p| &**p))); + } + } + let mut by_hash: HashMap<&str, std::collections::VecDeque<&str>> = HashMap::new(); + for (path, p) in &files { + if let Some(h) = p.and_then(|p| p.content_hash.as_deref()) { + by_hash.entry(base_hash(h)).or_default().push_back(path); + } + } + let usable = |c: &&ImportCandidate| c.error.is_none() && c.duplicate.as_deref() != Some("path"); + let mut moved: HashMap = HashMap::new(); + for c in candidates.iter().filter(usable) { + let hash = probes.get(&c.path).and_then(|i| i.content_hash.as_deref()); + if let Some(from) = hash.and_then(|h| by_hash.get_mut(base_hash(h))).and_then(std::collections::VecDeque::pop_front) { + moved.insert(from.to_string(), c.path.clone()); + } + } + let key = |size: u64, w: u32, h: u32| (size > 0 && w > 0 && h > 0).then_some((size, w, h)); + let mut hashless: HashMap<(u64, u32, u32), Vec<&str>> = HashMap::new(); + for (path, p) in &files { + if let Some(p) = p.filter(|p| p.content_hash.is_none()) + && let Some(k) = key(p.file_size, p.width, p.height) + { + hashless.entry(k).or_default().push(path); + } + } + let mut found: HashMap<(u64, u32, u32), Vec<&str>> = HashMap::new(); + for c in candidates.iter().filter(usable).filter(|c| !moved.values().any(|to| *to == c.path)) { + if let Some(k) = key(c.file_size, c.width, c.height) { + found.entry(k).or_default().push(c.path.as_str()); + } + } + for (k, from) in hashless { + if let ([from], Some([to])) = (from.as_slice(), found.get(&k).map(Vec::as_slice)) { + moved.insert(from.to_string(), to.to_string()); + } + } + moved +} + /// What checking one photo of the folder found. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] enum Check { diff --git a/crates/engine/src/tests_sync.rs b/crates/engine/src/tests_sync.rs index 3ac2030ee..616745120 100644 --- a/crates/engine/src/tests_sync.rs +++ b/crates/engine/src/tests_sync.rs @@ -419,3 +419,35 @@ fn a_sidecar_the_library_wrote_itself_is_no_update() { let r = scan(&mut s, &dir.path("trip")); assert!(paths(&r, "metadata").is_empty(), "{r}"); } + +#[test] +fn a_renamed_photo_with_no_content_hash_is_relinked_when_its_file_is_unmistakable() { + // as photos taken over from a Lightroom catalog are: no hash on record + let dir = Scratch::new("nohash"); + let mut s = library(&dir); + let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id; + let mut p = (**s.catalog.photo(a).unwrap()).clone(); + p.content_hash = None; + s.catalog.apply(lightcraft_catalog::Op::RemovePhoto { id: a }).unwrap(); + s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap(); + std::fs::rename(dir.path("trip/a.png"), dir.path("trip/a-renamed.png")).unwrap(); + let r = scan(&mut s, &dir.path("trip")); + assert_eq!(r["moved"][0]["to"], dir.path("trip/a-renamed.png"), "{r}"); + assert!(paths(&r, "missing").is_empty() && paths(&r, "new").is_empty(), "{r}"); +} + +#[test] +fn renamed_duplicates_are_each_relinked() { + let dir = Scratch::new("dups"); + let mut s = library(&dir); + let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id; + s.execute("photo.duplicate", &json!({"ids": [a.0]})).unwrap(); + assert!(Path::new(&dir.path("trip/a-copy.png")).exists()); + std::fs::rename(dir.path("trip/a.png"), dir.path("trip/one.png")).unwrap(); + std::fs::rename(dir.path("trip/a-copy.png"), dir.path("trip/two.png")).unwrap(); + let r = scan(&mut s, &dir.path("trip")); + assert_eq!(r["moved"].as_array().map(Vec::len), Some(2), "{r}"); + assert!(paths(&r, "missing").is_empty(), "{r}"); + let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap(); + assert_eq!(r["relinked"], 2, "{r}"); +} From 97e2c7ff518e544e8b6bb7df675f00b4555e580d Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 18:28:27 -0400 Subject: [PATCH 14/28] Synchronize Folder: background face search doesn't make a scan stale (review fix) A scan was current only while the folder's photo records were the very same; the face search adding regions in the background replaced them, so confirming could keep failing as stale while it worked through the folder. A photo now counts as changed only when something synchronizing depends on changed: its file, its place in the library, or what a sidecar is compared with (regions aside). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/sync.rs | 15 ++++++++++++++- crates/engine/src/tests_sync.rs | 15 +++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs index 5e74fb220..b07566caf 100644 --- a/crates/engine/src/sync.rs +++ b/crates/engine/src/sync.rs @@ -238,6 +238,19 @@ pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges { changes } +/// Whether a photo is the same as far as synchronizing goes: its file, its place in the library +/// and what a sidecar is compared with. Face and pet regions found in the background meanwhile +/// don't make a scan stale. +fn same_for_sync(a: &Arc, b: &Arc) -> bool { + if Arc::ptr_eq(a, b) { + return true; + } + let meta = |p: &Photo| lightcraft_catalog::Meta { regions: Vec::new(), ..p.meta.clone() }; + (&a.source, a.deleted, a.local, a.copy_of, &a.content_hash, a.file_size, a.rating, a.flag, a.label, &a.captured, &a.edited, &a.develop) + == (&b.source, b.deleted, b.local, b.copy_of, &b.content_hash, b.file_size, b.rating, b.flag, b.label, &b.captured, &b.edited, &b.develop) + && meta(a) == meta(b) +} + /// The content a hash stands for: a Duplicate's own hash is the original's plus `:dup`. fn base_hash(h: &str) -> &str { h.split(':').next().unwrap_or(h) @@ -388,7 +401,7 @@ impl Session { let mut then: Vec = c.seen.iter().map(|p| p.id).collect(); now.sort(); then.sort(); - let unchanged = now == then && c.seen.iter().all(|p| self.catalog.photo(p.id).is_some_and(|q| Arc::ptr_eq(p, q))); + let unchanged = now == then && c.seen.iter().all(|p| self.catalog.photo(p.id).is_some_and(|q| same_for_sync(p, q))); unchanged.then_some(c) } } diff --git a/crates/engine/src/tests_sync.rs b/crates/engine/src/tests_sync.rs index 616745120..724487ff2 100644 --- a/crates/engine/src/tests_sync.rs +++ b/crates/engine/src/tests_sync.rs @@ -451,3 +451,18 @@ fn renamed_duplicates_are_each_relinked() { let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip")})).unwrap(); assert_eq!(r["relinked"], 2, "{r}"); } + +#[test] +fn face_regions_found_meanwhile_leave_the_scan_current() { + let dir = Scratch::new("faces"); + let mut s = library(&dir); + write_png(&dir.path("trip/c.png"), 3); + scan(&mut s, &dir.path("trip")); + // background face search adds a region to a photo of the folder + let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id; + let mut meta = s.catalog.photo(a).unwrap().meta.clone(); + meta.regions.push(lightcraft_meta::Region { rect: Default::default(), kind: lightcraft_meta::RegionKind::Face, name: None, description: None }); + s.catalog.apply(lightcraft_catalog::Op::SetMeta { id: a, meta: Box::new(meta) }).unwrap(); + let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "scanned": true})).unwrap(); + assert_eq!(r["imported"], 1, "{r}"); +} From 465c1f87fccf9190c79afd8b92443f43d455010e Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 18:28:31 -0400 Subject: [PATCH 15/28] UI: a Synchronize Folder rescan keeps what was ticked (review fix) When a stale scan was made again, the dialog came back with the default choices, so a ticked "Remove missing" or "Read metadata" was silently dropped before the next click. The choices now carry over. A saved dialog without `relink_moved` reads it as on, the engine's default. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/state.rs | 6 +++++- crates/ui-egui/src/sync.rs | 10 +++++++--- crates/ui-egui/src/tests_sync.rs | 6 +++++- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index d08402ba4..28947ea4b 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -677,7 +677,7 @@ pub enum Dialog { /// What the scan found (`None` while it runs). counts: Option, import_new: bool, - #[serde(default)] + #[serde(default = "yes")] relink_moved: bool, remove_missing: bool, read_metadata: bool, @@ -686,6 +686,10 @@ pub enum Dialog { Shortcuts, } +fn yes() -> bool { + true +} + /// How many changes a Synchronize Folder scan found, by kind. #[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] diff --git a/crates/ui-egui/src/sync.rs b/crates/ui-egui/src/sync.rs index 642de3e5e..c44bc9421 100644 --- a/crates/ui-egui/src/sync.rs +++ b/crates/ui-egui/src/sync.rs @@ -173,13 +173,17 @@ pub fn confirm(app: &mut LightcraftApp, dlg: &Dialog) -> Result { ); app.sync_owns_changes = false; if r.is_err() { - // (the folder changed since the scan, or went offline): look again, in the background - let (path, disk) = (path.clone(), *disk); + // (the folder changed since the scan, or went offline): look again, in the background, + // keeping what was ticked; the error is what is reported, not a failure to look again let name = match dlg { Dialog::SynchronizeFolder { name, .. } => name.clone(), _ => String::new(), }; - open(app, &path, &name, disk)?; + 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); + } } r } diff --git a/crates/ui-egui/src/tests_sync.rs b/crates/ui-egui/src/tests_sync.rs index 631aaa6b5..d4ccd0ad3 100644 --- a/crates/ui-egui/src/tests_sync.rs +++ b/crates/ui-egui/src/tests_sync.rs @@ -167,6 +167,9 @@ fn a_scan_gone_stale_in_the_dialog_is_made_again_in_the_background() { let dir = Scratch::new("restale"); let mut h = changed_folder(&dir); open_and_scan(&mut h, &dir.path("trip")); + let r = h.request("ui.clickWidget", json!({"id": "syncRemoveMissing"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); // a photo of the folder changes while the dialog is open let a = h.app.session.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id; let r = h.request("engine.execute", json!({"command": "photo.rate", "params": {"ids": [a.0], "rating": 3}}), T); @@ -177,7 +180,8 @@ fn a_scan_gone_stale_in_the_dialog_is_made_again_in_the_background() { assert!(matches!(h.app.ui.dialog, Some(Dialog::SynchronizeFolder { .. })), "the dialog stays open"); let rescanned = h.step_until(T, |h| matches!(&h.app.ui.dialog, Some(Dialog::SynchronizeFolder { counts: Some(_), .. }))); assert!(rescanned, "and scans again"); + assert!(matches!(h.app.ui.dialog, Some(Dialog::SynchronizeFolder { remove_missing: true, .. })), "keeping what was ticked"); let r = h.request("ui.dialog.confirm", json!({}), T); assert_eq!(r["ok"], true, "{r}"); - assert_eq!(in_library(&h), vec!["a.png", "b.png", "c.png"]); + assert_eq!(in_library(&h), vec!["a.png", "c.png"], "c came in, the missing b went"); } From 7478407d07b7def6a425b844f1f67057f9c6a763 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 18:28:58 -0400 Subject: [PATCH 16/28] Docs: Synchronize Folder relinks Duplicates and photos without a hash Co-Authored-By: Claude Opus 5.5 --- docs/parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/parity.md b/docs/parity.md index 6be931359..7de7a7601 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -743,7 +743,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages, in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs | | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`) | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | -| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize imports on the UI thread once confirmed (its scan does not), has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files isn't cancelled midway and its depth/entry limits are only logged | +| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize imports on the UI thread once confirmed (its scan does not), has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files isn't cancelled midway and its depth/entry limits are only logged | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | | LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 30 fields (rating, flag, label, type, edits, keywords, any text, filename, file path, format, title, caption, camera, lens, location, creator, copyright, copyright status, capture / import / edit date, ISO, aperture, focal length, megapixels, GPS, virtual copy, album) with text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`) | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | From f3a3fa6c1041d022ed5c4a0d141a09627f37820e Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 18:36:27 -0400 Subject: [PATCH 17/28] Engine: Session::sidecar_op, the half of Read Metadata that reads no file read_sidecar_op = read and parse the packet (file I/O) + sidecar_op (build the op from what it says). The split lets a worker thread do the reading while the UI thread only builds and commits the op. No change in behaviour. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/sidecar.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index 7e9249489..f8127f685 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -475,9 +475,15 @@ impl Session { let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?; let Some(orig) = file_path(p) else { return Ok(None) }; let Some((packet, from)) = read_packet(orig, p.kind, self.sidecar_naming(id)) else { return Ok(None) }; - let sc = parse_sidecar(&packet, p.kind == MediaKind::Raw) - .map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))? - .resolve_label(&self.catalog); + let sc = parse_sidecar(&packet, p.kind == MediaKind::Raw).map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?; + Ok(Some((self.sidecar_op(id, sc)?, from))) + } + + /// The op that applies what a sidecar says (already read and parsed, e.g. on a worker + /// thread) to photo `id`: the sidecar wins. No file is read. + pub fn sidecar_op(&self, id: PhotoId, sc: SidecarData) -> Result { + let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?; + let sc = sc.resolve_label(&self.catalog); let mut q = (**p).clone(); let develop_changed = merge_into(&mut q, &sc, &(self.clock)()); let mut ops = vec![ @@ -492,7 +498,7 @@ impl Session { if develop_changed { ops.extend(self.develop_op(id, (*q.develop).clone(), "Read Metadata from File")); } - Ok(Some((Op::Batch { ops }, from))) + Ok(Op::Batch { ops }) } /// Auto-write: sidecars for photos changed by `ops` (errors are logged, not returned). From 5a21fc9d9733a58de997053fd99825f44ea69063 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 18:38:52 -0400 Subject: [PATCH 18/28] Import: a file gone or changed since the review is probed again The import reused the review's probes for every file it had seen, even one deleted (or rewritten) since: such a file was added as a photo of a file that isn't there. A cached probe is now used only while the file is there with the same size (checked in prepare_files, on the import's worker thread; the web's files keep their probes). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/import.rs | 13 +++++++++++-- crates/engine/src/tests_import.rs | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 124ac3903..7fc537a46 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -850,8 +850,17 @@ impl ImportJob { } } - // files a preceding scan already probed are not read again (a network share is slow to read) - let cached: Vec> = todo.iter().map(|f| self.cache.remove(f)).collect(); + // files a preceding scan already probed are not read again (a network share is slow to read), + // as long as they are still there, as they were: a file gone or changed since is probed again + let cached: Vec> = todo + .iter() + .map(|f| { + // (the web's files are not std::fs files: kept as probed) + self.cache + .remove(f) + .filter(|info| cfg!(target_arch = "wasm32") || std::fs::metadata(f).is_ok_and(|m| m.is_file() && m.len() == info.file_size)) + }) + .collect(); let missing: Vec = todo.iter().zip(&cached).filter(|(_, c)| c.is_none()).map(|(f, _)| f.clone()).collect(); let progress = ScanProgress::default(); let mut fresh = probe_all(self.probe.as_ref(), &missing, &progress).into_iter(); diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 4a4cad539..2fdaace85 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -1105,3 +1105,17 @@ fn folder_import_picks_up_undecodable_raw_containers_as_preview_only() { assert!(s.catalog.photos().all(|p| p.preview_only.is_some() && p.kind == lightcraft_catalog::MediaKind::Raw && (p.width, p.height) == (40, 30))); let _ = std::fs::remove_dir_all(&src); } + +#[test] +fn a_file_gone_since_the_import_review_is_not_imported() { + let src = temp_dir("gone-since-review"); + write_png(&src.join("a.png"), 1); + write_png(&src.join("b.png"), 2); + let mut s = Session::new().with_fs(); + s.execute("library.importPreview", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + std::fs::remove_file(src.join("b.png")).unwrap(); + let r = s.execute("library.import", &json!({"paths": [src.join("a.png").to_string_lossy(), src.join("b.png").to_string_lossy()]})).unwrap(); + assert_eq!((ids(&r, "imported"), ids(&r, "failed")), (1, 1), "{r}"); + assert_eq!(s.catalog.len(), 1, "no photo for a file that isn't there"); + let _ = std::fs::remove_dir_all(&src); +} From 90a38718e71a6245e977fd1d83418b5866f73dc5 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 18:39:33 -0400 Subject: [PATCH 19/28] Engine: synchronize in halves, so no file is touched on the UI thread SyncJob::start readies a synchronize from the session without any file-system call; SyncWork::run does all of the file work (the import's probes in batches, whether moved and missing files are still as the scan found them, reading the sidecars) and hands over SyncSteps; SyncCommit applies each to the catalog without touching the disk and finish() makes them one undo step. The app runs the work on a worker thread; folder.synchronize runs the same halves in turn. Progress (done/total) and cancel go through SyncProgress, one task from start to end. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/sync.rs | 289 ++++++++++++++++++++++++-------- crates/engine/src/tests_sync.rs | 72 ++++++++ 2 files changed, 293 insertions(+), 68 deletions(-) diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs index b07566caf..3c94e35a8 100644 --- a/crates/engine/src/sync.rs +++ b/crates/engine/src/sync.rs @@ -433,73 +433,226 @@ fn sidecar_has_news(labels: &Catalog, p: &Photo, naming: SidecarNaming) -> bool /// whose file is back by now is left alone. Once the import is in, a part that fails is /// reported in `failed` and the rest is still done (and still one step). pub fn synchronize(s: &mut Session, changes: FolderChanges, choice: SyncChoice) -> Result { - if changes.offline { - return Err(not_there(&changes.path)); + let (work, mut commit) = SyncJob::start(s, changes, choice)?; + let mut steps = Vec::new(); + work.run(&SyncProgress::default(), |step| { + steps.push(step); + true + }); + for step in steps { + commit.apply(s, step); + } + Ok(commit.finish(s)) +} + +/// Synchronizing in halves, so no file is touched on the UI thread: [`SyncJob::start`] takes what +/// it needs from the session (no file-system call); the [`SyncWork`] does every file read (the +/// import's probes, whether files are still where the scan found them, the sidecars) and hands +/// over [`SyncStep`]s — on a worker thread in the app; [`SyncCommit::apply`] puts each into the +/// catalog and [`SyncCommit::finish`] makes them one undo step. +pub struct SyncJob; + +/// The file-system half of a synchronize (see [`SyncJob`]). +pub struct SyncWork { + /// The import of the new files, and the files. + import: Option<(crate::import::ImportJob, Vec)>, + moved: Vec, + missing: Vec, + /// (photo, its file, how its sidecar is named) + sidecars: Vec<(PhotoId, String, SidecarNaming, bool)>, +} + +/// The catalog half of a synchronize (see [`SyncJob`]). +pub struct SyncCommit { + opts: ImportOptions, + now: String, + mark: u64, + report: SyncReport, +} + +/// A piece of work done, for [`SyncCommit::apply`]. +pub enum SyncStep { + /// A batch of new files, readied for the catalog. + Import(crate::import::Prepared), + /// Moved photos whose file is still at its new place (photo, new path). + Relink(Vec<(PhotoId, String)>), + /// Missing photos whose file is still gone. + Remove(Vec), + /// Sidecars read (photo, its file, what the sidecar says or why it couldn't be read). + Sidecars(Vec<(PhotoId, String, std::result::Result)>), +} + +impl SyncJob { + /// Ready a synchronize of `changes` as `choice` says. Reads no file. + pub fn start(s: &mut Session, changes: FolderChanges, choice: SyncChoice) -> Result<(SyncWork, SyncCommit)> { + if changes.offline { + return Err(not_there(&changes.path)); + } + let FolderChanges { new, missing, metadata, moved, probes, .. } = changes; + let opts = ImportOptions { mode: ImportMode::Add, ..Default::default() }; + let import = if choice.import_new && !new.is_empty() { + s.import_probes = probes; + let job = crate::import::ImportJob::new(s, opts.clone())?; + Some((job, new.into_iter().map(|c| c.path).collect())) + } else { + None + }; + let now = import.as_ref().map_or_else(|| (s.clock)(), |(j, _)| j.now().to_string()); + let sidecars = if choice.read_metadata { + metadata + .into_iter() + .filter_map(|m| { + let p = s.catalog.photo(PhotoId(m.id))?; + Some((p.id, m.path, s.sidecar_naming(p.id), p.kind == lightcraft_catalog::MediaKind::Raw)) + }) + .collect() + } else { + Vec::new() + }; + let work = SyncWork { + import, + moved: if choice.relink_moved { moved } else { Vec::new() }, + missing: if choice.remove_missing { missing } else { Vec::new() }, + sidecars, + }; + Ok((work, SyncCommit { opts, now, mark: s.commits(), report: SyncReport::default() })) + } +} + +impl SyncWork { + /// Do the work, handing each step to `send` (`false`: nobody takes them any more — stop). + /// Stops before the next piece once `progress.files.cancel` is set; what was handed over + /// stays handed over. + pub fn run(self, progress: &SyncProgress, mut send: impl FnMut(SyncStep) -> bool) { + use std::sync::atomic::Ordering::Relaxed; + let cancel = &progress.files.cancel; + let SyncWork { import, moved, missing, sidecars } = self; + let pieces = import.as_ref().map_or(0, |(_, f)| f.len()) + moved.len() + missing.len() + sidecars.len(); + progress.to_check.store(pieces, Relaxed); + progress.checked.store(0, Relaxed); + let step = |n: usize| { + progress.checked.fetch_add(n, Relaxed); + }; + if let Some((mut job, files)) = import { + for chunk in files.chunks(crate::import::batch_size()) { + if cancel.load(Relaxed) { + return; + } + let prepared = job.prepare_files(chunk.to_vec(), cancel); + step(chunk.len()); + // (the files are added in place: a batch nobody takes leaves nothing behind on disk) + if !send(SyncStep::Import(prepared)) { + return; + } + } + } + if cancel.load(Relaxed) { + return; + } + // still where the scan found it, and still gone from where the photo says + let relink: Vec<(PhotoId, String)> = + moved.into_iter().filter(|m| Path::new(&m.to).is_file() && !Path::new(&m.from).exists()).map(|m| (PhotoId(m.id), m.to)).collect(); + step(relink.len()); + if !relink.is_empty() && !send(SyncStep::Relink(relink)) { + return; + } + if cancel.load(Relaxed) { + return; + } + let gone: Vec = missing.iter().filter(|m| !Path::new(&m.path).exists()).map(|m| PhotoId(m.id)).collect(); + step(missing.len()); + if !gone.is_empty() && !send(SyncStep::Remove(gone)) { + return; + } + let mut read = Vec::with_capacity(sidecars.len()); + for (id, path, naming, raw) in sidecars { + if cancel.load(Relaxed) { + break; + } + read.push((id, path.clone(), read_sidecar(&path, naming, raw))); + step(1); + } + if !read.is_empty() { + send(SyncStep::Sidecars(read)); + } + } +} + +/// Read and parse photo file `path`'s XMP sidecar file. +fn read_sidecar(path: &str, naming: SidecarNaming, raw: bool) -> std::result::Result { + let file = crate::sidecar::find_sidecar(path, naming).ok_or_else(|| "no XMP sidecar".to_string())?; + let len = std::fs::metadata(&file).map_err(|e| format!("{}: {e}", file.display()))?.len(); + if len > MAX_SIDECAR_BYTES { + return Err(format!("{}: too large for an XMP sidecar", file.display())); + } + let text = std::fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))?; + crate::sidecar::parse_sidecar(&text, raw).map_err(|e| format!("{}: {e}", file.display())) +} + +impl SyncCommit { + /// Put a step into the catalog (no file is touched). A part that fails is reported in the + /// end, and the rest is still done. + pub fn apply(&mut self, s: &mut Session, step: SyncStep) { + let report = &mut self.report; + match step { + SyncStep::Import(prepared) => match crate::import::commit_prepared(s, &self.opts, &self.now, prepared) { + Ok(r) => { + report.imported += r.imported.len(); + report.failed.extend(r.failed); + } + Err(e) => report.failed.push((String::new(), e.to_string())), + }, + SyncStep::Relink(moved) => { + let ops: Vec = + moved.iter().filter(|(id, _)| s.catalog.photo(*id).is_some()).map(|(id, to)| crate::cmd::missing::relink_op(*id, to)).collect(); + let n = ops.len(); + if n > 0 { + match s.commit("Relink Moved Photos", Op::Batch { ops }) { + Ok(()) => report.relinked += n, + Err(e) => report.failed.push((String::new(), e.to_string())), + } + } + } + SyncStep::Remove(gone) => { + let ops: Vec = gone + .iter() + .filter(|id| s.catalog.photo(**id).is_some_and(|p| p.in_library())) + .map(|id| Op::SetDeleted { id: *id, deleted: true }) + .collect(); + let n = ops.len(); + if n > 0 { + match s.commit("Remove Missing Photos", Op::Batch { ops }) { + Ok(()) => report.removed += n, + Err(e) => report.failed.push((String::new(), e.to_string())), + } + } + } + SyncStep::Sidecars(read) => { + let mut ops = Vec::new(); + for (id, path, sc) in read { + match sc.map_err(EngineError::Other).and_then(|sc| s.sidecar_op(id, sc)) { + Ok(op) => ops.push(op), + Err(e) => report.failed.push((path, e.to_string())), + } + } + let n = ops.len(); + if n > 0 { + match s.commit("Read Metadata from File", Op::Batch { ops }) { + Ok(()) => report.read += n, + Err(e) => report.failed.push((String::new(), e.to_string())), + } + } + } + } + } + + /// What was done, as one undo step ("Synchronize Folder"). + pub fn finish(self, s: &mut Session) -> SyncReport { + let steps = usize::try_from(s.commits().wrapping_sub(self.mark)).unwrap_or(usize::MAX).min(s.undo.len()); + s.merge_undo(steps, "Synchronize Folder"); + if let Some(last) = s.undo.last_mut().filter(|_| steps == 1) { + last.label = "Synchronize Folder".into(); + } + self.report } - let mark = s.commits(); - let mut report = SyncReport::default(); - let FolderChanges { new, missing, metadata, moved, probes, .. } = changes; - if choice.import_new && !new.is_empty() { - s.import_probes = probes; - let files: Vec = new.into_iter().map(|c| c.path).collect(); - let r = crate::import::import_with(s, &files, &ImportOptions { mode: ImportMode::Add, ..Default::default() })?; - report.imported = r.imported.len(); - report.failed.extend(r.failed); - } - if choice.relink_moved { - // still where the scan found it, and still gone from where the photo says - let ops: Vec = moved - .iter() - .filter(|m| Path::new(&m.to).is_file() && !Path::new(&m.from).exists() && s.catalog.photo(PhotoId(m.id)).is_some()) - .map(|m| crate::cmd::missing::relink_op(PhotoId(m.id), &m.to)) - .collect(); - if !ops.is_empty() { - let n = ops.len(); - match s.commit("Relink Moved Photos", Op::Batch { ops }) { - Ok(()) => report.relinked = n, - Err(e) => report.failed.push((String::new(), e.to_string())), - } - } - } - if choice.remove_missing { - let gone: Vec = missing.iter().filter(|m| !Path::new(&m.path).exists()).map(|m| PhotoId(m.id)).collect(); - let ops: Vec = gone - .iter() - .filter(|id| s.catalog.photo(**id).is_some_and(|p| p.in_library())) - .map(|id| Op::SetDeleted { id: *id, deleted: true }) - .collect(); - if !ops.is_empty() { - let n = ops.len(); - match s.commit("Remove Missing Photos", Op::Batch { ops }) { - Ok(()) => report.removed = n, - Err(e) => report.failed.push((String::new(), e.to_string())), - } - } - } - if choice.read_metadata { - let mut ops = Vec::new(); - for m in &metadata { - match s.read_sidecar_op(PhotoId(m.id)) { - Ok(Some((op, _))) => { - ops.push(op); - report.read += 1; - } - Ok(None) => report.failed.push((m.path.clone(), "no XMP sidecar".into())), - Err(e) => report.failed.push((m.path.clone(), e.to_string())), - } - } - if !ops.is_empty() - && let Err(e) = s.commit("Read Metadata from File", Op::Batch { ops }) - { - report.read = 0; - report.failed.push((String::new(), e.to_string())); - } - } - // (what was done stays one step even when a later part failed: the report says which) - let steps = usize::try_from(s.commits().wrapping_sub(mark)).unwrap_or(usize::MAX).min(s.undo.len()); - s.merge_undo(steps, "Synchronize Folder"); - if let Some(last) = s.undo.last_mut().filter(|_| steps == 1) { - last.label = "Synchronize Folder".into(); - } - Ok(report) } diff --git a/crates/engine/src/tests_sync.rs b/crates/engine/src/tests_sync.rs index 724487ff2..dd6ab7ac1 100644 --- a/crates/engine/src/tests_sync.rs +++ b/crates/engine/src/tests_sync.rs @@ -466,3 +466,75 @@ fn face_regions_found_meanwhile_leave_the_scan_current() { let r = s.execute("folder.synchronize", &json!({"path": dir.path("trip"), "scanned": true})).unwrap(); assert_eq!(r["imported"], 1, "{r}"); } + +/// Synchronizing in halves, as the app does it: readied on the UI thread without touching the +/// disk, the work (every file read) on a worker, each step committed back on the UI thread. +fn sync_in_halves(s: &mut Session, path: &str, choice: crate::sync::SyncChoice) -> crate::sync::SyncReport { + let changes = crate::sync::scan(s, path, false).unwrap(); + let (work, mut commit) = crate::sync::SyncJob::start(s, changes, choice).unwrap(); + let progress = crate::sync::SyncProgress::default(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || work.run(&progress, |step| tx.send(step).is_ok())).join().unwrap(); + for step in rx { + commit.apply(s, step); + } + commit.finish(s) +} + +#[test] +fn the_work_of_synchronizing_runs_apart_from_its_commits() { + let dir = Scratch::new("halves"); + let mut s = library(&dir); + let before = holdings(&s); + write_png(&dir.path("trip/c.png"), 3); + std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap(); + write_sidecar(&dir.path("trip/a.png"), 5, Duration::ZERO); + let choice = crate::sync::SyncChoice { import_new: true, relink_moved: true, remove_missing: true, read_metadata: true }; + let r = sync_in_halves(&mut s, &dir.path("trip"), choice); + assert_eq!((r.imported, r.removed, r.read), (1, 1, 1), "{r:?}"); + assert_eq!(s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().rating, 5); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(holdings(&s), before, "still one undo step"); +} + +#[test] +fn readying_a_synchronize_reads_no_file() { + let dir = Scratch::new("ready"); + let mut s = library(&dir); + write_png(&dir.path("trip/c.png"), 3); + let changes = crate::sync::scan(&mut s, &dir.path("trip"), false).unwrap(); + // the file goes away between readying (UI thread) and the work (worker): the work finds out + let (work, mut commit) = crate::sync::SyncJob::start(&mut s, changes, crate::sync::SyncChoice::default()).unwrap(); + std::fs::remove_file(dir.path("trip/c.png")).unwrap(); + let mut steps = Vec::new(); + work.run(&crate::sync::SyncProgress::default(), |step| { + steps.push(step); + true + }); + for step in steps { + commit.apply(&mut s, step); + } + let r = commit.finish(&mut s); + assert_eq!(r.imported, 0, "{r:?}"); + assert_eq!(r.failed.len(), 1, "{r:?}"); +} + +#[test] +fn a_cancelled_synchronize_does_nothing_more() { + let dir = Scratch::new("cancel-run"); + let mut s = library(&dir); + let steps_before = s.undo.len(); + write_png(&dir.path("trip/c.png"), 3); + let changes = crate::sync::scan(&mut s, &dir.path("trip"), false).unwrap(); + let (work, commit) = crate::sync::SyncJob::start(&mut s, changes, crate::sync::SyncChoice::default()).unwrap(); + let progress = crate::sync::SyncProgress::default(); + progress.files.cancel.store(true, std::sync::atomic::Ordering::Relaxed); + let mut sent = 0; + work.run(&progress, |_| { + sent += 1; + true + }); + assert_eq!(sent, 0); + let r = commit.finish(&mut s); + assert_eq!((r.imported, s.undo.len()), (0, steps_before)); +} From d43efe713a61530e30dd923d17a2cff80838d2f3 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 18:43:39 -0400 Subject: [PATCH 20/28] UI: Synchronize Folder runs in the background, with progress and Cancel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synchronize used to run folder.synchronize on the UI thread: the import of the new files, the existence checks and the sidecar reads all made the window wait. Confirming now only takes the dialog's scan and hands its SyncWork to a worker thread; the dialog closes at once, a progress window ("Synchronizing… n / m", Cancel) shows while it runs, the steps are committed between frames through execute_fn (auto-write, durable save, panic guard) and the whole run is one undo step, with a toast saying what was done. Cancel stops before the next piece; what was done stays. A stale scan is still refused and made again. Headless `busy` now waits for the scan and the run. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de.json | 8 +- crates/ui-egui/locales/es.json | 8 +- crates/ui-egui/locales/ja.json | 8 +- crates/ui-egui/locales/pt-br.json | 8 +- crates/ui-egui/locales/ru.json | 9 +- crates/ui-egui/locales/zh-hans.json | 8 +- crates/ui-egui/locales/zh-hant.json | 8 +- crates/ui-egui/src/headless.rs | 2 + crates/ui-egui/src/lib.rs | 5 + crates/ui-egui/src/sync.rs | 161 ++++++++++++++++++++++++---- crates/ui-egui/src/tests_sync.rs | 46 ++++++-- 11 files changed, 238 insertions(+), 33 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index baadd1afa..475b96bfc 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1905,5 +1905,11 @@ "Skipped: files already in the library elsewhere": "Übersprungen: Dateien, die bereits an anderer Stelle in der Bibliothek sind", "Skipped: files that can't be read": "Übersprungen: Dateien, die nicht gelesen werden können", "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Dieser Ordner ist nicht vorhanden: Er wurde verschoben oder umbenannt, oder sein Datenträger ist nicht verbunden.", - "Relink photos whose file was renamed or moved": "Fotos neu verknüpfen, deren Datei umbenannt oder verschoben wurde" + "Relink photos whose file was renamed or moved": "Fotos neu verknüpfen, deren Datei umbenannt oder verschoben wurde", + "A folder is already being synchronized": "Es wird bereits ein Ordner synchronisiert", + "Synchronizing…": "Wird synchronisiert…", + "Relinked": "Neu verknüpft", + "Removed": "Entfernt", + "Metadata read": "Metadaten gelesen", + "Failed": "Fehlgeschlagen" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 037e0644b..a84c43613 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1750,5 +1750,11 @@ "Skipped: files already in the library elsewhere": "Omitidos: archivos que ya están en la biblioteca en otro lugar", "Skipped: files that can't be read": "Omitidos: archivos que no se pueden leer", "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Esta carpeta no está: se movió o se le cambió el nombre, o su disco no está conectado.", - "Relink photos whose file was renamed or moved": "Volver a vincular fotos cuyo archivo se renombró o movió" + "Relink photos whose file was renamed or moved": "Volver a vincular fotos cuyo archivo se renombró o movió", + "A folder is already being synchronized": "Ya se está sincronizando una carpeta", + "Synchronizing…": "Sincronizando…", + "Relinked": "Revinculadas", + "Removed": "Quitadas", + "Metadata read": "Metadatos leídos", + "Failed": "Con errores" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index dfd14f8ca..b3321139b 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1750,5 +1750,11 @@ "Skipped: files already in the library elsewhere": "スキップ: 別の場所ですでにライブラリにあるファイル", "Skipped: files that can't be read": "スキップ: 読み込めないファイル", "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "このフォルダが見つかりません。移動または名前の変更がされたか、ディスクが接続されていません。", - "Relink photos whose file was renamed or moved": "ファイル名の変更や移動があった写真をリンクし直す" + "Relink photos whose file was renamed or moved": "ファイル名の変更や移動があった写真をリンクし直す", + "A folder is already being synchronized": "すでにフォルダを同期中です", + "Synchronizing…": "同期中…", + "Relinked": "リンクし直し", + "Removed": "除去", + "Metadata read": "メタデータ読み込み", + "Failed": "失敗" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 7c7769ad3..9039c17ae 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1750,5 +1750,11 @@ "Skipped: files already in the library elsewhere": "Ignorados: arquivos que já estão na biblioteca em outro lugar", "Skipped: files that can't be read": "Ignorados: arquivos que não podem ser lidos", "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Esta pasta não está lá: foi movida ou renomeada, ou o disco dela não está conectado.", - "Relink photos whose file was renamed or moved": "Vincular de novo fotos cujo arquivo foi renomeado ou movido" + "Relink photos whose file was renamed or moved": "Vincular de novo fotos cujo arquivo foi renomeado ou movido", + "A folder is already being synchronized": "Uma pasta já está sendo sincronizada", + "Synchronizing…": "Sincronizando…", + "Relinked": "Revinculadas", + "Removed": "Removidas", + "Metadata read": "Metadados lidos", + "Failed": "Com falha" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 8bcab8f60..3e4e5c714 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1423,5 +1423,12 @@ "Skipped: files already in the library elsewhere": "Пропущено: файлы, которые уже есть в медиатеке в другом месте", "Skipped: files that can't be read": "Пропущено: файлы, которые не удаётся прочитать", "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Этой папки нет: её переместили или переименовали, или её диск не подключён.", - "Relink photos whose file was renamed or moved": "Заново связать фото, файл которых переименован или перемещён" + "Relink photos whose file was renamed or moved": "Заново связать фото, файл которых переименован или перемещён", + "A folder is already being synchronized": "Папка уже синхронизируется", + "Synchronizing…": "Синхронизация…", + "Imported": "Импортировано", + "Relinked": "Связано заново", + "Removed": "Убрано", + "Metadata read": "Метаданные прочитаны", + "Failed": "Ошибки" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ef6ab58c3..b143b3118 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1750,5 +1750,11 @@ "Skipped: files already in the library elsewhere": "已跳过:已在图库其他位置的文件", "Skipped: files that can't be read": "已跳过:无法读取的文件", "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "此文件夹不存在:它已被移动或重命名,或其磁盘未连接。", - "Relink photos whose file was renamed or moved": "重新链接文件被重命名或移动的照片" + "Relink photos whose file was renamed or moved": "重新链接文件被重命名或移动的照片", + "A folder is already being synchronized": "已有文件夹正在同步", + "Synchronizing…": "正在同步…", + "Relinked": "已重新链接", + "Removed": "已移除", + "Metadata read": "已读取元数据", + "Failed": "失败" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index a59bc8fb7..4284d1885 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1750,5 +1750,11 @@ "Skipped: files already in the library elsewhere": "已略過:已在照片庫其他位置的檔案", "Skipped: files that can't be read": "已略過:無法讀取的檔案", "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "此資料夾不存在:它已被移動或重新命名,或其磁碟未連接。", - "Relink photos whose file was renamed or moved": "重新連結檔案被重新命名或移動的照片" + "Relink photos whose file was renamed or moved": "重新連結檔案被重新命名或移動的照片", + "A folder is already being synchronized": "已有資料夾正在同步", + "Synchronizing…": "正在同步…", + "Relinked": "已重新連結", + "Removed": "已移除", + "Metadata read": "已讀取中繼資料", + "Failed": "失敗" } diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index e6929269f..e5c6427ed 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -266,6 +266,8 @@ impl Headless { || crate::panels::left::fs_cached_running(&self.view.ctx) > 0 || self.app.merge.busy() || self.app.scan.is_some() + || self.app.sync.is_some() + || self.app.sync_run.is_some() || self.app.import.is_some() || self.app.export.is_some() || self.app.session.denoise_busy() diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8f1b41822..503c50b28 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -247,6 +247,8 @@ pub struct LightcraftApp { pub sync: Option, /// `session.folder_changes` came from the Synchronize Folder dialog (and goes with it). pub sync_owns_changes: bool, + /// A Synchronize Folder at work in the background. + pub sync_run: Option, /// A Lightroom catalog inspect/import in progress. pub lightroom: Option, /// Last terminal Lightroom result, exposed by the command's status/wait response. @@ -320,6 +322,7 @@ impl LightcraftApp { scan: None, sync: None, sync_owns_changes: false, + sync_run: None, lightroom: None, lightroom_last: None, export: None, @@ -744,6 +747,7 @@ impl LightcraftApp { merge::poll(self, ctx); import::poll_scan(self, ctx); sync::poll(self, ctx); + sync::poll_run(self, ctx); import::tick(self, ctx); lightroom_import::tick(self, ctx); tasks::poll(self, ctx); @@ -960,6 +964,7 @@ impl LightcraftApp { panels::dialogs::show(self, &ctx); panels::library_problem::show(self, &ctx); import::progress(self, &ctx); + sync::progress_window(self, &ctx); import::scan_progress(self, &ctx); lightroom_import::progress(self, &ctx); export_task::poll(self, &ctx); diff --git a/crates/ui-egui/src/sync.rs b/crates/ui-egui/src/sync.rs index c44bc9421..22d31b098 100644 --- a/crates/ui-egui/src/sync.rs +++ b/crates/ui-egui/src/sync.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use std::sync::atomic::Ordering; -use lightcraft_engine::sync::{FolderChanges, SyncChoice, SyncInput, SyncProgress, scan_with}; +use lightcraft_engine::sync::{FolderChanges, SyncChoice, SyncCommit, SyncInput, SyncJob, SyncProgress, SyncStep, scan_with}; use serde_json::{Value, json}; use crate::LightcraftApp; @@ -162,28 +162,151 @@ pub fn body(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) { } } -/// Synchronize: `folder.synchronize` with the dialog's choices. +/// 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. pub fn confirm(app: &mut LightcraftApp, dlg: &Dialog) -> Result { - let Dialog::SynchronizeFolder { path, disk, counts: Some(_), import_new, relink_moved, remove_missing, read_metadata, .. } = dlg else { + let Dialog::SynchronizeFolder { path, name, disk, counts: Some(_), import_new, relink_moved, remove_missing, read_metadata, .. } = dlg else { return Err("the folder is still being scanned".into()); }; - let r = app.run( - "folder.synchronize", - json!({"path": path, "disk": disk, "scanned": true, "importNew": import_new, "relinkMoved": relink_moved, "removeMissing": remove_missing, "readMetadata": read_metadata}), - ); + if app.sync_run.is_some() { + return Err(crate::i18n::tr("A folder is already being synchronized").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; - if r.is_err() { - // (the folder changed since the scan, or went offline): look again, in the background, - // keeping what was ticked; the error is what is reported, not a failure to look again - let name = match dlg { - Dialog::SynchronizeFolder { name, .. } => name.clone(), - _ => String::new(), - }; - 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); + 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 (tx, rx) = std::sync::mpsc::channel(); + let p = progress.clone(); + let job = move || { + // (a panic in the work ends it; what was handed over is still committed) + let _ = lightcraft_engine::guard::catch("synchronize", || work.run(&p, |step| tx.send(step).is_ok())); + }; + #[cfg(not(target_arch = "wasm32"))] + std::thread::spawn(job); + #[cfg(target_arch = "wasm32")] + job(); + app.sync_run = Some(SyncRun { name: name.clone(), progress, rx, commit: Some(commit), stopping: false }); + 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, + rx: std::sync::mpsc::Receiver, + commit: Option, + /// Cancel was pressed: nothing new is started; what was readied is still committed. + stopping: bool, +} + +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)); + 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 }; + for step in steps { + let _ = app.session.execute_fn("folder.synchronize", |s| { + commit.apply(s, step); + Ok(Value::Null) + }); + } + if !finished { + if let Some(run) = app.sync_run.as_mut() { + run.commit = Some(commit); + } + return; + } + let name = app.sync_run.take().map(|r| r.name.clone()).unwrap_or_default(); + let mut report = None; + let _ = app.session.execute_fn("folder.synchronize", |s| { + report = Some(commit.finish(s)); + Ok(Value::Null) + }); + let Some(r) = report else { return }; + for (path, e) in &r.failed { + log::warn!("synchronize {name}: {path}: {e}"); + } + let 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(); + let text = if parts.is_empty() { crate::i18n::tr("The library is up to date with this folder.").to_string() } else { parts.join(" · ") }; + app.toast(ctx, format!("{name}: {text}")); +} + +/// The progress window while a synchronize runs, with Cancel (what was done stays, as one undo +/// step; nothing new is started). +pub fn progress_window(app: &mut LightcraftApp, ctx: &egui::Context) { + let Some(run) = &app.sync_run else { return }; + let t = Tokens::get(ctx); + let (done, total) = run.counts(); + let text = + if run.stopping { crate::i18n::tr("Stopping…").to_string() } else { format!("{} {done} / {total}", crate::i18n::tr("Synchronizing…")) }; + let frac = if total == 0 { 0.0 } else { done as f32 / total as f32 }; + let stopping = run.stopping; + let mut cancel = false; + egui::Window::new(crate::i18n::tr("Synchronize Folder")) + .id(egui::Id::new("sync-progress")) + .title_bar(false) + .resizable(false) + .anchor(egui::Align2::CENTER_BOTTOM, [0.0, -80.0]) + .fixed_size([340.0, 80.0]) + .show(ctx, |ui| { + ui.label(egui::RichText::new(text).color(t.text)); + ui.add(egui::ProgressBar::new(frac).desired_width(320.0)); + let r = ui.add_enabled(!stopping, egui::Button::new(crate::i18n::tr("Cancel"))); + crate::widgets::register(ui.ctx(), "button:syncCancel", r.rect); + cancel = r.clicked(); + }); + if cancel && let Some(run) = app.sync_run.as_mut() { + run.cancel(); } - r } diff --git a/crates/ui-egui/src/tests_sync.rs b/crates/ui-egui/src/tests_sync.rs index d4ccd0ad3..3873fbec1 100644 --- a/crates/ui-egui/src/tests_sync.rs +++ b/crates/ui-egui/src/tests_sync.rs @@ -8,6 +8,9 @@ //! * Synchronize with the defaults imports the new photos and leaves missing ones alone. //! * Ticking "Remove missing photos" also moves those to Recently Deleted. //! * Cancel leaves the library as it was. +//! * Synchronize hands the work to the background at once: the dialog closes, the app keeps +//! answering, and the photos arrive over the next frames under a progress window, as one undo +//! step. use std::time::Duration; @@ -93,6 +96,19 @@ fn open_and_scan(h: &mut Headless, folder: &str) { assert!(scanned, "the scan finished: {:?}", h.app.ui.dialog); } +/// Synchronize: the dialog closes and the work goes to the background at once (nothing has +/// changed yet when the confirm returns); then wait for it. +fn synchronize(h: &mut Headless) { + let before = in_library(h); + // the dialog's confirm itself, between frames: it only hands the work over + let dlg = h.app.ui.dialog.take().expect("the dialog is open"); + let r = crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg); + assert!(r.is_ok(), "{r:?}"); + assert!(h.app.sync_run.is_some(), "the work runs in the background"); + assert_eq!(in_library(h), before, "nothing was done on the UI thread"); + assert!(h.step_until(T, |h| h.app.sync_run.is_none()), "the work finishes"); +} + fn counts(h: &Headless) -> crate::state::SyncCounts { match &h.app.ui.dialog { Some(Dialog::SynchronizeFolder { counts: Some(c), .. }) => c.clone(), @@ -115,10 +131,8 @@ fn synchronizing_with_the_defaults_imports_the_new_photos_only() { let dir = Scratch::new("defaults"); let mut h = changed_folder(&dir); open_and_scan(&mut h, &dir.path("trip")); - let r = h.request("ui.dialog.confirm", json!({}), T); - assert_eq!(r["ok"], true, "{r}"); + synchronize(&mut h); assert_eq!(in_library(&h), vec!["a.png", "b.png", "c.png"], "c came in; the missing b stays"); - assert!(h.app.ui.dialog.is_none()); } #[test] @@ -129,8 +143,7 @@ fn ticking_remove_missing_also_removes_the_missing_photos() { let r = h.request("ui.clickWidget", json!({"id": "syncRemoveMissing"}), T); assert_eq!(r["ok"], true, "{r}"); h.step(); - let r = h.request("ui.dialog.confirm", json!({}), T); - assert_eq!(r["ok"], true, "{r}"); + synchronize(&mut h); assert_eq!(in_library(&h), vec!["a.png", "c.png"], "b went to Recently Deleted"); let r = h.request("engine.execute", json!({"command": "edit.undo", "params": {}}), T); assert_eq!(r["ok"], true, "{r}"); @@ -181,7 +194,26 @@ fn a_scan_gone_stale_in_the_dialog_is_made_again_in_the_background() { let rescanned = h.step_until(T, |h| matches!(&h.app.ui.dialog, Some(Dialog::SynchronizeFolder { counts: Some(_), .. }))); assert!(rescanned, "and scans again"); assert!(matches!(h.app.ui.dialog, Some(Dialog::SynchronizeFolder { remove_missing: true, .. })), "keeping what was ticked"); - let r = h.request("ui.dialog.confirm", json!({}), T); - assert_eq!(r["ok"], true, "{r}"); + synchronize(&mut h); assert_eq!(in_library(&h), vec!["a.png", "c.png"], "c came in, the missing b went"); } + +#[test] +fn the_progress_window_can_stop_a_synchronize() { + let dir = Scratch::new("stop"); + let mut h = changed_folder(&dir); + open_and_scan(&mut h, &dir.path("trip")); + let steps = h.app.session.undo.len(); + let dlg = h.app.ui.dialog.take().expect("the dialog is open"); + assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_ok()); + // Cancel before the first frame commits anything + h.app.sync_run.as_mut().expect("running").cancel(); + assert!(h.step_until(T, |h| h.app.sync_run.is_none()), "it ends"); + // whatever was done by then is at most one undo step + assert!(h.app.session.undo.len() <= steps + 1); + if h.app.session.undo.len() == steps + 1 { + let r = h.request("engine.execute", json!({"command": "edit.undo", "params": {}}), T); + assert_eq!(r["ok"], true, "{r}"); + } + assert_eq!(in_library(&h), vec!["a.png", "b.png"]); +} From 4153f62bb5eeffdc7be7280eb4ea10ebae489733 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 18:44:18 -0400 Subject: [PATCH 21/28] Docs: Synchronize Folder runs in the background Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/sync.rs | 6 ++++-- docs/parity.md | 2 +- docs/whats-new.md | 5 +++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs index 3c94e35a8..a1e1c799c 100644 --- a/crates/engine/src/sync.rs +++ b/crates/engine/src/sync.rs @@ -13,8 +13,10 @@ //! label, metadata, capture time or develop settings). Reading one makes the sidecar win, as //! Read Metadata from File does. Embedded XMP in a raw is not a sidecar and is not looked at. //! -//! [`synchronize`] then imports the new files (in place), moves the missing photos to Recently -//! Deleted and reads the metadata updates — each only when chosen — as one undo step. +//! [`synchronize`] then imports the new files (in place), relinks the moved photos, moves the +//! missing photos to Recently Deleted and reads the metadata updates — each only when chosen — as +//! one undo step. It runs in halves ([`SyncJob`]) so that, in the app, every file it reads is +//! read on a worker thread and only the catalog changes happen on the UI thread. //! //! The scan runs without the session ([`SyncInput`] + [`scan_with`]), so the app can run it on a //! worker thread: a folder on a sleeping network share must never stall a frame. diff --git a/docs/parity.md b/docs/parity.md index 7de7a7601..139398498 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -743,7 +743,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages, in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs | | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`) | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | -| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize imports on the UI thread once confirmed (its scan does not), has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files isn't cancelled midway and its depth/entry limits are only logged | +| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames under a progress window with Cancel, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files isn't cancelled midway and its depth/entry limits are only logged | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | | LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 30 fields (rating, flag, label, type, edits, keywords, any text, filename, file path, format, title, caption, camera, lens, location, creator, copyright, copyright status, capture / import / edit date, ISO, aperture, focal length, megapixels, GPS, virtual copy, album) with text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`) | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | diff --git a/docs/whats-new.md b/docs/whats-new.md index e0a1cc836..260ace3fb 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -7,8 +7,9 @@ Folder…. The folder and the folders inside it are scanned in the background for photos added on disk, photos whose file is gone, photos whose file was renamed or moved within the folder, and XMP sidecars changed by other apps; then choose what to do: import the new photos and relink the moved ones (the defaults), move the missing ones to Recently - Deleted, read the metadata updates. A folder on a disk that isn't connected is reported as such. Everything is one undo step, and no file on - disk is touched. Agents use `folder.scanChanges` and `folder.synchronize`. + Deleted, read the metadata updates. A folder on a disk that isn't connected is reported as such. Synchronizing + runs in the background with a progress window and Cancel; everything it does is one undo step, and no file on disk + is touched. Agents use `folder.scanChanges` and `folder.synchronize`. ### Folder colour labels - Right-click a folder (or a disk other than the startup disk) in the sidebar's Folders section ▸ Set Color Label ▸ a colour or None. The row From f7828fdfbcc0495db3c884872e6b0d64f0fcea81 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 18:48:58 -0400 Subject: [PATCH 22/28] UI tests: wait for the Remove missing tick to land before going on A click is queued input: under a loaded test run it could land after the stale confirm had replaced the dialog, on a box that was disabled while scanning, so the choice the test checks was never made. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tests_sync.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/tests_sync.rs b/crates/ui-egui/src/tests_sync.rs index 3873fbec1..bf1c33867 100644 --- a/crates/ui-egui/src/tests_sync.rs +++ b/crates/ui-egui/src/tests_sync.rs @@ -109,6 +109,14 @@ fn synchronize(h: &mut Headless) { assert!(h.step_until(T, |h| h.app.sync_run.is_none()), "the work finishes"); } +/// Tick "Remove missing photos" and wait until the click has landed (a click is queued input). +fn tick_remove_missing(h: &mut Headless) { + let r = h.request("ui.clickWidget", json!({"id": "syncRemoveMissing"}), T); + assert_eq!(r["ok"], true, "{r}"); + let ticked = h.step_until(T, |h| matches!(h.app.ui.dialog, Some(Dialog::SynchronizeFolder { remove_missing: true, .. }))); + assert!(ticked, "ticked"); +} + fn counts(h: &Headless) -> crate::state::SyncCounts { match &h.app.ui.dialog { Some(Dialog::SynchronizeFolder { counts: Some(c), .. }) => c.clone(), @@ -140,9 +148,7 @@ fn ticking_remove_missing_also_removes_the_missing_photos() { let dir = Scratch::new("remove"); let mut h = changed_folder(&dir); open_and_scan(&mut h, &dir.path("trip")); - let r = h.request("ui.clickWidget", json!({"id": "syncRemoveMissing"}), T); - assert_eq!(r["ok"], true, "{r}"); - h.step(); + tick_remove_missing(&mut h); synchronize(&mut h); assert_eq!(in_library(&h), vec!["a.png", "c.png"], "b went to Recently Deleted"); let r = h.request("engine.execute", json!({"command": "edit.undo", "params": {}}), T); @@ -180,9 +186,7 @@ fn a_scan_gone_stale_in_the_dialog_is_made_again_in_the_background() { let dir = Scratch::new("restale"); let mut h = changed_folder(&dir); open_and_scan(&mut h, &dir.path("trip")); - let r = h.request("ui.clickWidget", json!({"id": "syncRemoveMissing"}), T); - assert_eq!(r["ok"], true, "{r}"); - h.step(); + tick_remove_missing(&mut h); // a photo of the folder changes while the dialog is open let a = h.app.session.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id; let r = h.request("engine.execute", json!({"command": "photo.rate", "params": {"ids": [a.0], "rating": 3}}), T); From 05178a783f05aa8470f2160244466f0b2def1b06 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 18:52:26 -0400 Subject: [PATCH 23/28] Synchronize Folder: steps never land in another library or swallow an Undo (review fixes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With the work in the background, File › Open Library during a run sent its remaining steps into the newly opened catalog, where the same photo ids are other photos: removals, relinks and sidecar reads hit unrelated photos. SyncCommit now remembers the library it was readied for (library_identity, as the Lightroom import job does) and drops steps for any other, saying so in the report. An Undo (or an edit) made between two of the run's steps was merged into the "Synchronize Folder" step, so undoing it undid that earlier, unrelated step too. The run's steps are merged only while they are contiguous on the history; otherwise each stays its own step. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/sync.rs | 44 +++++++++++++++++++++--- crates/engine/src/tests_sync.rs | 59 +++++++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs index a1e1c799c..d4f638955 100644 --- a/crates/engine/src/sync.rs +++ b/crates/engine/src/sync.rs @@ -468,7 +468,15 @@ pub struct SyncWork { pub struct SyncCommit { opts: ImportOptions, now: String, - mark: u64, + /// The library the run was readied for: steps never land in another one (File › Open + /// Library while the work runs), whose photo ids name other photos. + library: Arc<()>, + /// The run's own undo steps so far, and the history's length after the last of them: they + /// are merged only while nothing else came in between (an Undo or an edit made meanwhile + /// stays its own step, and the run's parts stay theirs). + own: usize, + after_last: Option, + interleaved: bool, report: SyncReport, } @@ -517,7 +525,16 @@ impl SyncJob { missing: if choice.remove_missing { missing } else { Vec::new() }, sidecars, }; - Ok((work, SyncCommit { opts, now, mark: s.commits(), report: SyncReport::default() })) + let commit = SyncCommit { + opts, + now, + library: Arc::clone(&s.library_identity), + own: 0, + after_last: None, + interleaved: false, + report: SyncReport::default(), + }; + Ok((work, commit)) } } @@ -595,6 +612,22 @@ impl SyncCommit { /// Put a step into the catalog (no file is touched). A part that fails is reported in the /// end, and the rest is still done. pub fn apply(&mut self, s: &mut Session, step: SyncStep) { + if !Arc::ptr_eq(&self.library, &s.library_identity) { + self.report.failed.push((String::new(), "another library was opened: the rest of the synchronize was dropped".into())); + return; + } + let commits = s.commits(); + if self.after_last.is_some_and(|n| n != s.undo.len()) { + self.interleaved = true; + } + self.apply_step(s, step); + if s.commits() != commits { + self.own += usize::try_from(s.commits().wrapping_sub(commits)).unwrap_or(0); + self.after_last = Some(s.undo.len()); + } + } + + fn apply_step(&mut self, s: &mut Session, step: SyncStep) { let report = &mut self.report; match step { SyncStep::Import(prepared) => match crate::import::commit_prepared(s, &self.opts, &self.now, prepared) { @@ -649,8 +682,11 @@ impl SyncCommit { } /// What was done, as one undo step ("Synchronize Folder"). - pub fn finish(self, s: &mut Session) -> SyncReport { - let steps = usize::try_from(s.commits().wrapping_sub(self.mark)).unwrap_or(usize::MAX).min(s.undo.len()); + pub fn finish(mut self, s: &mut Session) -> SyncReport { + // a report line once, however many steps came after a library switch + self.report.failed.dedup(); + let ours = Arc::ptr_eq(&self.library, &s.library_identity) && !self.interleaved && self.after_last == Some(s.undo.len()); + let steps = if ours { self.own.min(s.undo.len()) } else { 0 }; s.merge_undo(steps, "Synchronize Folder"); if let Some(last) = s.undo.last_mut().filter(|_| steps == 1) { last.label = "Synchronize Folder".into(); diff --git a/crates/engine/src/tests_sync.rs b/crates/engine/src/tests_sync.rs index dd6ab7ac1..75d40d8cc 100644 --- a/crates/engine/src/tests_sync.rs +++ b/crates/engine/src/tests_sync.rs @@ -538,3 +538,62 @@ fn a_cancelled_synchronize_does_nothing_more() { let r = commit.finish(&mut s); assert_eq!((r.imported, s.undo.len()), (0, steps_before)); } + +/// Ready a synchronize of the trip folder and do its work, keeping the steps for the test to +/// commit when it likes. +fn readied(s: &mut Session, dir: &Scratch, choice: crate::sync::SyncChoice) -> (Vec, crate::sync::SyncCommit) { + let changes = crate::sync::scan(s, &dir.path("trip"), false).unwrap(); + let (work, commit) = crate::sync::SyncJob::start(s, changes, choice).unwrap(); + let mut steps = Vec::new(); + work.run(&crate::sync::SyncProgress::default(), |step| { + steps.push(step); + true + }); + (steps, commit) +} + +#[test] +fn steps_for_a_library_that_was_closed_meanwhile_are_not_applied() { + let dir = Scratch::new("switched"); + let mut s = library(&dir); + write_png(&dir.path("trip/c.png"), 3); + std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap(); + let choice = crate::sync::SyncChoice { remove_missing: true, ..Default::default() }; + let (steps, mut commit) = readied(&mut s, &dir, choice); + // File › Open Library while the work runs: another catalog, whose photo ids mean other photos + s.library_identity = std::sync::Arc::new(()); + let before = holdings(&s); + let undo = s.undo.len(); + for step in steps { + commit.apply(&mut s, step); + } + let r = commit.finish(&mut s); + assert_eq!(holdings(&s), before, "nothing landed in the other library"); + assert_eq!(s.undo.len(), undo); + assert_eq!((r.imported, r.removed), (0, 0), "{r:?}"); + assert!(!r.failed.is_empty(), "and the report says why: {r:?}"); +} + +#[test] +fn an_undo_pressed_while_synchronizing_is_not_folded_into_it() { + let dir = Scratch::new("undo-mid"); + let mut s = library(&dir); + let a = s.catalog.photos().find(|p| p.file_name == "a.png").unwrap().id; + s.execute("photo.rate", &json!({"ids": [a.0], "rating": 4})).unwrap(); + write_png(&dir.path("trip/c.png"), 3); + std::fs::remove_file(dir.path("trip/day1/b.png")).unwrap(); + let choice = crate::sync::SyncChoice { remove_missing: true, ..Default::default() }; + let (mut steps, mut commit) = readied(&mut s, &dir, choice); + assert!(steps.len() >= 2, "an import step and a remove step"); + let first = steps.remove(0); + commit.apply(&mut s, first); + // the person presses Undo between two steps of the run + s.execute("edit.undo", &json!({})).unwrap(); + for step in steps { + commit.apply(&mut s, step); + } + commit.finish(&mut s); + // undoing the run's last step never takes the rating with it + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.photo(a).unwrap().rating, 4, "the earlier, unrelated step stays"); +} From 0f8b23875db3385d7cbd42a07725656a239359e9 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 18:54:39 -0400 Subject: [PATCH 24/28] UI: a Synchronize Folder that was stopped, failed or not saved says so (review fixes) The end of a run threw away execute_fn's result, so a step that went into the catalog but couldn't be saved (a full disk) went unmentioned, and a run stopped with Cancel, or ended by a panic in its worker, with nothing done toasted "The library is up to date with this folder." The toast now leads with Stopped / Stopped by an error, adds Not saved with the reason (both logged, and the toast stays longer), and only says "up to date" when the run finished with nothing to do. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de.json | 5 ++- crates/ui-egui/locales/es.json | 5 ++- crates/ui-egui/locales/ja.json | 5 ++- crates/ui-egui/locales/pt-br.json | 5 ++- crates/ui-egui/locales/ru.json | 5 ++- crates/ui-egui/locales/zh-hans.json | 5 ++- crates/ui-egui/locales/zh-hant.json | 5 ++- crates/ui-egui/src/sync.rs | 64 +++++++++++++++++++++-------- crates/ui-egui/src/tests_sync.rs | 14 +++---- 9 files changed, 81 insertions(+), 32 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 475b96bfc..64c70e031 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1911,5 +1911,8 @@ "Relinked": "Neu verknüpft", "Removed": "Entfernt", "Metadata read": "Metadaten gelesen", - "Failed": "Fehlgeschlagen" + "Failed": "Fehlgeschlagen", + "Stopped": "Angehalten", + "Stopped by an error (see the log)": "Durch einen Fehler angehalten (siehe Protokoll)", + "Not saved": "Nicht gespeichert" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index a84c43613..d023cf6c4 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1756,5 +1756,8 @@ "Relinked": "Revinculadas", "Removed": "Quitadas", "Metadata read": "Metadatos leídos", - "Failed": "Con errores" + "Failed": "Con errores", + "Stopped": "Detenido", + "Stopped by an error (see the log)": "Detenido por un error (ver el registro)", + "Not saved": "Sin guardar" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index b3321139b..9c5072025 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1756,5 +1756,8 @@ "Relinked": "リンクし直し", "Removed": "除去", "Metadata read": "メタデータ読み込み", - "Failed": "失敗" + "Failed": "失敗", + "Stopped": "停止しました", + "Stopped by an error (see the log)": "エラーで停止しました(ログを参照)", + "Not saved": "保存されていません" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 9039c17ae..21a3d0c84 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1756,5 +1756,8 @@ "Relinked": "Revinculadas", "Removed": "Removidas", "Metadata read": "Metadados lidos", - "Failed": "Com falha" + "Failed": "Com falha", + "Stopped": "Interrompido", + "Stopped by an error (see the log)": "Interrompido por um erro (veja o log)", + "Not saved": "Não salvo" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 3e4e5c714..952ef5c92 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1430,5 +1430,8 @@ "Relinked": "Связано заново", "Removed": "Убрано", "Metadata read": "Метаданные прочитаны", - "Failed": "Ошибки" + "Failed": "Ошибки", + "Stopped": "Остановлено", + "Stopped by an error (see the log)": "Остановлено из-за ошибки (см. журнал)", + "Not saved": "Не сохранено" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index b143b3118..a67df15a8 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1756,5 +1756,8 @@ "Relinked": "已重新链接", "Removed": "已移除", "Metadata read": "已读取元数据", - "Failed": "失败" + "Failed": "失败", + "Stopped": "已停止", + "Stopped by an error (see the log)": "因错误而停止(见日志)", + "Not saved": "未保存" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 4284d1885..3d179de54 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1756,5 +1756,8 @@ "Relinked": "已重新連結", "Removed": "已移除", "Metadata read": "已讀取中繼資料", - "Failed": "失敗" + "Failed": "失敗", + "Stopped": "已停止", + "Stopped by an error (see the log)": "因錯誤而停止(請見記錄)", + "Not saved": "未儲存" } diff --git a/crates/ui-egui/src/sync.rs b/crates/ui-egui/src/sync.rs index 22d31b098..b60243ac1 100644 --- a/crates/ui-egui/src/sync.rs +++ b/crates/ui-egui/src/sync.rs @@ -192,17 +192,21 @@ pub fn confirm(app: &mut LightcraftApp, dlg: &Dialog) -> Result { } }; 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 = progress.clone(); + let (p, c) = (progress.clone(), crashed.clone()); let job = move || { - // (a panic in the work ends it; what was handed over is still committed) - let _ = lightcraft_engine::guard::catch("synchronize", || work.run(&p, |step| tx.send(step).is_ok())); + // (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(); - app.sync_run = Some(SyncRun { name: name.clone(), progress, rx, commit: Some(commit), stopping: false }); + app.sync_run = Some(SyncRun { name: name.clone(), progress, crashed, rx, commit: Some(commit), stopping: false, not_saved: Vec::new() }); Ok(json!({"started": true})) } @@ -211,7 +215,11 @@ pub fn confirm(app: &mut LightcraftApp, dlg: &Dialog) -> Result { 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, @@ -250,36 +258,60 @@ pub fn poll_run(app: &mut LightcraftApp, ctx: &egui::Context) { } }; let Some(mut commit) = run.commit.take() else { return }; + let mut not_saved = Vec::new(); for step in steps { - let _ = app.session.execute_fn("folder.synchronize", |s| { + // (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) - }); - } - if !finished { - if let Some(run) = app.sync_run.as_mut() { - run.commit = Some(commit); + }) { + 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 = app.sync_run.take().map(|r| r.name.clone()).unwrap_or_default(); + 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; - let _ = app.session.execute_fn("folder.synchronize", |s| { + if let Err(e) = app.session.execute_fn("folder.synchronize", |s| { report = Some(commit.finish(s)); Ok(Value::Null) - }); - let Some(r) = report else { return }; + }) { + not_saved.push(e.to_string()); + } + let r = report.unwrap_or_default(); for (path, e) in &r.failed { log::warn!("synchronize {name}: {path}: {e}"); } - let parts: Vec = + 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(" · ") }; - app.toast(ctx, format!("{name}: {text}")); + if not_saved.is_empty() && !crashed { + app.toast(ctx, format!("{name}: {text}")); + } else { + app.toast_for(ctx, format!("{name}: {text}"), 12.0); + } } /// The progress window while a synchronize runs, with Cancel (what was done stays, as one undo diff --git a/crates/ui-egui/src/tests_sync.rs b/crates/ui-egui/src/tests_sync.rs index bf1c33867..dff960bc9 100644 --- a/crates/ui-egui/src/tests_sync.rs +++ b/crates/ui-egui/src/tests_sync.rs @@ -203,21 +203,17 @@ fn a_scan_gone_stale_in_the_dialog_is_made_again_in_the_background() { } #[test] -fn the_progress_window_can_stop_a_synchronize() { +fn a_stopped_synchronize_says_it_was_stopped_and_stays_at_most_one_step() { let dir = Scratch::new("stop"); let mut h = changed_folder(&dir); open_and_scan(&mut h, &dir.path("trip")); let steps = h.app.session.undo.len(); let dlg = h.app.ui.dialog.take().expect("the dialog is open"); assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_ok()); - // Cancel before the first frame commits anything + // the progress window's Cancel, before any frame committed anything h.app.sync_run.as_mut().expect("running").cancel(); assert!(h.step_until(T, |h| h.app.sync_run.is_none()), "it ends"); - // whatever was done by then is at most one undo step - assert!(h.app.session.undo.len() <= steps + 1); - if h.app.session.undo.len() == steps + 1 { - let r = h.request("engine.execute", json!({"command": "edit.undo", "params": {}}), T); - assert_eq!(r["ok"], true, "{r}"); - } - assert_eq!(in_library(&h), vec!["a.png", "b.png"]); + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.contains("Stopped"), "never 'up to date': {toast:?}"); + assert!(h.app.session.undo.len() <= steps + 1, "what was done by then is at most one step"); } From f6bb274da3ecc08ab98a5e4bf4754a399866c444 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 18:56:04 -0400 Subject: [PATCH 25/28] UI: a Synchronize Folder and an import never run at once (review fix) Each readies its files against the library as it was when it started, so with the synchronize in the background, dropping the same folder on the window (or Auto Import) while it ran could add the same files twice. Imports (the review, dropped paths, Auto Import) wait while a folder is being synchronized, and Synchronize waits for a running import. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de.json | 4 +++- crates/ui-egui/locales/es.json | 4 +++- crates/ui-egui/locales/ja.json | 4 +++- crates/ui-egui/locales/pt-br.json | 4 +++- crates/ui-egui/locales/ru.json | 4 +++- crates/ui-egui/locales/zh-hans.json | 4 +++- crates/ui-egui/locales/zh-hant.json | 4 +++- crates/ui-egui/src/import.rs | 8 ++++++++ crates/ui-egui/src/lib.rs | 7 ++++++- crates/ui-egui/src/sync.rs | 4 ++++ crates/ui-egui/src/tests_sync.rs | 29 +++++++++++++++++++++++++++++ 11 files changed, 68 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 64c70e031..eb237a2e8 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1914,5 +1914,7 @@ "Failed": "Fehlgeschlagen", "Stopped": "Angehalten", "Stopped by an error (see the log)": "Durch einen Fehler angehalten (siehe Protokoll)", - "Not saved": "Nicht gespeichert" + "Not saved": "Nicht gespeichert", + "A folder is being synchronized": "Ein Ordner wird gerade synchronisiert", + "Wait for the import to finish": "Warten Sie, bis der Import abgeschlossen ist" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index d023cf6c4..c40f6372c 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1759,5 +1759,7 @@ "Failed": "Con errores", "Stopped": "Detenido", "Stopped by an error (see the log)": "Detenido por un error (ver el registro)", - "Not saved": "Sin guardar" + "Not saved": "Sin guardar", + "A folder is being synchronized": "Se está sincronizando una carpeta", + "Wait for the import to finish": "Espera a que termine la importación" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 9c5072025..bd55af76c 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1759,5 +1759,7 @@ "Failed": "失敗", "Stopped": "停止しました", "Stopped by an error (see the log)": "エラーで停止しました(ログを参照)", - "Not saved": "保存されていません" + "Not saved": "保存されていません", + "A folder is being synchronized": "フォルダを同期中です", + "Wait for the import to finish": "読み込みが終わるまでお待ちください" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 21a3d0c84..7e9511241 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1759,5 +1759,7 @@ "Failed": "Com falha", "Stopped": "Interrompido", "Stopped by an error (see the log)": "Interrompido por um erro (veja o log)", - "Not saved": "Não salvo" + "Not saved": "Não salvo", + "A folder is being synchronized": "Uma pasta está sendo sincronizada", + "Wait for the import to finish": "Aguarde a importação terminar" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 952ef5c92..4f3689f7d 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1433,5 +1433,7 @@ "Failed": "Ошибки", "Stopped": "Остановлено", "Stopped by an error (see the log)": "Остановлено из-за ошибки (см. журнал)", - "Not saved": "Не сохранено" + "Not saved": "Не сохранено", + "A folder is being synchronized": "Идёт синхронизация папки", + "Wait for the import to finish": "Дождитесь окончания импорта" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index a67df15a8..f12273d05 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1759,5 +1759,7 @@ "Failed": "失败", "Stopped": "已停止", "Stopped by an error (see the log)": "因错误而停止(见日志)", - "Not saved": "未保存" + "Not saved": "未保存", + "A folder is being synchronized": "正在同步文件夹", + "Wait for the import to finish": "请等待导入完成" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 3d179de54..f7e7c35e7 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1759,5 +1759,7 @@ "Failed": "失敗", "Stopped": "已停止", "Stopped by an error (see the log)": "因錯誤而停止(請見記錄)", - "Not saved": "未儲存" + "Not saved": "未儲存", + "A folder is being synchronized": "正在同步資料夾", + "Wait for the import to finish": "請等待匯入完成" } diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index 49e3a2664..4b1e40770 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -393,6 +393,12 @@ pub struct ScanTask { sources: Vec, } +/// 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(()) } +} + /// 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() { @@ -565,6 +571,7 @@ impl ScanTask { /// Start importing the dialog's checked files (the dialog's OK / `ui.dialog.confirm`). pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result { + busy_synchronizing(app)?; let queue = d.selected_paths(); if queue.is_empty() { return Err("no photos selected".into()); @@ -616,6 +623,7 @@ pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result /// 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 app.import.is_some() || app.scan.as_ref().is_some_and(|t| !t.browse) { return Err("an import is running".into()); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 503c50b28..05591366a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -772,7 +772,12 @@ impl LightcraftApp { if let Some(folder) = self.session.import_defaults.auto_folder.clone() { const LABEL: &str = "Auto Import"; let now = ctx.input(|i| i.time); - if now - self.ui.auto_import_at >= 3.0 && self.import.is_none() && self.lightroom.is_none() && !self.tasks.is_running(LABEL) { + if now - self.ui.auto_import_at >= 3.0 + && self.import.is_none() + && self.sync_run.is_none() + && self.lightroom.is_none() + && !self.tasks.is_running(LABEL) + { self.ui.auto_import_at = now; let work = move || lightcraft_engine::cmd::library::list_auto_import_folder(&folder); let done = |app: &mut LightcraftApp, _ctx: &egui::Context, listing: Result, String>| { diff --git a/crates/ui-egui/src/sync.rs b/crates/ui-egui/src/sync.rs index b60243ac1..244f4308c 100644 --- a/crates/ui-egui/src/sync.rs +++ b/crates/ui-egui/src/sync.rs @@ -172,6 +172,10 @@ pub fn confirm(app: &mut LightcraftApp, dlg: &Dialog) -> Result { 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) { diff --git a/crates/ui-egui/src/tests_sync.rs b/crates/ui-egui/src/tests_sync.rs index dff960bc9..59fd041f5 100644 --- a/crates/ui-egui/src/tests_sync.rs +++ b/crates/ui-egui/src/tests_sync.rs @@ -217,3 +217,32 @@ fn a_stopped_synchronize_says_it_was_stopped_and_stays_at_most_one_step() { assert!(toast.contains("Stopped"), "never 'up to date': {toast:?}"); assert!(h.app.session.undo.len() <= steps + 1, "what was done by then is at most one step"); } + +#[test] +fn a_synchronize_and_an_import_never_run_at_once() { + let dir = Scratch::new("exclusive"); + let mut h = changed_folder(&dir); + open_and_scan(&mut h, &dir.path("trip")); + let dlg = h.app.ui.dialog.take().expect("the dialog is open"); + assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_ok()); + // dropping the same folder on the window while the run works would add its files twice + let r = crate::import::start_paths(&mut h.app, vec![dir.path("trip")]); + assert!(r.is_err(), "no import while synchronizing: {r:?}"); + assert!(h.step_until(T, |h| h.app.sync_run.is_none())); + // and the other way round: an import is running (no frame has run since it started) + let r = crate::import::start_paths(&mut h.app, vec![dir.path("trip/a.png")]); + assert!(r.is_ok(), "{r:?}"); + assert!(h.app.import.is_some()); + let d = Dialog::SynchronizeFolder { + path: dir.path("trip"), + name: "trip".into(), + disk: false, + counts: Some(Default::default()), + import_new: true, + relink_moved: true, + remove_missing: false, + read_metadata: false, + }; + let r = crate::panels::dialogs::confirm_dialog(&mut h.app, &d); + assert!(r.is_err() && h.app.sync_run.is_none(), "no synchronize while importing: {r:?}"); +} From 47dfdd3c0eed9c20a5bfabf86be84190874eb142 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 18:56:49 -0400 Subject: [PATCH 26/28] Engine: work out sidecar naming for many photos in one pass (review fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session::sidecar_naming builds the catalog's sidecar owners (a pass over every photo) for each photo it is asked about, so readying a synchronize (on the UI thread) with M sidecar updates in an N-photo library cost M·N — seconds for thousands of updates in a large library. The folder scan had the same pattern. Session::sidecar_namings works them out once for a list of photos; both use it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/sidecar.rs | 12 ++++++++++++ crates/engine/src/sync.rs | 9 ++++++--- crates/engine/src/tests_sync.rs | 24 ++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index f8127f685..830b72087 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -445,6 +445,18 @@ impl Session { /// catalogued file shares the stem (`IMG_0001.CR3` + `IMG_0001.JPG`) and owns the stem /// sidecar — a raw first, else the first by file name. The others use Full naming /// (`IMG_0001.JPG.xmp`), so their metadata never overwrites each other. + /// [`Session::sidecar_naming`] for many photos, the catalog's sidecar owners worked out once + /// (per photo that is a pass over the whole catalog). + pub fn sidecar_namings(&self, ids: &[PhotoId]) -> Vec { + let owners = (self.xmp.naming == SidecarNaming::Stem).then(|| StemOwners::of(&self.catalog)); + ids.iter() + .map(|id| match (self.catalog.photo(*id), &owners) { + (Some(p), Some(o)) => o.naming(p, self.xmp.naming), + _ => self.xmp.naming, + }) + .collect() + } + pub fn sidecar_naming(&self, id: PhotoId) -> SidecarNaming { match self.catalog.photo(id) { Some(p) if self.xmp.naming == SidecarNaming::Stem => StemOwners::of(&self.catalog).naming(p, self.xmp.naming), diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs index d4f638955..b876aef89 100644 --- a/crates/engine/src/sync.rs +++ b/crates/engine/src/sync.rs @@ -174,7 +174,8 @@ impl SyncInput { if !disk && (lightcraft_catalog::folders::is_disk_root(path) || crate::cmd::library::covers_other_disks(s, path, &ids)) { return Err(invalid(format!("{path} is a whole disk or holds disks: pass `disk: true` to synchronize it"))); } - let photos = ids.iter().filter_map(|id| s.catalog.photo(*id).map(|p| (Arc::clone(p), s.sidecar_naming(*id)))).collect(); + let namings = s.sidecar_namings(&ids); + let photos = ids.iter().zip(namings).filter_map(|(id, naming)| s.catalog.photo(*id).map(|p| (Arc::clone(p), naming))).collect(); let (scan, _) = ScanInput::new(s, &[path.to_string()]); Ok(SyncInput { folder: path.to_string(), scan, photos, labels: s.catalog.clone() }) } @@ -509,11 +510,13 @@ impl SyncJob { }; let now = import.as_ref().map_or_else(|| (s.clock)(), |(j, _)| j.now().to_string()); let sidecars = if choice.read_metadata { + let namings = s.sidecar_namings(&metadata.iter().map(|m| PhotoId(m.id)).collect::>()); metadata .into_iter() - .filter_map(|m| { + .zip(namings) + .filter_map(|(m, naming)| { let p = s.catalog.photo(PhotoId(m.id))?; - Some((p.id, m.path, s.sidecar_naming(p.id), p.kind == lightcraft_catalog::MediaKind::Raw)) + Some((p.id, m.path, naming, p.kind == lightcraft_catalog::MediaKind::Raw)) }) .collect() } else { diff --git a/crates/engine/src/tests_sync.rs b/crates/engine/src/tests_sync.rs index 75d40d8cc..b5601e714 100644 --- a/crates/engine/src/tests_sync.rs +++ b/crates/engine/src/tests_sync.rs @@ -597,3 +597,27 @@ fn an_undo_pressed_while_synchronizing_is_not_folded_into_it() { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.catalog.photo(a).unwrap().rating, 4, "the earlier, unrelated step stays"); } + +#[test] +fn sidecar_namings_for_many_photos_agree_with_one_at_a_time() { + let dir = Scratch::new("namings"); + let mut s = library(&dir); + // a raw beside its JPEG: the raw owns IMG_1.xmp, the JPEG's is IMG_1.JPG.xmp + for name in ["IMG_1.CR3", "IMG_1.JPG", "IMG_2.JPG"] { + let id = s.catalog.alloc_photo_id(); + let p = lightcraft_catalog::Photo::new( + id, + lightcraft_catalog::Source::File { path: dir.path(&format!("trip/{name}")) }, + name, + "X", + 6, + 4, + "2026-01-01", + ); + s.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + let ids: Vec = s.catalog.photos().map(|p| p.id).collect(); + let one_by_one: Vec<_> = ids.iter().map(|id| s.sidecar_naming(*id)).collect(); + assert_eq!(s.sidecar_namings(&ids), one_by_one); + assert!(one_by_one.contains(&crate::sidecar::SidecarNaming::Full), "the case that matters is there"); +} From 8233d1e4126d8fda94f533f62064f8d3ef56ade9 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 18:57:15 -0400 Subject: [PATCH 27/28] Synchronize Folder: progress reaches its end; say what still writes on the UI thread (review fixes) The relink check counted only the photos still to relink, so when some moved files were gone again the bar stopped short of its total. The module docs now name the writes that stay on the UI thread after each step (the durable save; auto-written sidecars), and a test that only showed the work checks files again is named for that. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/sync.rs | 7 +++++-- crates/engine/src/tests_sync.rs | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs index b876aef89..126cac37c 100644 --- a/crates/engine/src/sync.rs +++ b/crates/engine/src/sync.rs @@ -16,7 +16,9 @@ //! [`synchronize`] then imports the new files (in place), relinks the moved photos, moves the //! missing photos to Recently Deleted and reads the metadata updates — each only when chosen — as //! one undo step. It runs in halves ([`SyncJob`]) so that, in the app, every file it reads is -//! read on a worker thread and only the catalog changes happen on the UI thread. +//! read on a worker thread and only the catalog changes happen on the UI thread — with what every +//! command does after them there: the library's durable save and, with XMP auto-write on, the +//! sidecars of the photos whose metadata was read. //! //! The scan runs without the session ([`SyncInput`] + [`scan_with`]), so the app can run it on a //! worker thread: a folder on a sleeping network share must never stall a frame. @@ -572,9 +574,10 @@ impl SyncWork { return; } // still where the scan found it, and still gone from where the photo says + let checked = moved.len(); let relink: Vec<(PhotoId, String)> = moved.into_iter().filter(|m| Path::new(&m.to).is_file() && !Path::new(&m.from).exists()).map(|m| (PhotoId(m.id), m.to)).collect(); - step(relink.len()); + step(checked); if !relink.is_empty() && !send(SyncStep::Relink(relink)) { return; } diff --git a/crates/engine/src/tests_sync.rs b/crates/engine/src/tests_sync.rs index b5601e714..22dce0af8 100644 --- a/crates/engine/src/tests_sync.rs +++ b/crates/engine/src/tests_sync.rs @@ -498,7 +498,7 @@ fn the_work_of_synchronizing_runs_apart_from_its_commits() { } #[test] -fn readying_a_synchronize_reads_no_file() { +fn the_work_checks_again_that_a_new_file_is_still_there() { let dir = Scratch::new("ready"); let mut s = library(&dir); write_png(&dir.path("trip/c.png"), 3); From 58eec1f688ade3f2b8423772deb29b5a88421651 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 19:05:22 -0400 Subject: [PATCH 28/28] UI tests: click the Remove missing box again until the tick lands Under a fully loaded test run a single queued click could arrive while the dialog was still settling into place and miss the box. The helper now clicks again at the box's current place until it is ticked. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/tests_sync.rs | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/tests_sync.rs b/crates/ui-egui/src/tests_sync.rs index 59fd041f5..0fa430891 100644 --- a/crates/ui-egui/src/tests_sync.rs +++ b/crates/ui-egui/src/tests_sync.rs @@ -109,12 +109,20 @@ fn synchronize(h: &mut Headless) { assert!(h.step_until(T, |h| h.app.sync_run.is_none()), "the work finishes"); } -/// Tick "Remove missing photos" and wait until the click has landed (a click is queued input). +/// Tick "Remove missing photos" and wait until the click has landed. A click is queued input: +/// on a loaded machine the dialog can still be settling into place when it arrives, so it is +/// clicked again (at the box's current place) until it lands. fn tick_remove_missing(h: &mut Headless) { - let r = h.request("ui.clickWidget", json!({"id": "syncRemoveMissing"}), T); - assert_eq!(r["ok"], true, "{r}"); - let ticked = h.step_until(T, |h| matches!(h.app.ui.dialog, Some(Dialog::SynchronizeFolder { remove_missing: true, .. }))); - assert!(ticked, "ticked"); + let ticked = |h: &Headless| matches!(h.app.ui.dialog, Some(Dialog::SynchronizeFolder { remove_missing: true, .. })); + for _ in 0..5 { + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "syncRemoveMissing"}), T); + assert_eq!(r["ok"], true, "{r}"); + if h.step_until(Duration::from_secs(3), ticked) { + return; + } + } + panic!("the box never ticked: {:?}", h.app.ui.dialog); } fn counts(h: &Headless) -> crate::state::SyncCounts {