diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7df97e837..13068ed3d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1246,6 +1246,8 @@ mod tests_mask_effects; #[cfg(test)] mod tests_merge; #[cfg(test)] +mod tests_move_to_share; +#[cfg(test)] mod tests_organize; #[cfg(test)] mod tests_persist; diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs index 6408b71e8..16003cd61 100644 --- a/crates/engine/src/rename.rs +++ b/crates/engine/src/rename.rs @@ -433,12 +433,31 @@ impl MoveFs for RealFs { } fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { - use std::io::Write; - let bytes = std::fs::read(a)?; - let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(b)?; - f.write_all(&bytes).and_then(|_| f.sync_all()).inspect_err(|_| { - let _ = std::fs::remove_file(b); - }) + if self.exists(b) { + return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", b.display()))); + } + // Streamed (a large raw or video is never read into memory whole), into a hidden name + // beside `b` that only takes `b`'s name once complete: a share that drops mid-copy, so + // the partial file can't be removed either, leaves no truncated file under the photo's + // name for a retry to take for the photo. + let name = b.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + // a free name: one left by a copy the share cut off (it couldn't be removed then) doesn't + // block the next + let partial = (0..1000u32) + .map(|k| b.with_file_name(format!(".{name}.lc-partial-{}-{k}", std::process::id()))) + .find(|p| !self.exists(p)) + .ok_or_else(|| std::io::Error::other(format!("no free temporary name beside {}", b.display())))?; + let copied = (|| { + let mut from = std::fs::File::open(a)?; + let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(&partial)?; + std::io::copy(&mut from, &mut f)?; + f.sync_all() + })(); + let placed = copied.and_then(|()| self.rename_no_replace(&partial, b)); + if placed.is_err() { + let _ = std::fs::remove_file(&partial); + } + placed } fn remove_file(&self, p: &Path) -> std::io::Result<()> { diff --git a/crates/engine/src/tests_move_to_share.rs b/crates/engine/src/tests_move_to_share.rs new file mode 100644 index 000000000..ed824b29d --- /dev/null +++ b/crates/engine/src/tests_move_to_share.rs @@ -0,0 +1,286 @@ +//! Moving photos to a folder on a NAS or network share (`photo.moveToFolder`, which dropping them +//! on a Folders row runs), where the share can go away between the move and its undo: +//! +//! * The share offline when Undo runs: Undo is refused with the reason and keeps the step; +//! nothing is deleted, the library still points at the files where they are, and once the share +//! is back the same Undo moves them home. +//! * The share read-only when Undo runs: the same. +//! * The connection lost partway through a move: the files already moved go back, the library is +//! unchanged and no history step is left. +//! * One file the share can't give back during Undo: the files already brought back go out again, +//! so every photo points at its file and the step can run once the share answers. +//! * Across devices (a share is another volume): the file is copied, checked and only then +//! removed; the copy goes to a hidden name and takes the photo's name only once complete. + +use lightcraft_catalog::{Op, Photo, PhotoId, Source}; +use serde_json::json; + +use crate::Session; + +/// 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-share-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("local")).unwrap(); + std::fs::create_dir_all(dir.join("share")).unwrap(); + Scratch(dir) + } + fn path(&self, rel: &str) -> String { + self.0.join(rel).components().collect::().to_string_lossy().to_string() + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn add_file(s: &mut Session, path: &str, body: &str) -> PhotoId { + std::fs::write(path, body).unwrap(); + let id = s.catalog.alloc_photo_id(); + let name = std::path::Path::new(path).file_name().unwrap().to_string_lossy().to_string(); + let p = Photo::new(id, Source::File { path: path.into() }, &name, "JPEG", 60, 40, "2026-01-01T10:00:00"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id +} + +fn file_of(s: &Session, id: PhotoId) -> String { + match &s.catalog.photo(id).unwrap().source { + Source::File { path } => path.clone(), + _ => panic!("not a file"), + } +} + +/// Two photos on the local disk moved to the share. +fn moved_to_share(dir: &Scratch) -> (Session, PhotoId, PhotoId) { + let mut s = Session::new(); + let a = add_file(&mut s, &dir.path("local/a.jpg"), "A"); + std::fs::write(dir.path("local/a.xmp"), "").unwrap(); + let b = add_file(&mut s, &dir.path("local/b.jpg"), "B"); + let r = s.execute("photo.moveToFolder", &json!({"ids": [a.0, b.0], "folder": dir.path("share")})).unwrap(); + assert_eq!(r["moved"], 2); + (s, a, b) +} + +#[test] +fn undo_with_the_share_offline_is_refused_keeps_the_step_and_works_once_it_is_back() { + let dir = Scratch::new("offline"); + let (mut s, a, b) = moved_to_share(&dir); + let steps = s.undo.len(); + // the share goes offline: its folder is no longer there + let away = dir.0.join("share-unmounted"); + std::fs::rename(dir.0.join("share"), &away).unwrap(); + let e = s.execute("edit.undo", &json!({})).unwrap_err().to_string(); + assert!(e.contains("can't move the files back"), "says why: {e}"); + assert_eq!(s.undo.len(), steps, "the step stays, to undo once the share is back"); + // nothing deleted, nothing half-moved, and the library still points at the share + assert!(away.join("a.jpg").is_file() && away.join("a.xmp").is_file() && away.join("b.jpg").is_file()); + assert!(!dir.0.join("local/a.jpg").exists() && !dir.0.join("local/b.jpg").exists()); + assert_eq!(file_of(&s, a), dir.path("share/a.jpg")); + assert_eq!(file_of(&s, b), dir.path("share/b.jpg")); + // the share is back: the same Undo moves them home, sidecar and all + std::fs::rename(&away, dir.0.join("share")).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(std::fs::read_to_string(dir.path("local/a.jpg")).unwrap(), "A"); + assert!(dir.0.join("local/a.xmp").is_file() && dir.0.join("local/b.jpg").is_file()); + assert_eq!(file_of(&s, a), dir.path("local/a.jpg")); + assert_eq!(file_of(&s, b), dir.path("local/b.jpg")); +} + +#[cfg(unix)] +#[test] +fn undo_from_a_read_only_share_is_refused_and_deletes_nothing() { + use std::os::unix::fs::PermissionsExt; + let dir = Scratch::new("readonly"); + let (mut s, a, _) = moved_to_share(&dir); + let steps = s.undo.len(); + std::fs::set_permissions(dir.0.join("share"), std::fs::Permissions::from_mode(0o555)).unwrap(); + // running as root ignores permissions: nothing to check there + let root = std::fs::write(dir.0.join("share/probe"), "x").is_ok(); + if !root { + assert!(s.execute("edit.undo", &json!({})).is_err()); + assert_eq!(s.undo.len(), steps, "the step stays"); + assert!(dir.0.join("share/a.jpg").is_file() && dir.0.join("share/b.jpg").is_file(), "nothing deleted"); + assert!(!dir.0.join("local/a.jpg").exists(), "no second copy left behind"); + assert_eq!(file_of(&s, a), dir.path("share/a.jpg")); + } + std::fs::set_permissions(dir.0.join("share"), std::fs::Permissions::from_mode(0o755)).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(dir.0.join("local/a.jpg").is_file()); +} + +/// The model volume spells paths with `/`; the code under test joins them with the host's separator. +fn host(path: &str) -> String { + path.replace('/', std::path::MAIN_SEPARATOR_STR) +} + +fn model_session(fs_files: &[&str]) -> (Session, Vec) { + let mut s = Session::new(); + let ids = fs_files + .iter() + .map(|path| { + let id = s.catalog.alloc_photo_id(); + let name = std::path::Path::new(path).file_name().unwrap().to_string_lossy().to_string(); + let p = Photo::new(id, Source::File { path: host(path) }, &name, "JPEG", 1, 1, "2026-01-01T00:00:00"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id + }) + .collect(); + (s, ids) +} + +fn plans(s: &Session, ids: &[PhotoId], into: &str) -> Vec { + ids.iter() + .map(|id| { + let from = file_of(s, *id); + let name = std::path::Path::new(&from).file_name().unwrap().to_string_lossy().to_string(); + crate::rename::RenamePlan { + id: id.0, + from: name.clone(), + to: name.clone(), + from_path: Some(from), + to_path: Some(host(&format!("{into}/{name}"))), + } + }) + .collect() +} + +/// Every photo points at a file the model volume holds (or, for `offline`, one it held). +fn assert_linked(s: &Session, fs: &crate::rename::tests::FakeFs, offline: &[&str]) { + let files = fs.listing(); + for p in s.catalog.photos() { + let Source::File { path } = &p.source else { continue }; + let path = path.replace('\\', "/"); + assert!( + files.iter().any(|(f, _)| *f == path) || offline.contains(&path.as_str()), + "photo {} points at {path}, which isn't there: {files:?}", + p.id.0 + ); + } +} + +#[test] +fn a_connection_lost_partway_through_a_move_puts_back_what_moved() { + let fs = crate::rename::tests::FakeFs::new(false, &[("/local/a.jpg", "A"), ("/local/b.jpg", "B"), ("/local/c.jpg", "C")]); + let (mut s, ids) = model_session(&["/local/a.jpg", "/local/b.jpg", "/local/c.jpg"]); + let plans = plans(&s, &ids, "/share"); + // the share stops answering when the second file goes + fs.fail.borrow_mut().push("/local/b.jpg".into()); + let steps = s.undo.len(); + assert!(s.apply_file_plans(&fs, &plans, &|_| "Move to Folder".into()).is_err()); + assert_eq!( + fs.listing(), + vec![("/local/a.jpg".into(), "A".into()), ("/local/b.jpg".into(), "B".into()), ("/local/c.jpg".into(), "C".into())], + "all back home" + ); + assert_eq!(s.undo.len(), steps, "no step for a move that didn't happen"); + assert_linked(&s, &fs, &[]); +} + +#[test] +fn a_file_the_share_cannot_give_back_rolls_the_whole_undo_back() { + let fs = crate::rename::tests::FakeFs::new(false, &[("/local/a.jpg", "A"), ("/local/b.jpg", "B")]); + let (mut s, ids) = model_session(&["/local/a.jpg", "/local/b.jpg"]); + let plans = plans(&s, &ids, "/share"); + s.apply_file_plans(&fs, &plans, &|_| "Move to Folder".into()).unwrap(); + assert_eq!(fs.listing(), vec![("/share/a.jpg".into(), "A".into()), ("/share/b.jpg".into(), "B".into())]); + // b can't be read back: the share answers for a but not for b + fs.fail.borrow_mut().push("/share/b.jpg".into()); + let e = s.undo_step_with(&fs).unwrap_err().to_string(); + assert!(e.contains("can't move the files back"), "{e}"); + // a came back before b failed: it went out again, so the step is whole and every photo + // points at its file, none lost + assert_linked(&s, &fs, &[]); + let contents: Vec = fs.listing().into_iter().map(|(_, c)| c).collect(); + assert_eq!(contents, vec!["A".to_string(), "B".to_string()], "both files still exist once: {:?}", fs.listing()); + // the share answers again: undoing whatever is left brings both home + fs.fail.borrow_mut().clear(); + while s.undo_step_with(&fs).is_ok() && fs.listing().iter().any(|(p, _)| p.starts_with("/share")) {} + assert_eq!(fs.listing(), vec![("/local/a.jpg".into(), "A".into()), ("/local/b.jpg".into(), "B".into())]); + assert_linked(&s, &fs, &[]); +} + +/// Where this machine has a second file system to write to (Linux's `/dev/shm`), a real move +/// across devices: copied, checked, the original removed, and Undo copies it back. Skipped where +/// there is none (a network share can't be mounted in a test). +#[test] +fn a_move_across_devices_copies_checks_and_removes_and_undoes_the_same_way() { + let dir = Scratch::new("devices"); + let Some(other) = other_device(&dir.0) else { return }; + let share = other.join(format!("lc-share-other-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&share); + std::fs::create_dir_all(&share).unwrap(); + let mut s = Session::new(); + let body: String = (0..300_000).map(|i| char::from(b'a' + (i % 26) as u8)).collect(); + let a = add_file(&mut s, &dir.path("local/a.jpg"), &body); + let r = s.execute("photo.moveToFolder", &json!({"ids": [a.0], "folder": share.to_string_lossy()})); + let moved = + r.is_ok() && std::fs::read_to_string(share.join("a.jpg")).ok().as_deref() == Some(body.as_str()) && !dir.0.join("local/a.jpg").exists(); + let undone = moved + && s.execute("edit.undo", &json!({})).is_ok() + && std::fs::read_to_string(dir.0.join("local/a.jpg")).ok().as_deref() == Some(body.as_str()); + let gone_from_share = !share.join("a.jpg").exists(); + let _ = std::fs::remove_dir_all(&share); + assert!(moved, "moved across devices: {r:?}"); + assert!(undone, "and back"); + assert!(gone_from_share, "no copy left on the other device"); +} + +/// A writable folder on another device than `here`, if there is one. +fn other_device(here: &std::path::Path) -> Option { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let dev = std::fs::metadata(here).ok()?.dev(); + ["/dev/shm", "/run/shm"] + .iter() + .map(std::path::PathBuf::from) + .find(|p| { + std::fs::metadata(p).is_ok_and(|m| m.dev() != dev) + && std::fs::write(p.join(format!(".lc-probe-{}", std::process::id())), b"x").is_ok() + }) + .inspect(|p| { + let _ = std::fs::remove_file(p.join(format!(".lc-probe-{}", std::process::id()))); + }) + } + #[cfg(not(unix))] + { + let _ = here; + None + } +} + +#[test] +fn the_copy_across_volumes_is_whole_or_absent_and_never_replaces() { + use crate::rename::MoveFs; + let dir = Scratch::new("copy"); + let body: Vec = (0..(3usize << 20) + 17).map(|i| (i % 251) as u8).collect(); + let (from, to) = (dir.0.join("local/big.raw"), dir.0.join("share/big.raw")); + std::fs::write(&from, &body).unwrap(); + crate::rename::RealFs.copy_no_replace(&from, &to).unwrap(); + assert_eq!(std::fs::read(&to).unwrap(), body); + // nothing left under a hidden name beside it + let left: Vec<_> = std::fs::read_dir(dir.0.join("share")).unwrap().flatten().map(|e| e.file_name()).collect(); + assert_eq!(left, vec![std::ffi::OsString::from("big.raw")], "{left:?}"); + // a partial left by a copy the share cut off doesn't block the next copy + let stale = dir.0.join(format!("share/.again.raw.lc-partial-{}-0", std::process::id())); + std::fs::write(&stale, b"cut off").unwrap(); + crate::rename::RealFs.copy_no_replace(&from, &dir.0.join("share/again.raw")).unwrap(); + assert_eq!(std::fs::read(dir.0.join("share/again.raw")).unwrap(), body); + std::fs::remove_file(&stale).unwrap(); + std::fs::remove_file(dir.0.join("share/again.raw")).unwrap(); + // a copy that can't start (its source unreadable) leaves nothing at all under the photo's name + let gone = dir.0.join("local/missing.raw"); + assert!(crate::rename::RealFs.copy_no_replace(&gone, &dir.0.join("share/missing.raw")).is_err()); + assert!(!dir.0.join("share/missing.raw").exists()); + let left: Vec<_> = std::fs::read_dir(dir.0.join("share")).unwrap().flatten().map(|e| e.file_name()).collect(); + assert_eq!(left.len(), 1, "no partial file left: {left:?}"); + // a name taken is never replaced, and what is there stays + std::fs::write(&to, b"other").unwrap(); + assert!(crate::rename::RealFs.copy_no_replace(&from, &to).is_err()); + assert_eq!(std::fs::read(&to).unwrap(), b"other"); +} diff --git a/crates/ui-egui/locales/cs-formats.json b/crates/ui-egui/locales/cs-formats.json index d3ce48fc6..a784e3e79 100644 --- a/crates/ui-egui/locales/cs-formats.json +++ b/crates/ui-egui/locales/cs-formats.json @@ -289,5 +289,9 @@ "Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.", "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” is already a person. Combining them makes one person; Undo separates them again.", "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has", - "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})" + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})", + "Already in “{}”": "Již v „{}“", + "Move {n} photo{} to “{}”": "Přesunout do „{1}“ fotografie: {n}{0:.0}", + "Move {n} photo{} to “{}”?": "Přesunout do „{1}“ fotografie: {n}{0:.0}?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Do „{1}“ přesunuto fotografií: {n}{0:.0}. Úpravy → Zpět je vrátí." } diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index f198457bb..02f609e27 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -289,5 +289,9 @@ "Renames {n} face{} across the library. The files are not changed.": "Benennt {n} Gesichter in der Bibliothek um. Die Dateien werden nicht geändert.{:.0}", "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "„{to}“ ist bereits eine Person. Zusammenführen ergibt eine Person; Rückgängig trennt sie wieder.", "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}-Zusammenfügung mit {mp} hinzugefügt: die volle Größe, {full}, braucht mehr Arbeitsspeicher, als dieser Computer hat", - "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}-Zusammenfügung mit {mp} hinzugefügt, der größten möglichen Panoramagröße (volle Größe: {full})" + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}-Zusammenfügung mit {mp} hinzugefügt, der größten möglichen Panoramagröße (volle Größe: {full})", + "Already in “{}”": "Bereits in „{}“", + "Move {n} photo{} to “{}”": "{n} Foto{0} nach „{1}“ verschieben", + "Move {n} photo{} to “{}”?": "{n} Foto{0} nach „{1}“ verschieben?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} Foto{0} nach „{1}“ verschoben. Bearbeiten → Rückgängig holt sie zurück." } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index e1168f126..a3fd18261 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -54,6 +54,9 @@ "Add to Favorites": "Zu Favoriten hinzufügen", "Add to Local": "Zu „Lokal“ hinzufügen", "Add to My Photos": "Zu „Meine Fotos“ hinzufügen", + "Can't be moved here": "Hierher nicht verschiebbar", + "This row covers more than one disk: drop on a folder inside it": "Diese Zeile umfasst mehr als ein Laufwerk: auf einem Ordner darin ablegen", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "Lokale Fotos sind noch nicht in der Bibliothek: erst zu „Meine Fotos“ hinzufügen, dann verschieben", "Add to Selected Photos": "Zu ausgewählten Fotos hinzufügen", "Add to Target Album": "Zu Zielalbum hinzufügen", "Add to this mask": "Zu dieser Maske hinzufügen", @@ -289,6 +292,12 @@ "Concrete Cool": "Kühler Beton", "Confirm before deleting": "Vor dem Löschen bestätigen", "Confirm before moving photos to Recently Deleted": "Vor dem Verschieben nach „Zuletzt gelöscht“ bestätigen", + "Move Photos": "Fotos verschieben", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Ihre Dateien und XMP-Filialdateien werden auf dem Datenträger verschoben. Bearbeiten → Rückgängig holt sie zurück.", + "Don't ask again": "Nicht mehr fragen", + "Another library was opened since the photos were dropped: nothing was moved": "Seit dem Ablegen der Fotos wurde eine andere Bibliothek geöffnet: Es wurde nichts verschoben", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Einstellungen → „Vor dem Verschieben in einen anderen Ordner auf dem Datenträger bestätigen“ schaltet es wieder ein", + "Confirm before moving photos to another folder on disk": "Vor dem Verschieben in einen anderen Ordner auf dem Datenträger bestätigen", "Constrain Crop": "Zuschnitt beschränken", "Continue": "Fortfahren", "Continue Without Saving": "Ohne Speichern fortfahren", diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 8274ed8be..e7a468914 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -289,5 +289,9 @@ "Renames {n} face{} across the library. The files are not changed.": "Cambia el nombre de {n} cara{} en toda la biblioteca. Los archivos no se modifican.", "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” ya es una persona. Combinarlas deja una sola; Deshacer las separa de nuevo.", "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Combinación {what} añadida a {mp}: el tamaño completo, {full}, necesita más memoria de la que tiene este ordenador", - "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Combinación {what} añadida a {mp}, el tamaño máximo de una panorámica (tamaño completo: {full})" + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Combinación {what} añadida a {mp}, el tamaño máximo de una panorámica (tamaño completo: {full})", + "Already in “{}”": "Ya está en “{}”", + "Move {n} photo{} to “{}”": "Mover {n} foto{0} a “{1}”", + "Move {n} photo{} to “{}”?": "¿Mover {n} foto{0} a “{1}”?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} foto{0} movida{0} a “{1}”. Editar → Deshacer las devuelve." } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 77a175721..1723afe8e 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -39,6 +39,9 @@ "Add to Favorites": "Añadir a Favoritos", "Add to Local": "Añadir a Local", "Add to My Photos": "Añadir a Mis fotos", + "Can't be moved here": "No se pueden mover aquí", + "This row covers more than one disk: drop on a folder inside it": "Esta fila abarca más de un disco: suéltalas en una carpeta dentro de ella", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "Las fotos locales aún no están en la biblioteca: primero Añadir a Mis fotos y luego muévelas", "Add to Selected Photos": "Añadir a las fotos seleccionadas", "Add to Target Album": "Añadir al álbum de destino", "Add to this mask": "Añadir a esta máscara", @@ -278,6 +281,12 @@ "Concrete Cool": "Hormigón frío", "Confirm before deleting": "Confirmar antes de eliminar", "Confirm before moving photos to Recently Deleted": "Confirmar antes de mover fotos a Eliminadas recientemente", + "Move Photos": "Mover fotos", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Sus archivos y archivos XMP auxiliares se mueven en el disco. Editar → Deshacer los devuelve.", + "Don't ask again": "No volver a preguntar", + "Another library was opened since the photos were dropped: nothing was moved": "Se abrió otra biblioteca después de soltar las fotos: no se movió nada", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Ajustes → Confirmar antes de mover fotos a otra carpeta del disco lo vuelve a activar", + "Confirm before moving photos to another folder on disk": "Confirmar antes de mover fotos a otra carpeta del disco", "Constrain Crop": "Restringir recorte", "Continue": "Continuar", "Continue Without Saving": "Continuar sin guardar", diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index 4a5716b0c..612ed068b 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -289,5 +289,9 @@ "Renames {n} face{} across the library. The files are not changed.": "Renomme {n} visage{} dans la bibliothèque. Les fichiers ne sont pas modifiés.", "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "« {to} » est déjà une personne. Les combiner n’en fait qu’une ; Annuler les sépare de nouveau.", "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Fusion de {what} ajoutée en {mp} : la taille complète, {full}, demande plus de mémoire que n’en a cet ordinateur", - "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Fusion de {what} ajoutée en {mp}, la plus grande taille possible pour un panorama (taille complète : {full})" + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Fusion de {what} ajoutée en {mp}, la plus grande taille possible pour un panorama (taille complète : {full})", + "Already in “{}”": "Déjà dans « {} »", + "Move {n} photo{} to “{}”": "Déplacer {n} photo{} vers « {} »", + "Move {n} photo{} to “{}”?": "Déplacer {n} photo{} vers « {} » ?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} photo{} déplacée(s) vers « {} ». Modifier → Annuler les remet en place." } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index d70878dfd..ffc450a1d 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -39,6 +39,9 @@ "Add to Favorites": "Ajouter aux favoris", "Add to Local": "Ajouter à Local", "Add to My Photos": "Ajouter à Mes photos", + "Can't be moved here": "Impossible de les déplacer ici", + "This row covers more than one disk: drop on a folder inside it": "Cette ligne couvre plusieurs disques : déposez-les sur un dossier qu’elle contient", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "Les photos locales ne sont pas encore dans la bibliothèque : Ajouter à Mes photos d’abord, puis déplacez-les", "Add to Selected Photos": "Ajouter aux photos sélectionnées", "Add to Target Album": "Ajouter à l'album cible", "Add to this mask": "Ajouter à ce masque", @@ -263,6 +266,12 @@ "Concrete Cool": "Béton froid", "Confirm before deleting": "Confirmer avant de supprimer", "Confirm before moving photos to Recently Deleted": "Confirmer avant de déplacer des photos vers Supprimées récemment", + "Move Photos": "Déplacer les photos", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Leurs fichiers et fichiers annexes XMP sont déplacés sur le disque. Modifier → Annuler les remet en place.", + "Don't ask again": "Ne plus demander", + "Another library was opened since the photos were dropped: nothing was moved": "Une autre bibliothèque a été ouverte depuis le dépôt des photos : rien n’a été déplacé", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Réglages → Confirmer avant de déplacer des photos vers un autre dossier sur le disque le réactive", + "Confirm before moving photos to another folder on disk": "Confirmer avant de déplacer des photos vers un autre dossier sur le disque", "Constrain Crop": "Contraindre le recadrage", "Continue": "Continuer", "Continue Without Saving": "Continuer sans enregistrer", diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 2df9f2b82..9adaa7999 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -13,6 +13,7 @@ "Added {n} photo{} to My Photos": "マイフォトに{n}枚を追加しました{:.0}", "Added {n} photo{} to “{}”": "「{1}」に{n}枚を追加しました{0:.0}", "All {n} selected photos move by the same amount.": "選択中の{n}枚を同じ時間だけずらします", + "Already in “{}”": "すでに「{}」にあります", "Auto Sync: edits apply to {n} photos": "自動同期:{n}枚に編集を適用", "Automatic ({} MB)": "自動({}MB)", "Click {}:1": "クリック時 {}:1", @@ -39,7 +40,10 @@ "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraftのウィンドウを開けませんでした: {e}\n\n詳しくはログファイルをご覧ください: {path}", "Matches: {}": "一致条件:{}", "Merging… {stage} {:.0}%": "合成中… {stage} {:.0}%", + "Move {n} photo{} to “{}”": "{n}枚を「{1}」に移動{0:.0}", + "Move {n} photo{} to “{}”?": "{n}枚を「{1}」に移動しますか?{0:.0}", "Move {what} to Recently Deleted?": "{what}を「最近削除した写真」に移動しますか?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n}枚を「{1}」に移動しました。編集 → 取り消す で元に戻せます。{0:.0}", "Named ({named_n})": "名前付き({named_n})", "Paste into {n} photo{} — only settings that were copied are pasted": "{n}枚に貼り付け{:.0}(コピーされた設定だけを適用)", "Preset: {name}": "プリセット:{name}", diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index db459dd70..440fed2ea 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -40,6 +40,9 @@ "Add to Favorites": "お気に入りに追加", "Add to Local": "ローカルに追加", "Add to My Photos": "マイフォトに追加", + "Can't be moved here": "ここには移動できません", + "This row covers more than one disk: drop on a folder inside it": "この行は複数のディスクにまたがっています。中のフォルダにドロップしてください", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "ローカルの写真はまだライブラリにありません。先に「マイフォトに追加」してから移動してください", "Add to Selected Photos": "選択した写真に追加", "Add to Target Album": "追加先アルバムに追加", "Add to this mask": "このマスクに追加", @@ -259,6 +262,12 @@ "Concrete Cool": "クールコンクリート", "Confirm before deleting": "削除前に確認", "Confirm before moving photos to Recently Deleted": "「最近削除した写真」に移動する前に確認", + "Move Photos": "写真を移動", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "ファイルと XMP サイドカーがディスク上で移動します。編集 → 取り消す で元に戻せます。", + "Don't ask again": "今後確認しない", + "Another library was opened since the photos were dropped: nothing was moved": "写真をドロップした後に別のライブラリが開かれました。何も移動していません", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "設定 → 「ディスク上の別のフォルダに移動する前に確認」で元に戻せます", + "Confirm before moving photos to another folder on disk": "ディスク上の別のフォルダに移動する前に確認", "Constrain Crop": "切り抜きを制限", "Continue": "続行", "Contrast": "コントラスト", diff --git a/crates/ui-egui/locales/pl-formats.json b/crates/ui-egui/locales/pl-formats.json index 682d3c33f..cd8a56dcf 100644 --- a/crates/ui-egui/locales/pl-formats.json +++ b/crates/ui-egui/locales/pl-formats.json @@ -289,5 +289,9 @@ "Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.", "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” is already a person. Combining them makes one person; Undo separates them again.", "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has", - "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})" + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})", + "Already in “{}”": "Już w „{}”", + "Move {n} photo{} to “{}”": "Przenieś do „{1}” — zdjęcia: {n}{0:.0}", + "Move {n} photo{} to “{}”?": "Przenieść do „{1}” — zdjęcia: {n}{0:.0}?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Przeniesiono do „{1}” — zdjęcia: {n}{0:.0}. Edytuj → Cofnij przywróci je." } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 1969dd262..6f7783347 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -17,6 +17,7 @@ "All {n} selected photos move by the same amount.": "Todas as {n} fotos selecionadas se movem pela mesma quantidade.", "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Já na biblioteca, em Excluídos recentemente: {0} foto{1} selecionada{1} · clique com o botão direito → Restaurar, ou Excluir permanentemente para importar de novo", "Already in the library: {} photo{} selected in All Photos": "Já na biblioteca: {0} foto{1} selecionada{1} em Todas as fotos", + "Already in “{}”": "Já está em “{}”", "Auto ({})": "Automático ({})", "Auto Sync: edits apply to {n} photos": "Sincronização automática: edições aplicadas a {n} fotos", "Automatic ({} MB)": "Automático ({} MB)", @@ -56,7 +57,10 @@ "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "O LightCraft não conseguiu abrir sua janela: {e}\n\nO arquivo de log tem os detalhes: {path}", "Matches: {}": "Correspondências: {}", "Merging… {stage} {:.0}%": "Mesclando… {stage} {:.0}%", + "Move {n} photo{} to “{}”": "Mover {n} foto{0} para “{1}”", + "Move {n} photo{} to “{}”?": "Mover {n} foto{0} para “{1}”?", "Move {what} to Recently Deleted?": "Mover {what} para Excluídos recentemente?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} foto{0} movida{0} para “{1}”. Editar → Desfazer as traz de volta.", "Named ({named_n})": "Nomeadas ({named_n})", "Paste into {n} photo{} — only settings that were copied are pasted": "Colar em {n} foto{} — somente os ajustes copiados são colados", "Preset: {name}": "Predefinição: {name}", diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 14c30f937..30dd80fc4 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -41,6 +41,9 @@ "Add to Favorites": "Adicionar aos favoritos", "Add to Local": "Adicionar a Local", "Add to My Photos": "Adicionar às Minhas fotos", + "Can't be moved here": "Não podem ser movidas para cá", + "This row covers more than one disk: drop on a folder inside it": "Esta linha abrange mais de um disco: solte em uma pasta dentro dela", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "As fotos locais ainda não estão na biblioteca: primeiro Adicionar às Minhas fotos, depois mova-as", "Add to Selected Photos": "Adicionar às fotos selecionadas", "Add to Target Album": "Adicionar ao álbum de destino", "Add to this mask": "Adicionar a esta máscara", @@ -271,6 +274,12 @@ "Concrete Cool": "Concreto frio", "Confirm before deleting": "Confirmar antes de excluir", "Confirm before moving photos to Recently Deleted": "Confirmar antes de mover as fotos para Excluídos recentemente", + "Move Photos": "Mover fotos", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Os arquivos e os arquivos XMP auxiliares são movidos no disco. Editar → Desfazer os traz de volta.", + "Don't ask again": "Não perguntar novamente", + "Another library was opened since the photos were dropped: nothing was moved": "Outra biblioteca foi aberta depois que as fotos foram soltas: nada foi movido", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Ajustes → Confirmar antes de mover fotos para outra pasta no disco o reativa", + "Confirm before moving photos to another folder on disk": "Confirmar antes de mover fotos para outra pasta no disco", "Constrain Crop": "Restringir recorte", "Continue": "Continuar", "Continue Without Saving": "Continuar sem salvar", diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 11d899c38..02671befc 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -289,5 +289,9 @@ "Renames {n} face{} across the library. The files are not changed.": "Переименовывает {n} лиц в библиотеке. Файлы не изменяются.{:.0}", "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "«{to}» уже есть среди людей. Объединение оставляет одного человека; отмена снова разделяет их.", "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}: слияние добавлено в размере {mp}: для полного размера ({full}) не хватает памяти этого компьютера", - "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}: слияние добавлено в размере {mp} — это наибольший размер панорамы (полный размер: {full})" + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}: слияние добавлено в размере {mp} — это наибольший размер панорамы (полный размер: {full})", + "Already in “{}”": "Уже в «{}»", + "Move {n} photo{} to “{}”": "Переместить в «{1}» фото: {n}{0:.0}", + "Move {n} photo{} to “{}”?": "Переместить в «{1}» фото: {n}{0:.0}?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "В «{1}» перемещено фото: {n}{0:.0}. Правка → Отменить вернёт их." } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 29c723d03..0fcd94d4e 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -24,6 +24,9 @@ "Add to Album": "Добавить в альбом", "Add to Favorites": "Добавить в избранное", "Add to My Photos": "Добавить в «Мои фото»", + "Can't be moved here": "Сюда переместить нельзя", + "This row covers more than one disk: drop on a folder inside it": "Эта строка охватывает несколько дисков: перетащите на папку внутри неё", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "Локальных фото ещё нет в библиотеке: сначала «Добавить в «Мои фото»», затем перемещайте", "Add to Selected Photos": "Добавить к выбранным фото", "Add to Target Album": "Добавить в целевой альбом", "Add to this mask": "Добавить к этой маске", @@ -222,6 +225,12 @@ "Concrete Cool": "Холодный бетон", "Confirm before deleting": "Подтверждать удаление", "Confirm before moving photos to Recently Deleted": "Подтверждать перемещение фото в «Недавно удалённые»", + "Move Photos": "Перемещение фото", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Их файлы и XMP-файлы перемещаются на диске. Правка → Отменить вернёт их.", + "Don't ask again": "Больше не спрашивать", + "Another library was opened since the photos were dropped: nothing was moved": "После перетаскивания фото была открыта другая библиотека: ничего не перемещено", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Настройки → «Подтверждать перемещение фото в другую папку на диске» включает его снова", + "Confirm before moving photos to another folder on disk": "Подтверждать перемещение фото в другую папку на диске", "Constrain Crop": "Ограничить кадрирование", "Continue Without Saving": "Продолжить без сохранения", "Contrast": "Контрастность", diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 2040873ca..947b0fc36 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -289,5 +289,9 @@ "Renames {n} face{} across the library. The files are not changed.": "Перейменовує {n} облич у бібліотеці. Файли не змінюються.{:.0}", "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "«{to}» уже є серед людей. Об’єднання лишає одну людину; скасування знову розділяє їх.", "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}: об’єднання додано в розмірі {mp}: для повного розміру ({full}) бракує пам’яті цього комп’ютера", - "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}: об’єднання додано в розмірі {mp} — це найбільший розмір панорами (повний розмір: {full})" + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}: об’єднання додано в розмірі {mp} — це найбільший розмір панорами (повний розмір: {full})", + "Already in “{}”": "Уже в «{}»", + "Move {n} photo{} to “{}”": "Перемістити до «{1}» фото: {n}{0:.0}", + "Move {n} photo{} to “{}”?": "Перемістити до «{1}» фото: {n}{0:.0}?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "До «{1}» переміщено фото: {n}{0:.0}. Редагування → Скасувати поверне їх." } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 8bb244ac2..f482572fe 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -381,6 +381,9 @@ "Add to Favorites": "Додати до улюблених", "Add to Local": "Додати до локальних", "Add to My Photos": "Додати до моїх фото", + "Can't be moved here": "Сюди перемістити не можна", + "This row covers more than one disk: drop on a folder inside it": "Цей рядок охоплює кілька дисків: перетягніть на теку всередині нього", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "Локальних фото ще немає в бібліотеці: спершу «Додати до моїх фото», потім переміщуйте", "Add to Selected Photos": "Додати до вибраних фото", "Add to Target Album": "Додати до цільового альбому", "Add to this mask": "Додати до цієї маски", @@ -552,6 +555,12 @@ "Concrete Cool": "Холодний бетон", "Confirm before deleting": "Підтверджувати перед видаленням", "Confirm before moving photos to Recently Deleted": "Підтверджувати перед переміщенням фото до нещодавно видалених", + "Move Photos": "Переміщення фото", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Їхні файли та XMP-файли переміщуються на диску. Редагування → Скасувати поверне їх.", + "Don't ask again": "Більше не питати", + "Another library was opened since the photos were dropped: nothing was moved": "Після перетягування фото було відкрито іншу бібліотеку: нічого не переміщено", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Налаштування → «Підтверджувати перед переміщенням фото до іншої теки на диску» вмикає його знову", + "Confirm before moving photos to another folder on disk": "Підтверджувати перед переміщенням фото до іншої теки на диску", "Constrain Crop": "Обмежити кадрування", "Continue": "Продовжити", "Convert to DNG": "Перетворити на DNG", diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index f93aaf809..7a269d8f4 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -13,6 +13,7 @@ "Added {n} photo{} to My Photos": "已将 {n} 张照片添加到我的照片{0:.0}", "Added {n} photo{} to “{}”": "已将 {n} 张照片添加到“{1}”{0:.0}", "All {n} selected photos move by the same amount.": "所选 {n} 张照片将按相同幅度移动。", + "Already in “{}”": "已在“{}”中", "Auto Sync: edits apply to {n} photos": "自动同步:编辑将应用到 {n} 张照片", "Automatic ({} MB)": "自动({0} MB)", "Click {}:1": "单击 {0}:1", @@ -39,7 +40,10 @@ "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft 无法打开窗口:{e}\n\n详细信息见日志文件:{path}", "Matches: {}": "匹配项:{0}", "Merging… {stage} {:.0}%": "合成中… {stage} {0:.0}%", + "Move {n} photo{} to “{}”": "将 {n} 张照片移到“{1}”{0:.0}", + "Move {n} photo{} to “{}”?": "将 {n} 张照片移到“{1}”?{0:.0}", "Move {what} to Recently Deleted?": "将 {what} 移到最近删除?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "已将 {n} 张照片移到“{1}”{0:.0}。编辑 → 撤销 可移回。", "Named ({named_n})": "已命名({named_n})", "Paste into {n} photo{} — only settings that were copied are pasted": "粘贴到 {n} 张照片 — 仅粘贴已复制的设置{0:.0}", "Preset: {name}": "预设:{name}", diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index d60edc75b..6e97c3cb1 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -41,6 +41,9 @@ "Add to Favorites": "添加到收藏夹", "Add to Local": "添加到本地", "Add to My Photos": "添加到我的照片", + "Can't be moved here": "无法移到这里", + "This row covers more than one disk: drop on a folder inside it": "此行涵盖多个磁盘:请拖放到其中的文件夹", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "本地照片尚未在图库中:请先“添加到我的照片”,再移动", "Add to Selected Photos": "添加到所选照片", "Add to Target Album": "添加到目标相册", "Add to this mask": "添加到此蒙版", @@ -269,6 +272,12 @@ "Concrete Cool": "冷调混凝土", "Confirm before deleting": "删除前确认", "Confirm before moving photos to Recently Deleted": "将照片移到“最近删除”前确认", + "Move Photos": "移动照片", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "它们的文件和 XMP 附属文件会在磁盘上移动。编辑 → 撤销 可移回。", + "Don't ask again": "不再询问", + "Another library was opened since the photos were dropped: nothing was moved": "拖放照片后打开了另一个图库:未移动任何内容", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "设置 → “将照片移到磁盘上其他文件夹前确认”可重新开启", + "Confirm before moving photos to another folder on disk": "将照片移到磁盘上其他文件夹前确认", "Constrain Crop": "限制裁剪", "Continue": "继续", "Continue Without Saving": "不保存继续", diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index f3bd24b20..7f6a1ce4e 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -14,6 +14,7 @@ "Added {n} photo{} to “{}”": "已將 {n} 張照片加入「{1}」{0:.0}", "Adding photos… {} of {}": "正在新增照片… {}/{}", "All {n} selected photos move by the same amount.": "所有 {n} 張選取的照片會調整相同的時間。", + "Already in “{}”": "已在「{}」中", "Auto ({})": "自動({})", "Auto Sync: edits apply to {n} photos": "自動同步:編輯會套用至 {n} 張照片", "Automatic ({} MB)": "自動({} MB)", @@ -53,7 +54,10 @@ "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft 無法開啟視窗:{e}\n\n詳細資訊請見記錄檔:{path}", "Matches: {}": "符合條件:{}", "Merging… {stage} {:.0}%": "正在合併… {stage} {:.0}%", + "Move {n} photo{} to “{}”": "將 {n} 張照片移到「{1}」{0:.0}", + "Move {n} photo{} to “{}”?": "將 {n} 張照片移到「{1}」?{0:.0}", "Move {what} to Recently Deleted?": "要將{what}移至「最近刪除」嗎?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "已將 {n} 張照片移到「{1}」{0:.0}。編輯 → 復原 可移回。", "Named ({named_n})": "已命名({named_n})", "Paste into {n} photo{} — only settings that were copied are pasted": "貼上至 {n} 張照片{:.0} — 只貼上已複製的設定", "Preset: {name}": "預設集:{name}", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index ce3b17f32..6d0322c61 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -41,6 +41,9 @@ "Add to Favorites": "加入喜好項目", "Add to Local": "加入本機", "Add to My Photos": "加入我的照片", + "Can't be moved here": "無法移到這裡", + "This row covers more than one disk: drop on a folder inside it": "此列涵蓋多個磁碟:請拖放到其中的資料夾", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "本機照片尚未在圖庫中:請先「加入我的照片」,再移動", "Add to Selected Photos": "加入選取的照片", "Add to Target Album": "加入目標相簿", "Add to this mask": "加入此遮色片", @@ -269,6 +272,12 @@ "Concrete Cool": "冷調混凝土", "Confirm before deleting": "刪除前確認", "Confirm before moving photos to Recently Deleted": "將照片移至「最近刪除」前確認", + "Move Photos": "移動照片", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "它們的檔案和 XMP 附屬檔案會在磁碟上移動。編輯 → 復原 可移回。", + "Don't ask again": "不再詢問", + "Another library was opened since the photos were dropped: nothing was moved": "拖放照片後開啟了另一個圖庫:未移動任何內容", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "設定 → 「將照片移至磁碟上其他資料夾前確認」可重新開啟", + "Confirm before moving photos to another folder on disk": "將照片移至磁碟上其他資料夾前確認", "Constrain Crop": "限制裁切範圍", "Continue": "繼續", "Continue Without Saving": "繼續使用但不儲存", diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d4520bfa8..c57f8d7d9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -79,6 +79,8 @@ mod tests_error_kinds; #[cfg(test)] mod tests_filmstrip; #[cfg(test)] +mod tests_folder_drop; +#[cfg(test)] mod tests_grading; #[cfg(test)] mod tests_grid; diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index a7779fcd6..e6f967f21 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -257,6 +257,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Merge { opts } => opts.title(), Dialog::Settings { .. } => "Settings", Dialog::ConfirmDelete { .. } => "Delete Photos", + Dialog::MovePhotos { .. } => "Move Photos", Dialog::LightroomCatalog { .. } => "Lightroom Classic Catalog", Dialog::SaveEffect { .. } => "New Effect Preset", Dialog::ManageEffects { .. } => "Manage Effect Presets", @@ -1216,6 +1217,15 @@ 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)); } + Dialog::MovePhotos { ids, name, dont_ask, .. } => { + let n = ids.len(); + ui.label(crate::i18n::tr_format!("Move {n} photo{} to “{}”?", if n == 1 { "" } else { "s" }, name, n = n)); + ui.label(egui::RichText::new(crate::i18n::tr("Their files and XMP sidecars move on disk. Edit → Undo moves them back.")).color(t.text_dim)); + ui.add_space(4.0); + let c = ui.checkbox(dont_ask, crate::i18n::tr("Don't ask again")); + crate::widgets::register(ui.ctx(), "check:moveDontAsk", c.rect); + c.on_hover_text(crate::i18n::tr("Settings → Confirm before moving photos to another folder on disk turns it back on")); + } d @ Dialog::SynchronizeFolder { .. } => crate::sync::body(app, ui, d), Dialog::LightroomCatalog { folder, catalog } => { ui.label(crate::i18n::tr("This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?")); @@ -1338,6 +1348,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::FaceModel { .. } if !informational => "Install", Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } | Dialog::DeleteEffects { .. } => "Delete", Dialog::SaveEffect { .. } => "Create", + Dialog::MovePhotos { .. } => "Move", // (not "Merge": that is photo merging, HDR / panorama, in some languages) Dialog::MoveKeyword { .. } => "Merge Keywords", Dialog::KeywordSet { .. } => "Save", @@ -1441,6 +1452,18 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { crate::panels::grid::toast_skipped(app, ctx, &r); close = true; } + // a move says how it went, or why nothing moved + Ok(r) if matches!(dlg, Dialog::MovePhotos { .. }) => { + if let Dialog::MovePhotos { name, .. } = &dlg { + let text = crate::panels::left::moved_text(r["moved"].as_u64().unwrap_or(0), name); + app.toast(ctx, text); + } + close = true; + } + Err(e) if matches!(dlg, Dialog::MovePhotos { .. }) => { + app.toast(ctx, e); + close = true; + } Ok(_) if keeps_open(app, &dlg) => { if let Dialog::SamModel { error, .. } = &mut dlg { *error = None; @@ -1857,6 +1880,17 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result crate::merge::start_final(app, opts), Dialog::Import { opts } => crate::import::start(app, opts), Dialog::ConfirmDelete { .. } => app.run("photo.delete", json!({})), + Dialog::MovePhotos { ids, folder, dont_ask, library, .. } => { + // another library opened meanwhile (from the menu bar, an agent): these numbers are + // other photos there + if *library != app.session.library_generation() { + return Err(crate::i18n::tr("Another library was opened since the photos were dropped: nothing was moved").to_string()); + } + if *dont_ask { + app.ui.settings.ask_before_move = false; + } + crate::panels::left::move_photos(app, Some(ids), folder).map(|n| json!({"moved": n})) + } // the catalog found: no file dialog; none: File ▸ Import Lightroom Catalog… asks for it Dialog::LightroomCatalog { catalog, .. } => { app.run("file.importLightroom", catalog.as_ref().map_or_else(|| json!({}), |c| json!({"path": c}))) diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 1aaddbeea..aaa3fd663 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -906,6 +906,13 @@ fn folder_breadcrumbs(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str) { /// While photos are dragged: a badge at the pointer; the drag ends when the button is up /// (drop targets act on the release frame, before this runs). +/// What the label following dragged photos says this frame instead of how many there are: where a +/// drop would put them (set by the drop target under the pointer, read by [`drag_feedback`]). +pub fn drag_hint(ctx: &egui::Context, text: String) { + let frame = ctx.cumulative_frame_nr(); + ctx.data_mut(|d| d.insert_temp(egui::Id::new("drag-photos-hint"), (frame, text))); +} + pub fn drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { let Some(ids) = &app.ui.dragging_photos else { return }; let (released, down, pos) = ctx.input(|i| (i.pointer.any_released(), i.pointer.any_down(), i.pointer.latest_pos())); @@ -917,13 +924,14 @@ pub fn drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let n = ids.len(); ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + // a drop target under the pointer says where they would go (only this frame's) + let frame = ctx.cumulative_frame_nr(); + let hint = ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::new("drag-photos-hint"))).filter(|(f, _)| *f == frame).map(|(_, text)| text); + let text = hint.unwrap_or_else(|| crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)); egui::Area::new(egui::Id::new("drag-photos")).order(egui::Order::Tooltip).interactable(false).fixed_pos(pos + vec2(14.0, 10.0)).show(ctx, |ui| { egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| { - ui.label( - egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)) - .color(Color32::WHITE) - .font(t.semibold(12.0)), - ); + let label = ui.add(egui::Label::new(egui::RichText::new(text).color(Color32::WHITE).font(t.semibold(12.0))).extend()); + crate::widgets::register(ui.ctx(), "dragPhotos", label.rect); }); }); } diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index ee3d7eae9..0a734c8bb 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -1765,6 +1765,7 @@ fn folder_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, id: &s }; let mark = n.label.map(crate::theme::label_color); let resp = row_sensed(app, ui, id, Icon::Folder, name, Some(&spoken), Some(n.count), sel, indent, Sense::click(), mark); + folder_drop_target(app, ui, &resp, n, open_id, open); let mut toggled = false; if !n.children.is_empty() { let tr = tree_toggle(ui, &resp, indent, open_id, open, format!("libraryFolderToggle:{}", n.path)); @@ -1786,6 +1787,134 @@ fn folder_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, id: &s row_menu(app, &resp, n); } +/// How long photos dragged over a closed Folders row wait before it opens, so a folder inside it +/// can be reached. +const SPRING_OPEN_SECONDS: f64 = 0.7; + +/// A Folders row while photos are dragged from the grid: a release moves their files there on +/// disk (`photo.moveToFolder`, one undo step). The row lights up and the dragged label says where +/// they will go, as the same gesture onto an album only adds them to it. Held over a closed row, +/// it opens. Photos that can't go there leave it dark (a row covering several disks; Local photos, +/// not in the library) and a release says why; photos already there say so. +fn folder_drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, n: &FolderNode, open_id: egui::Id, open: &mut bool) { + let Some(ids) = app.ui.dragging_photos.clone() else { return }; + // only where the row can be seen: not scrolled out of the sidebar or beneath the pinned header + if !pointer_over_row(ui, resp.rect) { + return; + } + spring_open(ui, n, open_id, open); + let name = std::path::Path::new(&n.path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| n.name.clone()); + let photos: Vec<_> = ids.iter().filter_map(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(*id))).collect(); + let refused = if !n.selectable { + Some(crate::i18n::tr("This row covers more than one disk: drop on a folder inside it")) + } else if photos.iter().any(|p| p.local) { + Some(crate::i18n::tr("Local photos aren't in the library yet: Add to My Photos first, then move them")) + } else { + None + }; + let here = |p: &lightcraft_catalog::Photo| match &p.source { + lightcraft_catalog::Source::File { path } => std::path::Path::new(path).parent().is_some_and(|d| same_folder(&d.to_string_lossy(), &n.path)), + _ => false, + }; + let all_here = !photos.is_empty() && photos.iter().all(|p| here(p)); + let count = photos.len(); + let released = ui.input(|i| i.pointer.any_released()); + let hint = match (&refused, all_here) { + (Some(_), _) => crate::i18n::tr("Can't be moved here").to_string(), + (None, true) => crate::i18n::tr_format!("Already in “{}”", name), + (None, false) => crate::i18n::tr_format!("Move {n} photo{} to “{}”", if count == 1 { "" } else { "s" }, name, n = count), + }; + super::grid::drag_hint(ui.ctx(), hint); + if let Some(why) = refused { + ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed); + if released { + app.toast(ui.ctx(), why); + app.ui.dragging_photos = None; + } + return; + } + if all_here { + if released { + app.ui.dragging_photos = None; + } + return; + } + let t = Tokens::get(ui.ctx()); + ui.painter().rect_stroke(resp.rect.shrink2(vec2(8.0, 1.0)), 4.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); + if released { + app.ui.dragging_photos = None; + // a drop moves files on disk: asked first (as Lightroom Classic does), unless the person + // said not to; the menu's Move Selected Photos Here is asked for by name and goes at once + if app.ui.settings.ask_before_move { + let library = app.session.library_generation(); + app.ui.dialog = Some(crate::state::Dialog::MovePhotos { ids, folder: n.path.clone(), name, dont_ask: false, library }); + } else { + move_photos_here(app, ui.ctx(), Some(&ids), &n.path, &name); + } + } +} + +/// Photos dragged over the closed row `n` for [`SPRING_OPEN_SECONDS`] open it (a row with folders +/// inside it). Restarts on each row the pointer comes to, and when it comes back to one after +/// leaving it (or after another drag). +fn spring_open(ui: &egui::Ui, n: &FolderNode, open_id: egui::Id, open: &mut bool) { + if *open || n.children.is_empty() { + return; + } + let key = egui::Id::new("folder-spring-open"); + let (now, frame) = (ui.input(|i| i.time), ui.ctx().cumulative_frame_nr()); + // held without a break: over this row last frame too + let since = match ui.data(|d| d.get_temp::<(String, f64, u64)>(key)) { + Some((path, since, seen)) if path == n.path && seen.saturating_add(1) >= frame => since, + _ => now, + }; + ui.data_mut(|d| d.insert_temp(key, (n.path.clone(), since, frame))); + if now - since >= SPRING_OPEN_SECONDS { + *open = true; + set_tree_open(ui, open_id, true); + ui.data_mut(|d| d.remove::<(String, f64, u64)>(key)); + } else { + // keep the clock running while the pointer rests + ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); + } +} + +/// Move photos (`ids`, else the selection) into the folder `path` on disk (`photo.moveToFolder`). +/// Photos that leave the view leave the selection, so nothing done next (Delete, an edit) reaches +/// photos no longer on screen. How many moved, or why none did. +pub(crate) fn move_photos(app: &mut LightcraftApp, ids: Option<&[u64]>, path: &str) -> Result { + let mut params = json!({"folder": path}); + if let Some(ids) = ids { + params["ids"] = json!(ids); + } + let r = app.run("photo.moveToFolder", params)?; + let visible: std::collections::HashSet = app.session.visible_cloned().into_iter().collect(); + let sel = &mut app.session.selection; + sel.ids.retain(|id| visible.contains(id)); + if sel.active.is_some_and(|a| !visible.contains(&a)) { + sel.active = sel.ids.first().copied(); + } + Ok(r["moved"].as_u64().unwrap_or(0)) +} + +/// What a move of `n` photos into the folder `name` says: how many moved and that Undo moves them +/// back, or that they were there already. +pub(crate) fn moved_text(n: u64, name: &str) -> String { + if n == 0 { + crate::i18n::tr_format!("Already in “{}”", name) + } else { + crate::i18n::tr_format!("Moved {n} photo{} to “{}”. Edit → Undo moves them back.", if n == 1 { "" } else { "s" }, name, n = n) + } +} + +/// [`move_photos`], saying how it went in a toast. +fn move_photos_here(app: &mut LightcraftApp, ctx: &egui::Context, ids: Option<&[u64]>, path: &str, name: &str) { + match move_photos(app, ids, path) { + Ok(n) => app.toast(ctx, moved_text(n, name)), + Err(e) => app.toast(ctx, e), + } +} + /// The context menu of a Folders row: a folder's own, a disk's (remove it), none for the /// startup disk. fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) { @@ -1931,10 +2060,7 @@ fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &F .on_hover_text(crate::i18n::tr("Moves the files and their XMP sidecars into this folder; never overwrites")) .clicked() { - match app.run("photo.moveToFolder", json!({"folder": path})) { - Ok(r) => app.toast(ui.ctx(), crate::i18n::tr_format!("Moved {} photo(s)", r["moved"])), - Err(e) => app.toast(ui.ctx(), e), - } + move_photos_here(app, ui.ctx(), None, path, &name); ui.close(); } if app.services.reveal.is_some() diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index c08f753f5..c50a412bb 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -149,6 +149,7 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { heading(ui, t, crate::i18n::tr("Culling")); check(ui, "settings.autoAdvance", &mut app.ui.auto_advance, "Auto Advance: move to the next photo after rating or flagging"); check(ui, "settings.confirmDelete", &mut app.ui.settings.confirm_delete, "Confirm before moving photos to Recently Deleted"); + check(ui, "settings.askBeforeMove", &mut app.ui.settings.ask_before_move, "Confirm before moving photos to another folder on disk"); heading(ui, t, crate::i18n::tr("External Editing")); row(ui, t, crate::i18n::tr("PhotoCraft"), |ui| { crate::text_field::TextField::singleline("field:photocraftPath", &mut app.ui.settings.photocraft_path) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 09c5bf4a1..dee10f201 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -343,6 +343,9 @@ pub struct AppSettings { /// `confirmDelete: false` by default (#1239). #[serde(rename = "askBeforeDelete")] pub confirm_delete: bool, + /// Ask before photos dropped on a Folders row move there on disk (the dialog's "Don't ask + /// again" turns it off). + pub ask_before_move: bool, /// GPU rendering allowed (`app.gpu`). pub gpu: bool, /// Largest long edge (pixels) the user lets the loupe render at; 0 = Automatic (the size it is @@ -392,6 +395,7 @@ impl Default for AppSettings { recent_libraries: Vec::new(), startup_view: StartupView::Last, confirm_delete: true, + ask_before_move: true, gpu: true, preview_limit: 0, external_editor: String::new(), @@ -1182,6 +1186,17 @@ pub enum Dialog { ConfirmDelete { count: usize, }, + /// Confirm moving photos dropped on a Folders row into that folder on disk + /// (`photo.moveToFolder`). `name`: the folder's name, as the question says it. + MovePhotos { + ids: Vec, + folder: String, + name: String, + dont_ask: bool, + /// The library the photos were dropped in (`Session::library_generation`): ids are + /// numbered per library, so in another one they name other photos. + library: u64, + }, /// Confirm taking a folder's photos out of the library (`library.removeFolder`). RemoveFolder { path: String, diff --git a/crates/ui-egui/src/tests_folder_drop.rs b/crates/ui-egui/src/tests_folder_drop.rs new file mode 100644 index 000000000..9b4cb860a --- /dev/null +++ b/crates/ui-egui/src/tests_folder_drop.rs @@ -0,0 +1,343 @@ +//! Photos dragged from the grid onto a Folders row move there on disk: the row lights up, the +//! dragged label says where they will go, a closed folder held under the pointer opens, and a +//! release asks first (unless the person said not to), then moves the files (one undo step). +//! Photos that can't go there, or are there already, say so and stay where they are. + +use std::path::{Path, PathBuf}; + +use egui::{Pos2, Rect}; +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +/// Folders `trip` and `home` in a fresh temporary folder, with a photo file in each. +fn folders(tag: &str) -> (PathBuf, PathBuf, PathBuf) { + let root = std::env::temp_dir().join(format!("lc-folder-drop-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let (trip, home) = (root.join("trip"), root.join("home")); + for (dir, file) in [(&trip, "beach.png"), (&home, "garden.png")] { + std::fs::create_dir_all(dir).unwrap(); + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[90, 120, 160, 255]; 64] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(dir.join(file), png).unwrap(); + } + (root, trip, home) +} + +/// The photos at `paths` in the catalog (`local`: browsed, not in the library), in the grid with +/// the sidebar out, tall enough for the Folders section. +fn app(paths: &[(PathBuf, bool)]) -> Headless { + use lightcraft_catalog::{Op, Photo, Source}; + let mut session = lightcraft_engine::Session::new().with_fs(); + for (path, local) in paths { + let id = session.catalog.alloc_photo_id(); + let name = path.file_name().unwrap().to_string_lossy().to_string(); + let mut p = Photo::new(id, Source::File { path: path.to_string_lossy().into() }, &name, "PNG", 8, 8, "2026-01-01T10:00:00"); + p.local = *local; + session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + let mut h = Headless::new(LightcraftApp::new(session, Services { png: None, ..Default::default() }), [1300.0, 1600.0], 1.0); + let r = h.request("ui.set", json!({"view": "photoGrid", "leftPanel": true, "sidebar": ["folders"]}), std::time::Duration::from_secs(10)); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..6 { + h.step(); + } + h +} + +fn widget(h: &Headless, id: &str) -> Option { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r) +} + +fn folder_row(h: &Headless, dir: &Path) -> Option { + widget(h, &format!("source:libfolder:{}", dir.to_string_lossy())) +} + +fn id_of(h: &Headless, file: &str) -> u64 { + h.app.session.catalog.photos().find(|p| p.file_name == file).map(|p| p.id.0).unwrap() +} + +fn press(h: &mut Headless, at: Pos2) { + h.app.synthetic.push(egui::Event::PointerMoved(at)); + h.app.synthetic.push(egui::Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + h.step(); +} + +/// Move the pointer, pressed, from `from` to `to` over a few frames. +fn glide(h: &mut Headless, from: Pos2, to: Pos2) { + for i in 1..=8 { + h.app.synthetic.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 8.0))); + h.step(); + } +} + +fn release(h: &mut Headless, at: Pos2) { + h.app.synthetic.push(egui::Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + h.step(); +} + +/// What the dragged label says this frame instead of the count, if a drop target set it. +fn hint(h: &Headless) -> Option { + h.view.ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::new("drag-photos-hint"))).map(|(_, text)| text) +} + +/// Click a widget the way a person does (press and release on it). +fn click(h: &mut Headless, id: &str) { + let at = widget(h, id).unwrap_or_else(|| panic!("no {id}")).center(); + press(h, at); + release(h, at); +} + +fn toast(h: &Headless) -> String { + h.app.ui.toast.as_ref().map(|(t, _, _)| t.clone()).unwrap_or_default() +} + +#[test] +fn photos_dropped_on_a_folder_move_there_on_disk() { + let (root, trip, home) = folders("move"); + let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]); + let beach = id_of(&h, "beach.png"); + // the two folders sit in one that starts closed: only it is there to drop on + let parent = folder_row(&h, &root).unwrap_or_else(|| { + panic!("the folder holding both: {:?}", h.app.widgets.iter().filter(|(w, _)| w.starts_with("source:")).collect::>()) + }); + assert!(folder_row(&h, &home).is_none(), "closed"); + // Given the beach photo dragged from the grid and held over the closed folder + let from = widget(&h, &format!("thumb:{beach}")).unwrap().center(); + press(&mut h, from); + glide(&mut h, from, parent.center()); + assert_eq!(h.app.ui.dragging_photos, Some(vec![beach])); + for _ in 0..60 { + h.step(); + } + // Then it opens, so a folder inside it can be reached + let target = folder_row(&h, &home).expect("held over, the folder opened"); + // When it is brought over home: the label says where it will go + glide(&mut h, parent.center(), target.center()); + assert_eq!(hint(&h).as_deref(), Some("Move 1 photo to “home”")); + // and released there: it asks first, nothing moved yet + release(&mut h, target.center()); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::MovePhotos { .. })), "{:?}", h.app.ui.dialog); + assert!(trip.join("beach.png").is_file(), "not before Move"); + h.step(); + click(&mut h, "button:dialogOk"); + // Then the file moved, the photo follows it, and the toast says Undo moves it back + assert!(home.join("beach.png").is_file() && !trip.join("beach.png").exists(), "moved on disk"); + let photo = h.app.session.catalog.photo(lightcraft_catalog::PhotoId(beach)).unwrap(); + assert!(matches!(&photo.source, lightcraft_catalog::Source::File { path } if Path::new(path) == home.join("beach.png"))); + assert!(toast(&h).starts_with("Moved 1 photo to “home”. Edit → Undo moves them back."), "{}", toast(&h)); + assert!(h.app.ui.dragging_photos.is_none(), "the drag ended"); + // and one Undo moves it back + h.app.run("edit.undo", json!({})).unwrap(); + assert!(trip.join("beach.png").is_file() && !home.join("beach.png").exists(), "back where it was"); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn local_photos_are_refused_with_the_reason() { + let (root, trip, home) = folders("local"); + // the garden photo imported; the beach one only looked at under Local, where the grid shows it + let mut h = app(&[(home.join("garden.png"), false)]); + let r = h.request( + "engine.execute", + json!({"command": "library.browse", "params": {"path": trip.to_string_lossy()}}), + std::time::Duration::from_secs(10), + ); + assert_eq!(r["ok"], true, "{r}"); + // (the folder is read in the background) + let shown = |h: &Headless| { + h.app.session.catalog.photos().any(|p| p.file_name == "beach.png") && h.app.widgets.iter().any(|(w, _)| w.starts_with("thumb:")) + }; + h.step_until(std::time::Duration::from_secs(20), |h| shown(h)); + let beach = id_of(&h, "beach.png"); + assert!(h.app.session.catalog.photo(lightcraft_catalog::PhotoId(beach)).unwrap().local); + let target = folder_row(&h, &home).expect("the imported folder"); + let from = widget(&h, &format!("thumb:{beach}")).unwrap().center(); + press(&mut h, from); + glide(&mut h, from, target.center()); + // Then the row says it can't take them, and a release says why + assert_eq!(hint(&h).as_deref(), Some("Can't be moved here")); + release(&mut h, target.center()); + assert!(trip.join("beach.png").is_file() && !home.join("beach.png").exists(), "stays"); + assert!(toast(&h).contains("Add to My Photos"), "{}", toast(&h)); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn photos_already_in_the_folder_stay_put() { + let (root, trip, home) = folders("here"); + let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]); + // open the folder holding both + let toggle = widget(&h, &format!("libraryFolderToggle:{}", root.to_string_lossy())).unwrap(); + h.app.synthetic.push(egui::Event::PointerMoved(toggle.center())); + h.app.synthetic.push(egui::Event::PointerButton { + pos: toggle.center(), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + release(&mut h, toggle.center()); + let target = folder_row(&h, &home).expect("opened"); + let garden = id_of(&h, "garden.png"); + h.app.ui.dragging_photos = Some(vec![garden]); + h.app.synthetic.push(egui::Event::PointerMoved(target.center())); + h.app.synthetic.push(egui::Event::PointerButton { + pos: target.center(), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + assert_eq!(hint(&h).as_deref(), Some("Already in “home”")); + let undo = h.app.session.undo.len(); + release(&mut h, target.center()); + assert!(home.join("garden.png").is_file()); + assert_eq!(h.app.session.undo.len(), undo, "nothing done"); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn photos_that_leave_the_view_leave_the_selection() { + let (root, trip, home) = folders("selection"); + std::fs::copy(trip.join("beach.png"), trip.join("dunes.png")).unwrap(); + let mut h = app(&[(trip.join("beach.png"), false), (trip.join("dunes.png"), false), (home.join("garden.png"), false)]); + // shown by folder: trip's two photos, both selected + let r = h.request( + "engine.execute", + json!({"command": "library.source", "params": {"kind": "libraryFolder", "path": trip.to_string_lossy()}}), + std::time::Duration::from_secs(10), + ); + assert_eq!(r["ok"], true, "{r}"); + let (beach, dunes) = (id_of(&h, "beach.png"), id_of(&h, "dunes.png")); + h.app.session.selection = + lightcraft_engine::Selection { ids: vec![lightcraft_catalog::PhotoId(beach)], active: Some(lightcraft_catalog::PhotoId(beach)) }; + for _ in 0..4 { + h.step(); + } + let target = folder_row(&h, &home).expect("the other folder is listed beside it"); + let from = widget(&h, &format!("thumb:{beach}")).unwrap().center(); + press(&mut h, from); + glide(&mut h, from, target.center()); + release(&mut h, target.center()); + h.step(); + click(&mut h, "button:dialogOk"); + assert!(home.join("beach.png").is_file(), "moved"); + // it left trip's view, and the selection with it: a Delete next can't reach it unseen + assert!(!h.app.session.selection.contains(lightcraft_catalog::PhotoId(beach)), "{:?}", h.app.session.selection); + assert!(h.app.session.visible_cloned().contains(&lightcraft_catalog::PhotoId(dunes))); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn a_folder_left_and_come_back_to_waits_again_before_opening() { + let (root, trip, home) = folders("spring"); + let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]); + let beach = id_of(&h, "beach.png"); + let parent = folder_row(&h, &root).unwrap(); + let from = widget(&h, &format!("thumb:{beach}")).unwrap().center(); + press(&mut h, from); + // over the closed folder for a moment, then away, longer than it waits + glide(&mut h, from, parent.center()); + glide(&mut h, parent.center(), from); + for _ in 0..60 { + h.step(); + } + // back over it: it doesn't open at once + glide(&mut h, from, parent.center()); + assert!(folder_row(&h, &home).is_none(), "came back to: it waits again"); + for _ in 0..60 { + h.step(); + } + assert!(folder_row(&h, &home).is_some(), "held: it opens"); + release(&mut h, parent.center()); + let _ = std::fs::remove_dir_all(&root); +} + +/// Drag the beach photo from the grid onto `target` and let go. +fn drop_beach(h: &mut Headless, beach: u64, target: Rect) { + let from = widget(h, &format!("thumb:{beach}")).unwrap().center(); + press(h, from); + glide(h, from, target.center()); + release(h, target.center()); + h.step(); +} + +#[test] +fn cancelling_the_question_moves_nothing() { + let (root, trip, home) = folders("cancel"); + let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]); + let beach = id_of(&h, "beach.png"); + let target = folder_row(&h, &home).unwrap_or_else(|| folder_row(&h, &root).unwrap()); + let target = if folder_row(&h, &home).is_some() { + target + } else { + click(&mut h, &format!("libraryFolderToggle:{}", root.to_string_lossy())); + folder_row(&h, &home).unwrap() + }; + let undo = h.app.session.undo.len(); + drop_beach(&mut h, beach, target); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::MovePhotos { .. }))); + click(&mut h, "button:dialogCancel"); + assert!(h.app.ui.dialog.is_none()); + assert!(trip.join("beach.png").is_file() && !home.join("beach.png").exists(), "nothing moved"); + assert_eq!(h.app.session.undo.len(), undo); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn dont_ask_again_moves_the_next_drop_at_once() { + let (root, trip, home) = folders("dont-ask"); + std::fs::copy(trip.join("beach.png"), trip.join("dunes.png")).unwrap(); + let mut h = app(&[(trip.join("beach.png"), false), (trip.join("dunes.png"), false), (home.join("garden.png"), false)]); + click(&mut h, &format!("libraryFolderToggle:{}", root.to_string_lossy())); + let target = folder_row(&h, &home).unwrap(); + let beach = id_of(&h, "beach.png"); + h.app.session.selection = lightcraft_engine::Selection::single(lightcraft_catalog::PhotoId(beach)); + drop_beach(&mut h, beach, target); + // the question, with Don't ask again ticked + click(&mut h, "check:moveDontAsk"); + click(&mut h, "button:dialogOk"); + assert!(home.join("beach.png").is_file()); + assert!(!h.app.ui.settings.ask_before_move, "kept in Settings"); + // the next drop goes at once + let dunes = id_of(&h, "dunes.png"); + h.app.session.selection = lightcraft_engine::Selection::single(lightcraft_catalog::PhotoId(dunes)); + h.step(); + let target = folder_row(&h, &home).unwrap(); + drop_beach(&mut h, dunes, target); + assert!(h.app.ui.dialog.is_none(), "not asked"); + assert!(home.join("dunes.png").is_file(), "moved at once"); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn a_question_left_open_while_another_library_opens_moves_nothing() { + let (root, trip, home) = folders("other-library"); + let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]); + click(&mut h, &format!("libraryFolderToggle:{}", root.to_string_lossy())); + let beach = id_of(&h, "beach.png"); + let target = folder_row(&h, &home).unwrap(); + drop_beach(&mut h, beach, target); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::MovePhotos { .. }))); + // another library opens meanwhile (the menu bar and agents reach past a dialog) + let other = root.join("other-library"); + h.app.session.open_library(other.join("lib"), false).unwrap(); + let r = h.request("ui.dialog.confirm", json!({}), std::time::Duration::from_secs(10)); + assert_eq!(r["ok"], false, "{r}"); + assert!(r["error"].as_str().unwrap_or_default().contains("Another library was opened"), "{r}"); + assert!(trip.join("beach.png").is_file() && !home.join("beach.png").exists(), "nothing moved"); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index ee05a4f55..fbd1b8a9a 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -702,7 +702,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | 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`); the lock is released explicitly when the library closes, not by letting its file handle close, so a program another thread is starting at that moment (which holds a copy of every file handle until it is launched) cannot keep the library "already open" for a moment (issue #680) | | LRC-LIB-CATALOG-SWITCH | New / Open Recent catalog, catalog name shown | P1 | ✅ | `cmd:app.newLibrary`, `cmd:app.openLibrary`, `cmd:app.recentLibraries`, `cmd:app.clearRecentLibraries`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/left.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Clear), the open library's name under My Photos and in the window title (issue #902); a library that can't be opened leaves the current one open; ⌘O (Ctrl+O on Windows and Linux) runs Open Library… and asks for a folder once (`crates/ui-egui/src/tests_keymap.rs`: `cmd_o_opens_the_library_picker_once`); a dialog or a text field with the focus keeps the key | | 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.create`, `cmd:photo.moveToFolder`, `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…, New Folder on Disk… (`folder.create`; one undo step, undo removes it while empty), Move Selected Photos Here (`photo.moveToFolder`, also *Move to Folder…* in the photo context menu: files and XMP sidecars move, virtual copies follow, across volumes copied and verified before the source goes, a name taken in the destination refuses the whole move, one undo step), 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, shown in the activity stack with ✕ to stop, 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); photos can't yet be dragged onto a folder row (use the menus); a name clash on moving photos is refused rather than renamed -1, -2; 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-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.create`, `cmd:photo.moveToFolder`, `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…, New Folder on Disk… (`folder.create`; one undo step, undo removes it while empty), Move Selected Photos Here (`photo.moveToFolder`, also *Move to Folder…* in the photo context menu, and photos dragged from the grid onto a Folders row: the row lights up, the dragged label says “Move 3 photos to “Trip””, a closed folder held under the pointer opens, a row covering several disks and Local photos are refused with the reason, photos already there say so; a drop asks first (“Move 2 photos to “Porto”?”, Don't ask again, Settings → Confirm before moving photos to another folder on disk), and photos that leave the view leave the selection; files and XMP sidecars move, virtual copies follow, across volumes copied (streamed) and verified before the source goes, a name taken in the destination refuses the whole move, one undo step; an undo with the share offline or read-only is refused with the reason and kept until it can run, nothing deleted), 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, shown in the activity stack with ✕ to stop, 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); photos can't yet be dragged onto a folder row (use the menus); a name clash on moving photos is refused rather than renamed -1, -2; 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), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album — a plain or a smart album, so one smart album can include or exclude another; a rule that would make an album include itself, directly or through others, is refused, and a loop saved anyway can't recurse: inside it, the album being evaluated counts as holding no photos, so an "isn't" rule on it matches every photo, and the sidebar marks it ⚠ —, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`, and after whole seconds a fraction and a time zone as imported capture times carry them (`.250+02:00`); no 30 February; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing album (plain or smart, not a folder), no empty groups; each problem names its rule (`rule 2.1: no rating 0–5 is 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album with an album picker (`crates/ui-egui/src/album_picker.rs`: the sidebar's tree with folders, or type part of a name or folder; ↑ / ↓ / Enter; albums that can't be picked shown greyed with the reason) (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | |