From 8924ea9afdb06f1b862c450462049cc504e1c8c1 Mon Sep 17 00:00:00 2001 From: Andrew Frahn Date: Sun, 11 Oct 2026 20:49:19 +1100 Subject: [PATCH] Library: create folders on disk and move photos between folders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit folder.create {parent, name} makes a folder on disk (one undo step; undo removes it again while it is empty). photo.moveToFolder {folder, ids?} moves the photos' files with their XMP sidecars into another folder and relinks them as one undo step (undo moves them back); virtual copies follow their master. It never overwrites: a name already taken in the destination refuses the whole move with an actionable error. Missing or offline originals and Local photos are refused. Across volumes a file is copied, compared byte for byte and only then removed. The move holds a row in the activity stack. UI: New Folder on Disk… and Move Selected Photos Here on a Folders row, Move to Folder… in the photo context menu; strings in every catalog. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/engine/src/cmd/browse.rs | 139 ++++++++++++++++++++ crates/engine/src/lib.rs | 7 +- crates/engine/src/rename.rs | 30 ++++- crates/engine/src/tests_folders.rs | 121 +++++++++++++++++ crates/ui-egui/locales/de-formats.json | 4 +- crates/ui-egui/locales/de.json | 8 +- crates/ui-egui/locales/es-formats.json | 4 +- crates/ui-egui/locales/es.json | 8 +- crates/ui-egui/locales/fr-formats.json | 4 +- crates/ui-egui/locales/fr.json | 8 +- crates/ui-egui/locales/ja-formats.json | 4 +- crates/ui-egui/locales/ja.json | 8 +- crates/ui-egui/locales/pt-br-formats.json | 4 +- crates/ui-egui/locales/pt-br.json | 8 +- crates/ui-egui/locales/ru-formats.json | 4 +- crates/ui-egui/locales/ru.json | 8 +- crates/ui-egui/locales/uk-formats.json | 4 +- crates/ui-egui/locales/uk.json | 8 +- crates/ui-egui/locales/zh-hans-formats.json | 4 +- crates/ui-egui/locales/zh-hans.json | 8 +- crates/ui-egui/locales/zh-hant-formats.json | 4 +- crates/ui-egui/locales/zh-hant.json | 8 +- crates/ui-egui/src/panels/grid.rs | 9 ++ crates/ui-egui/src/panels/left.rs | 24 ++++ docs/parity-checklist.md | 2 +- 25 files changed, 416 insertions(+), 24 deletions(-) diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs index 6406db479..9e3baeb25 100644 --- a/crates/engine/src/cmd/browse.rs +++ b/crates/engine/src/cmd/browse.rs @@ -195,6 +195,24 @@ pub fn specs() -> Vec { Ok(json!({"path": to, "relinked": n})) } ), + cmd!( + "folder.create", + "New Folder on Disk", + [], + None, + "{parent, name} — create a folder on disk inside `parent` (refused if the name is taken); one undo step (undo removes it again while it is empty) → {path}", + always, + create_folder + ), + cmd!( + "photo.moveToFolder", + "Move to Folder", + [], + None, + "{folder, ids?} — move the photos' files (default: the selection) with their XMP sidecars into `folder` on disk and relink them; virtual copies follow their master; across volumes the file is copied, verified and only then removed; never overwrites (a name taken in `folder` refuses the whole move); missing/offline and Local photos are refused; one undo step (undo moves them back) → {moved, folder}", + always, + move_to_folder + ), cmd!( "library.browse", "Browse Folder", @@ -249,6 +267,127 @@ pub(crate) fn rename_folder_on_disk(from: &str, to: &str) -> std::result::Result std::fs::rename(src, dst).map_err(|e| format!("{from} → {to}: {e}")) } +/// Do an undo step's folder change on disk: rename `from` → `to`; with `to` empty, remove the +/// empty folder `from`; with `from` empty, create the folder `to`. Never overwrites. +pub(crate) fn folder_on_disk(from: &str, to: &str) -> std::result::Result<(), String> { + match (from.is_empty(), to.is_empty()) { + (false, false) => rename_folder_on_disk(from, to), + (false, true) => remove_empty_folder(from), + (true, false) => { + if Path::new(to).exists() { + return Err(format!("{to} already exists")); + } + std::fs::create_dir(to).map_err(|e| format!("{to}: {e}")) + } + (true, true) => Ok(()), + } +} + +/// Remove an empty folder; one with anything in it (hidden files too) is refused. +fn remove_empty_folder(path: &str) -> std::result::Result<(), String> { + let p = Path::new(path); + if !p.is_dir() { + return Err(format!("{path} is not a folder")); + } + let mut entries = std::fs::read_dir(p).map_err(|e| format!("{path}: {e}"))?; + if entries.next().is_some() { + return Err(format!("{path} is not empty")); + } + std::fs::remove_dir(p).map_err(|e| format!("{path}: {e}")) +} + +/// A folder path parameter without trailing separators (`None` when missing or blank). +fn folder_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> { + str_param(p, key).map(|x| x.trim_end_matches(['/', '\\'])).filter(|x| !x.is_empty()) +} + +/// `folder.create`: a new folder on disk inside `parent`; undo removes it while it is empty. +fn create_folder(s: &mut Session, p: &Value) -> Result { + const C: &str = "folder.create"; + let parent = folder_param(p, "parent").ok_or_else(|| bad(C, "missing parent"))?; + let name = str_param(p, "name") + .map(str::trim) + .filter(|n| !n.is_empty() && !n.contains(['/', '\\']) && *n != "." && *n != "..") + .ok_or_else(|| bad(C, "give a folder name (no slashes)"))?; + if !Path::new(parent).is_dir() { + return Err(bad(C, format!("{parent} is not a folder (offline, moved or deleted?)"))); + } + let path = Path::new(parent).join(name).to_string_lossy().to_string(); + if Path::new(&path).exists() { + return Err(bad(C, format!("{path} already exists: choose another name"))); + } + folder_on_disk("", &path).map_err(|e| bad(C, e))?; + let undo = crate::FolderMove { from: path.clone(), to: String::new() }; + if let Err(e) = s.commit_with_folder(&format!("Create Folder “{name}”"), Op::Batch { ops: vec![] }, undo) { + let _ = remove_empty_folder(&path); + return Err(e); + } + Ok(json!({"path": path})) +} + +/// `photo.moveToFolder`: move the photos' files (with their sidecars) into `folder` and relink +/// them, as one undo step. Virtual copies follow their master's file. Never overwrites: a name +/// already taken in `folder` refuses the whole move. +fn move_to_folder(s: &mut Session, p: &Value) -> Result { + const C: &str = "photo.moveToFolder"; + let folder = folder_param(p, "folder").ok_or_else(|| bad(C, "missing folder"))?; + let dir = Path::new(folder); + if !dir.is_dir() { + return Err(bad(C, format!("{folder} is not a folder (create it first with folder.create)"))); + } + let ids = s.targets(p); + if ids.is_empty() { + return Err(bad(C, "no photos to move: select some or give ids")); + } + let mut plans: Vec = Vec::new(); + let mut taken: std::collections::HashSet = std::collections::HashSet::new(); + let mut clashes: Vec = Vec::new(); + for id in ids { + let ph = s.catalog.photo(id).ok_or_else(|| bad(C, format!("no photo {}", id.0)))?; + if ph.local { + return Err(bad(C, format!("{} is not in the library (Local): add it first", ph.file_name))); + } + let lightcraft_catalog::Source::File { path } = &ph.source else { + return Err(bad(C, format!("{} has no file on disk", ph.file_name))); + }; + if plans.iter().any(|pl| pl.from_path.as_deref() == Some(path.as_str())) { + continue; // a virtual copy of a photo already moving + } + let src = Path::new(path); + if !src.is_file() { + return Err(bad(C, format!("{path} is missing or offline: find it first (Find Missing Photos)"))); + } + let Some(file) = src.file_name() else { return Err(bad(C, format!("{path}: no file name"))) }; + if src.parent() == Some(dir) { + continue; // already there + } + let to = dir.join(file); + let to_s = to.to_string_lossy().to_string(); + if to.exists() || !taken.insert(to_s.to_lowercase()) { + clashes.push(file.to_string_lossy().to_string()); + continue; + } + let name = ph.file_name.clone(); + plans.push(crate::rename::RenamePlan { id: id.0, from: name.clone(), to: name, from_path: Some(path.clone()), to_path: Some(to_s) }); + } + if !clashes.is_empty() { + let s_ = if clashes.len() == 1 { "" } else { "s" }; + return Err(bad( + C, + format!("{} already in {folder}: nothing was moved. Rename the photo{s_} first or choose another folder", clashes.join(", ")), + )); + } + let n = plans.len(); + if n == 0 { + return Ok(json!({"moved": 0, "folder": folder})); + } + let plural = |k: usize| if k == 1 { "" } else { "s" }; + let _task = s.activity.start("move", &format!("Moving {n} photo{}", plural(n)), crate::activity::Cancel::No); + let name = dir.file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| folder.to_string()); + s.apply_file_plans(&crate::rename::RealFs, &plans, &|k| format!("Move {k} Photo{} to “{name}”", plural(k)))?; + Ok(json!({"moved": n, "folder": folder})) +} + /// After a folder moved from `from` to `to`: a browsed folder at or below it follows. pub(crate) fn follow_folder(s: &mut Session, from: &str, to: &str) { // a library folder chosen at or below it follows too, as a source or as a filter diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 66acfe4a7..edf220f13 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -100,7 +100,8 @@ pub struct UndoEntry { pub label: String, pub op: Op, /// A folder to rename on disk (`from` → `to`) before `op` is applied: Rename / Move Folder - /// (whose `op` relinks the photos inside). Never overwrites; refused when `to` exists. + /// (whose `op` relinks the photos inside). Never overwrites; refused when `to` exists. An + /// empty `to` removes the (empty) folder `from`, an empty `from` creates `to` (Create Folder). pub folder: Option, } @@ -586,11 +587,11 @@ impl Session { fn apply_with_files(&mut self, op: &Op, folder: Option<&FolderMove>) -> Result { let fs = rename::RealFs; if let Some(f) = folder { - cmd::browse::rename_folder_on_disk(&f.from, &f.to).map_err(|e| EngineError::Other(format!("can't move the folder back: {e}")))?; + cmd::browse::folder_on_disk(&f.from, &f.to).map_err(|e| EngineError::Other(format!("can't move the folder back: {e}")))?; } let undo_folder = || { if let Some(f) = folder { - let _ = cmd::browse::rename_folder_on_disk(&f.to, &f.from); + let _ = cmd::browse::folder_on_disk(&f.to, &f.from); } }; let moves = self.file_moves(op); diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs index 259d51fcd..08d820914 100644 --- a/crates/engine/src/rename.rs +++ b/crates/engine/src/rename.rs @@ -370,7 +370,8 @@ impl MoveFs for RealFs { let _ = std::fs::remove_file(b); }), Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e), - Err(_) => std::fs::rename(a, b), + Err(_) => std::fs::rename(a, b) + .or_else(|e| if e.kind() == std::io::ErrorKind::CrossesDevices { move_across_volumes(self, a, b) } else { Err(e) }), } } @@ -392,6 +393,24 @@ impl MoveFs for RealFs { } } +/// Move `a` to `b` on another volume: copy (never replacing), compare the copy with the original +/// byte for byte, and only then remove the original. A copy that doesn't match is removed. +fn move_across_volumes(fs: &RealFs, a: &Path, b: &Path) -> std::io::Result<()> { + fs.copy_no_replace(a, b)?; + let same = match (std::fs::read(a), std::fs::read(b)) { + (Ok(x), Ok(y)) => x == y, + _ => false, + }; + if !same { + let _ = std::fs::remove_file(b); + return Err(std::io::Error::other(format!("the copy at {} did not match the original; the original was kept", b.display()))); + } + std::fs::remove_file(a).inspect_err(|_| { + // the original stays: drop the copy so the file isn't in two places + let _ = std::fs::remove_file(b); + }) +} + /// `from` → `to` differs only in letter case and `to` is that very file (a case-insensitive /// volume): the rename must go through a temporary name. fn is_respelling(fs: &dyn MoveFs, from: &Path, to: &Path) -> bool { @@ -652,6 +671,13 @@ impl Session { /// [`Session::apply_rename`] on the file system `fs`. pub(crate) fn apply_rename_with(&mut self, fs: &dyn MoveFs, plans: &[RenamePlan]) -> Result { + self.apply_file_plans(fs, plans, &|n| format!("Rename {n} Photo{}", if n == 1 { "" } else { "s" })) + } + + /// Carry out `plans` (renames or moves to another folder): move the files with their sidecars + /// (all or nothing), then commit one undoable op, labelled `label(count)`, that points the + /// photos (and their virtual copies) at the new paths. Undo moves the files back. + pub(crate) fn apply_file_plans(&mut self, fs: &dyn MoveFs, plans: &[RenamePlan], label: &dyn Fn(usize) -> String) -> Result { let mut ops = Vec::new(); let by_path: HashMap<&str, &RenamePlan> = plans.iter().filter_map(|p| p.from_path.as_deref().map(|f| (f, p))).collect(); for p in self.catalog.photos() { @@ -682,7 +708,7 @@ impl Session { return Err(EngineError::Other(self.follow_stuck(&op, e, true))); } if n > 0 - && let Err(err) = self.commit(&format!("Rename {n} Photo{}", if n == 1 { "" } else { "s" }), op) + && let Err(err) = self.commit(&label(n), op) { let back: Vec<(String, String)> = moves.iter().rev().map(|(a, b)| (b.clone(), a.clone())).collect(); if let Err(e) = move_all(fs, &back) { diff --git a/crates/engine/src/tests_folders.rs b/crates/engine/src/tests_folders.rs index 9f51ab408..b55e88c8a 100644 --- a/crates/engine/src/tests_folders.rs +++ b/crates/engine/src/tests_folders.rs @@ -564,3 +564,124 @@ fn a_label_comes_off_a_folder_whose_photos_are_gone() { assert_eq!(s.catalog.folder_record("/pics/trip"), None); assert!(s.execute("folder.label", &json!({"path": "/pics/trip", "label": "red"})).is_err(), "but a new one needs photos"); } + +// ---- folder.create and photo.moveToFolder (issue #765) + +/// A library photo of the real file `path` (written with `body`); returns its id. +fn add_file(s: &mut Session, path: &str, body: &str) -> lightcraft_catalog::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: lightcraft_catalog::PhotoId) -> String { + match &s.catalog.photo(id).unwrap().source { + Source::File { path } => path.clone(), + _ => panic!("not a file"), + } +} + +#[test] +fn a_folder_is_created_on_disk_and_undo_removes_it() { + let dir = Scratch::new("create"); + let mut s = Session::new(); + let r = s.execute("folder.create", &json!({"parent": dir.path(""), "name": "picks"})).unwrap(); + assert_eq!(r["path"], dir.path("picks")); + assert!(dir.0.join("picks").is_dir()); + assert!(s.execute("folder.create", &json!({"parent": dir.path(""), "name": "picks"})).unwrap_err().to_string().contains("already exists")); + assert!(s.execute("folder.create", &json!({"parent": dir.path(""), "name": "../x"})).is_err(), "one name, no slashes"); + assert!(s.execute("folder.create", &json!({"parent": dir.path("nope"), "name": "x"})).unwrap_err().to_string().contains("not a folder")); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!dir.0.join("picks").exists(), "undo removes it"); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(dir.0.join("picks").is_dir(), "redo makes it again"); + std::fs::write(dir.0.join("picks/note.txt"), "x").unwrap(); + assert!(s.execute("edit.undo", &json!({})).is_err(), "a folder with something in it is never removed"); + assert!(dir.0.join("picks/note.txt").is_file()); +} + +#[test] +fn photos_move_to_another_folder_with_their_sidecars_and_back_on_undo() { + let dir = Scratch::new("move-photos"); + std::fs::create_dir_all(dir.0.join("in")).unwrap(); + std::fs::create_dir_all(dir.0.join("out")).unwrap(); + let mut s = Session::new(); + let a = add_file(&mut s, &dir.path("in/a.jpg"), "A"); + std::fs::write(dir.path("in/a.xmp"), "").unwrap(); + let b = add_file(&mut s, &dir.path("in/b.jpg"), "B"); + // a virtual copy shares its master's file and goes along + let vc = s.catalog.alloc_photo_id(); + let p = Photo::new(vc, Source::File { path: dir.path("in/a.jpg") }, "a.jpg", "JPEG", 60, 40, "2026-01-01T10:00:00"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + let r = s.execute("photo.moveToFolder", &json!({"ids": [a.0, b.0], "folder": dir.path("out")})).unwrap(); + assert_eq!(r["moved"], 2); + assert_eq!(std::fs::read_to_string(dir.path("out/a.jpg")).unwrap(), "A"); + assert!(dir.0.join("out/a.xmp").is_file(), "the sidecar goes along"); + assert!(!dir.0.join("in/a.jpg").exists() && !dir.0.join("in/a.xmp").exists()); + assert_eq!(file_of(&s, a), dir.path("out/a.jpg")); + assert_eq!(file_of(&s, b), dir.path("out/b.jpg")); + assert_eq!(file_of(&s, vc), dir.path("out/a.jpg"), "the virtual copy follows"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(file_of(&s, a), dir.path("in/a.jpg")); + assert_eq!(file_of(&s, vc), dir.path("in/a.jpg")); + assert!(dir.0.join("in/a.jpg").is_file() && dir.0.join("in/a.xmp").is_file() && dir.0.join("in/b.jpg").is_file()); + assert!(!dir.0.join("out/a.jpg").exists()); + s.execute("edit.redo", &json!({})).unwrap(); + assert!(dir.0.join("out/b.jpg").is_file()); + assert_eq!(file_of(&s, b), dir.path("out/b.jpg")); +} + +#[test] +fn a_name_taken_in_the_destination_refuses_the_move_and_overwrites_nothing() { + let dir = Scratch::new("move-clash"); + std::fs::create_dir_all(dir.0.join("in")).unwrap(); + std::fs::create_dir_all(dir.0.join("out")).unwrap(); + let mut s = Session::new(); + let a = add_file(&mut s, &dir.path("in/a.jpg"), "A"); + let b = add_file(&mut s, &dir.path("in/b.jpg"), "B"); + std::fs::write(dir.path("out/b.jpg"), "OTHER").unwrap(); + let e = s.execute("photo.moveToFolder", &json!({"ids": [a.0, b.0], "folder": dir.path("out")})).unwrap_err().to_string(); + assert!(e.contains("b.jpg already in") && e.contains("nothing was moved"), "{e}"); + assert_eq!(std::fs::read_to_string(dir.path("out/b.jpg")).unwrap(), "OTHER"); + assert!(dir.0.join("in/a.jpg").is_file() && !dir.0.join("out/a.jpg").exists(), "all or nothing"); + assert_eq!(file_of(&s, a), dir.path("in/a.jpg")); +} + +#[test] +fn moving_to_a_missing_folder_or_a_missing_original_is_refused() { + let dir = Scratch::new("move-missing"); + std::fs::create_dir_all(dir.0.join("in")).unwrap(); + let mut s = Session::new(); + let a = add_file(&mut s, &dir.path("in/a.jpg"), "A"); + let e = s.execute("photo.moveToFolder", &json!({"ids": [a.0], "folder": dir.path("nope")})).unwrap_err().to_string(); + assert!(e.contains("not a folder"), "{e}"); + std::fs::create_dir_all(dir.0.join("out")).unwrap(); + std::fs::remove_file(dir.path("in/a.jpg")).unwrap(); + let e = s.execute("photo.moveToFolder", &json!({"ids": [a.0], "folder": dir.path("out")})).unwrap_err().to_string(); + assert!(e.contains("missing or offline"), "{e}"); + assert_eq!(file_of(&s, a), dir.path("in/a.jpg")); +} + +#[cfg(unix)] +#[test] +fn a_destination_without_write_permission_reports_an_error_and_keeps_everything() { + use std::os::unix::fs::PermissionsExt; + let dir = Scratch::new("move-perm"); + std::fs::create_dir_all(dir.0.join("in")).unwrap(); + std::fs::create_dir_all(dir.0.join("ro")).unwrap(); + let mut s = Session::new(); + let a = add_file(&mut s, &dir.path("in/a.jpg"), "A"); + std::fs::set_permissions(dir.0.join("ro"), std::fs::Permissions::from_mode(0o555)).unwrap(); + // running as root ignores permissions: nothing to check there + let root = std::fs::write(dir.0.join("ro/probe"), "x").is_ok(); + if !root { + assert!(s.execute("photo.moveToFolder", &json!({"ids": [a.0], "folder": dir.path("ro")})).is_err()); + assert!(dir.0.join("in/a.jpg").is_file()); + assert_eq!(file_of(&s, a), dir.path("in/a.jpg")); + assert!(s.execute("folder.create", &json!({"parent": dir.path("ro"), "name": "x"})).is_err()); + } + std::fs::set_permissions(dir.0.join("ro"), std::fs::Permissions::from_mode(0o755)).unwrap(); +} diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 671da17d4..b54d90a95 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -228,5 +228,7 @@ "+{n} more": "+{n} weitere", "1 task is still running: {names}. Quitting stops it.": "1 Aufgabe läuft noch: {names}. Beenden bricht sie ab.", "{n} tasks are still running: {names}. Quitting stops them.": "{n} Aufgaben laufen noch: {names}. Beenden bricht sie ab.", - "{what} merge cancelled": "{what}-Zusammenfügung abgebrochen" + "{what} merge cancelled": "{what}-Zusammenfügung abgebrochen", + "New Folder in “{name}”": "Neuer Ordner in „{name}“", + "Moved {} photo(s)": "{} Foto(s) verschoben" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 6520fb3e3..3aacc41e0 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2039,5 +2039,11 @@ "Downloading model": "Modell wird heruntergeladen", "Finding faces": "Gesichter werden gesucht", "Synchronizing folder": "Ordner wird synchronisiert", - "Looking for changes": "Änderungen werden gesucht" + "Looking for changes": "Änderungen werden gesucht", + "New Folder on Disk": "Neuer Ordner auf dem Datenträger", + "New Folder on Disk…": "Neuer Ordner auf dem Datenträger…", + "Move to Folder": "In Ordner verschieben", + "Move to Folder…": "In Ordner verschieben…", + "Move Selected Photos Here": "Ausgewählte Fotos hierher verschieben", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Verschiebt die Dateien samt XMP-Filialdateien in diesen Ordner; nichts wird überschrieben" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 9ba1c8f10..16c78d1cf 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -228,5 +228,7 @@ "+{n} more": "+{n} más", "1 task is still running: {names}. Quitting stops it.": "1 tarea sigue en curso: {names}. Salir la detiene.", "{n} tasks are still running: {names}. Quitting stops them.": "{n} tareas siguen en curso: {names}. Salir las detiene.", - "{what} merge cancelled": "Combinación {what} cancelada" + "{what} merge cancelled": "Combinación {what} cancelada", + "New Folder in “{name}”": "Nueva carpeta en «{name}»", + "Moved {} photo(s)": "{} foto(s) movida(s)" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index fc53b6392..a1bf89abb 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1884,5 +1884,11 @@ "Downloading model": "Descargando modelo", "Finding faces": "Buscando caras", "Synchronizing folder": "Sincronizando carpeta", - "Looking for changes": "Buscando cambios" + "Looking for changes": "Buscando cambios", + "New Folder on Disk": "Nueva carpeta en el disco", + "New Folder on Disk…": "Nueva carpeta en el disco…", + "Move to Folder": "Mover a carpeta", + "Move to Folder…": "Mover a carpeta…", + "Move Selected Photos Here": "Mover aquí las fotos seleccionadas", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Mueve los archivos y sus archivos XMP auxiliares a esta carpeta; nunca sobrescribe" } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index 7ccaf5a38..92ab820c6 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -228,5 +228,7 @@ "+{n} more": "+{n} autres", "1 task is still running: {names}. Quitting stops it.": "1 tâche est encore en cours : {names}. Quitter l'arrête.", "{n} tasks are still running: {names}. Quitting stops them.": "{n} tâches sont encore en cours : {names}. Quitter les arrête.", - "{what} merge cancelled": "Fusion {what} annulée" + "{what} merge cancelled": "Fusion {what} annulée", + "New Folder in “{name}”": "Nouveau dossier dans « {name} »", + "Moved {} photo(s)": "{} photo(s) déplacée(s)" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 53509a503..483049a81 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1884,5 +1884,11 @@ "Panels": "Panels", "Reset Panel Layout": "Reset Panel Layout", "Resize panel window": "Resize panel window", - "Resize panels": "Resize panels" + "Resize panels": "Resize panels", + "New Folder on Disk": "Nouveau dossier sur le disque", + "New Folder on Disk…": "Nouveau dossier sur le disque…", + "Move to Folder": "Déplacer vers un dossier", + "Move to Folder…": "Déplacer vers un dossier…", + "Move Selected Photos Here": "Déplacer ici les photos sélectionnées", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Déplace les fichiers et leurs fichiers annexes XMP dans ce dossier ; n'écrase jamais rien" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index f1c06351c..0e7265fb8 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -228,5 +228,7 @@ "+{n} more": "ほか{n}件", "1 task is still running: {names}. Quitting stops it.": "1件のタスクが実行中です:{names}。終了すると停止します。", "{n} tasks are still running: {names}. Quitting stops them.": "{n}件のタスクが実行中です:{names}。終了すると停止します。", - "{what} merge cancelled": "{what}合成をキャンセルしました" + "{what} merge cancelled": "{what}合成をキャンセルしました", + "New Folder in “{name}”": "「{name}」に新規フォルダ", + "Moved {} photo(s)": "{} 枚の写真を移動しました" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 2babd30b6..d08fce0c9 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1886,5 +1886,11 @@ "Downloading model": "モデルをダウンロード中", "Finding faces": "顔を検索中", "Synchronizing folder": "フォルダを同期中", - "Looking for changes": "変更を確認中" + "Looking for changes": "変更を確認中", + "New Folder on Disk": "ディスクに新規フォルダ", + "New Folder on Disk…": "ディスクに新規フォルダ…", + "Move to Folder": "フォルダへ移動", + "Move to Folder…": "フォルダへ移動…", + "Move Selected Photos Here": "選択した写真をここへ移動", + "Moves the files and their XMP sidecars into this folder; never overwrites": "ファイルと XMP サイドカーをこのフォルダへ移動します。上書きはしません" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 4a9d1168a..f8dfa4bcc 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -228,5 +228,7 @@ "+{n} more": "+{n} mais", "1 task is still running: {names}. Quitting stops it.": "1 tarefa ainda está em andamento: {names}. Sair a interrompe.", "{n} tasks are still running: {names}. Quitting stops them.": "{n} tarefas ainda estão em andamento: {names}. Sair as interrompe.", - "{what} merge cancelled": "{what} mesclagem cancelada" + "{what} merge cancelled": "{what} mesclagem cancelada", + "New Folder in “{name}”": "Nova pasta em “{name}”", + "Moved {} photo(s)": "{} foto(s) movida(s)" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index dfe472818..38357bb1f 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1884,5 +1884,11 @@ "Downloading model": "Baixando modelo", "Finding faces": "Procurando rostos", "Synchronizing folder": "Sincronizando pasta", - "Looking for changes": "Procurando alterações" + "Looking for changes": "Procurando alterações", + "New Folder on Disk": "Nova pasta no disco", + "New Folder on Disk…": "Nova pasta no disco…", + "Move to Folder": "Mover para pasta", + "Move to Folder…": "Mover para pasta…", + "Move Selected Photos Here": "Mover as fotos selecionadas para cá", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Move os arquivos e seus arquivos XMP auxiliares para esta pasta; nunca sobrescreve" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index ba3560ad0..6111f8693 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -228,5 +228,7 @@ "+{n} more": "ещё {n}", "1 task is still running: {names}. Quitting stops it.": "Ещё выполняется задача: {names}. Выход прервёт её.", "{n} tasks are still running: {names}. Quitting stops them.": "Ещё выполняется задач: {n} — {names}. Выход прервёт их.", - "{what} merge cancelled": "{what}: слияние отменено" + "{what} merge cancelled": "{what}: слияние отменено", + "New Folder in “{name}”": "Новая папка в «{name}»", + "Moved {} photo(s)": "Перемещено фото: {}" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index fafb65a14..cc717c77a 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1598,5 +1598,11 @@ "Finding faces": "Поиск лиц", "Quit Anyway": "Всё равно выйти", "Synchronizing folder": "Синхронизация папки", - "Looking for changes": "Поиск изменений" + "Looking for changes": "Поиск изменений", + "New Folder on Disk": "Новая папка на диске", + "New Folder on Disk…": "Новая папка на диске…", + "Move to Folder": "Переместить в папку", + "Move to Folder…": "Переместить в папку…", + "Move Selected Photos Here": "Переместить выбранные фото сюда", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Перемещает файлы и их XMP-файлы в эту папку; ничего не перезаписывает" } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index c33dd0cb0..c61326f26 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -228,5 +228,7 @@ "+{n} more": "+{n} ще", "1 task is still running: {names}. Quitting stops it.": "1 завдання ще виконується: {names}. Вихід зупинить його.", "{n} tasks are still running: {names}. Quitting stops them.": "Ще виконуються завдання ({n}): {names}. Вихід зупинить їх.", - "{what} merge cancelled": "Об’єднання {what} скасовано" + "{what} merge cancelled": "Об’єднання {what} скасовано", + "New Folder in “{name}”": "Нова тека в «{name}»", + "Moved {} photo(s)": "Переміщено фото: {}" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 7ebad5b10..2be56fefc 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2078,5 +2078,11 @@ "Panels": "Panels", "Reset Panel Layout": "Reset Panel Layout", "Resize panel window": "Resize panel window", - "Resize panels": "Resize panels" + "Resize panels": "Resize panels", + "New Folder on Disk": "Нова тека на диску", + "New Folder on Disk…": "Нова тека на диску…", + "Move to Folder": "Перемістити до теки", + "Move to Folder…": "Перемістити до теки…", + "Move Selected Photos Here": "Перемістити вибрані фото сюди", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Переміщує файли та їхні XMP-файли до цієї теки; нічого не перезаписує" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index eb429af42..54715712d 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -228,5 +228,7 @@ "+{n} more": "还有 {n} 项", "1 task is still running: {names}. Quitting stops it.": "仍有 1 个任务在运行:{names}。退出将停止它。", "{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 个任务在运行:{names}。退出将停止它们。", - "{what} merge cancelled": "已取消 {what} 合并" + "{what} merge cancelled": "已取消 {what} 合并", + "New Folder in “{name}”": "在“{name}”中新建文件夹", + "Moved {} photo(s)": "已移动 {} 张照片" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 8a0e58b8e..d6977a1c6 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1884,5 +1884,11 @@ "Downloading model": "正在下载模型", "Finding faces": "正在查找人脸", "Synchronizing folder": "正在同步文件夹", - "Looking for changes": "正在查找更改" + "Looking for changes": "正在查找更改", + "New Folder on Disk": "在磁盘上新建文件夹", + "New Folder on Disk…": "在磁盘上新建文件夹…", + "Move to Folder": "移动到文件夹", + "Move to Folder…": "移动到文件夹…", + "Move Selected Photos Here": "将所选照片移到此处", + "Moves the files and their XMP sidecars into this folder; never overwrites": "将文件及其 XMP 附属文件移到此文件夹;绝不覆盖" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 0b2ec7657..61499321f 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -228,5 +228,7 @@ "+{n} more": "還有 {n} 項", "1 task is still running: {names}. Quitting stops it.": "仍有 1 個工作在執行:{names}。結束將停止它。", "{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 個工作在執行:{names}。結束將停止它們。", - "{what} merge cancelled": "已取消{what}合併" + "{what} merge cancelled": "已取消{what}合併", + "New Folder in “{name}”": "在「{name}」中新增資料夾", + "Moved {} photo(s)": "已移動 {} 張照片" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index f1cbec3a0..aedd2c1bb 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1884,5 +1884,11 @@ "Downloading model": "正在下載模型", "Finding faces": "正在尋找臉孔", "Synchronizing folder": "正在同步資料夾", - "Looking for changes": "正在尋找變更" + "Looking for changes": "正在尋找變更", + "New Folder on Disk": "在磁碟上新增資料夾", + "New Folder on Disk…": "在磁碟上新增資料夾…", + "Move to Folder": "移至資料夾", + "Move to Folder…": "移至資料夾…", + "Move Selected Photos Here": "將所選照片移至此處", + "Moves the files and their XMP sidecars into this folder; never overwrites": "將檔案及其 XMP 附屬檔案移至此資料夾;絕不覆寫" } diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 4264e4290..5d2047527 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -935,6 +935,15 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button(crate::i18n::tr("Rename…")).clicked() { let _ = app.run("dialog.rename", json!({})); } + if app.services.pick_folder.is_some() && ui.button(crate::i18n::tr("Move to Folder…")).clicked() { + let into = app.services.pick_folder.as_mut().and_then(|f| f()); + if let Some(into) = into { + match app.run("photo.moveToFolder", json!({"folder": into})) { + Ok(r) => app.toast(ui.ctx(), crate::i18n::tr_format!("Moved {} photo(s)", r["moved"])), + Err(e) => app.toast(ui.ctx(), e), + } + } + } if ui.button(crate::i18n::tr("Reload from Disk")).clicked() { let _ = app.run("photo.reload", json!({})); } diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 4f1bae44c..d2b2c6cfb 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -1320,6 +1320,30 @@ fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &F } ui.close(); } + if ui.button(crate::i18n::tr("New Folder on Disk…")).clicked() { + app.ui.dialog = Some(crate::state::Dialog::TextPrompt { + title: crate::i18n::tr_format!("New Folder in “{name}”", name = label), + hint: "Folder name (created on disk)".into(), + value: String::new(), + command: "folder.create".into(), + params: json!({"parent": path}), + key: "name".into(), + }); + ui.close(); + } + let selected = app.session.selection.ids.len(); + if selected > 0 + && ui + .button(crate::i18n::tr("Move Selected Photos Here")) + .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), + } + ui.close(); + } if app.services.reveal.is_some() && ui.button(crate::i18n::tr(crate::menus::reveal_label())).clicked() && let Some(f) = app.services.reveal.as_mut() diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 099411089..0f94e9e57 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -682,7 +682,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages, in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs | | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`) | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | -| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; 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); creating a folder and moving photos between folders are not here; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files isn't cancelled midway and its depth/entry limits are only logged | +| LRC-LIB-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-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 | ⬜ | | |