# Conflicts:
#	crates/ui-egui/locales/de-formats.json
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es-formats.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/fr-formats.json
#	crates/ui-egui/locales/fr.json
#	crates/ui-egui/locales/ja-formats.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br-formats.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru-formats.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/uk-formats.json
#	crates/ui-egui/locales/uk.json
#	crates/ui-egui/locales/zh-hans-formats.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant-formats.json
#	crates/ui-egui/locales/zh-hant.json
This commit is contained in:
Claude committed 2026-10-11 12:02:51 +00:00
commit 45a6a42ebf
25 files changed
+416 -24

No files matched your search

+139
View File
@@ -197,6 +197,24 @@ pub fn specs() -> Vec<CommandSpec> {
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",
@@ -251,6 +269,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<Value> {
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<Value> {
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<crate::rename::RenamePlan> = Vec::new();
let mut taken: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut clashes: Vec<String> = 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
+4 -3
View File
@@ -108,7 +108,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<FolderMove>,
}
@@ -597,11 +598,11 @@ impl Session {
fn apply_with_files(&mut self, op: &Op, folder: Option<&FolderMove>) -> Result<Op> {
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);
+28 -2
View File
@@ -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<usize> {
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<usize> {
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) {
+121
View File
@@ -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"), "<x/>").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();
}
+3 -1
View File
@@ -231,5 +231,7 @@
"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",
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Übersprungen: {n} lokale Fotos{0:.0} – Alben enthalten Bibliotheksfotos (zuerst zu „Meine Fotos“ hinzufügen)"
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Übersprungen: {n} lokale Fotos{0:.0} – Alben enthalten Bibliotheksfotos (zuerst zu „Meine Fotos“ hinzufügen)",
"New Folder in “{name}”": "Neuer Ordner in „{name}“",
"Moved {} photo(s)": "{} Foto(s) verschoben"
}
+7 -1
View File
@@ -2074,5 +2074,11 @@
"No Recent Libraries": "Keine zuletzt verwendeten Bibliotheken",
"New Library: Choose Where to Put It": "Neue Bibliothek: Speicherort wählen",
"New Library": "Neue Bibliothek",
"Library name": "Name der Bibliothek"
"Library name": "Name der Bibliothek",
"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"
}
+3 -1
View File
@@ -231,5 +231,7 @@
"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",
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Fotos locales omitidas: {n}{0:.0} — los álbumes contienen fotos de la biblioteca (añádelas primero a Mis fotos)"
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Fotos locales omitidas: {n}{0:.0} — los álbumes contienen fotos de la biblioteca (añádelas primero a Mis fotos)",
"New Folder in “{name}”": "Nueva carpeta en «{name}»",
"Moved {} photo(s)": "{} foto(s) movida(s)"
}
+7 -1
View File
@@ -1916,5 +1916,11 @@
"No Recent Libraries": "No hay bibliotecas recientes",
"New Library: Choose Where to Put It": "Nueva biblioteca: elige dónde guardarla",
"New Library": "Nueva biblioteca",
"Library name": "Nombre de la biblioteca"
"Library name": "Nombre de la biblioteca",
"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"
}
+3 -1
View File
@@ -231,5 +231,7 @@
"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",
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Photos locales ignorées : {n}{0:.0} — les albums contiennent des photos de la bibliothèque (ajoutez-les d’abord à Mes photos)"
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Photos locales ignorées : {n}{0:.0} — les albums contiennent des photos de la bibliothèque (ajoutez-les d’abord à Mes photos)",
"New Folder in “{name}”": "Nouveau dossier dans « {name} »",
"Moved {} photo(s)": "{} photo(s) déplacée(s)"
}
+7 -1
View File
@@ -1916,5 +1916,11 @@
"No Recent Libraries": "Aucune bibliothèque récente",
"New Library: Choose Where to Put It": "Nouvelle bibliothèque : choisir son emplacement",
"New Library": "Nouvelle bibliothèque",
"Library name": "Nom de la bibliothèque"
"Library name": "Nom de la bibliothèque",
"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"
}
+3 -1
View File
@@ -231,5 +231,7 @@
"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}合成をキャンセルしました",
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "ローカル写真{n}枚をスキップしました{0:.0}:アルバムに入れられるのはライブラリの写真です(先にマイフォトに追加してください)"
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "ローカル写真{n}枚をスキップしました{0:.0}:アルバムに入れられるのはライブラリの写真です(先にマイフォトに追加してください)",
"New Folder in “{name}”": "「{name}」に新規フォルダ",
"Moved {} photo(s)": "{} 枚の写真を移動しました"
}
+7 -1
View File
@@ -1918,5 +1918,11 @@
"No Recent Libraries": "最近使ったライブラリはありません",
"New Library: Choose Where to Put It": "新しいライブラリ:保存場所を選択",
"New Library": "新しいライブラリ",
"Library name": "ライブラリ名"
"Library name": "ライブラリ名",
"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 サイドカーをこのフォルダへ移動します。上書きはしません"
}
+3 -1
View File
@@ -231,5 +231,7 @@
"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",
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Fotos locais ignoradas: {n}{0:.0} — álbuns contêm fotos da biblioteca (adicione-as às Minhas fotos primeiro)"
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Fotos locais ignoradas: {n}{0:.0} — álbuns contêm fotos da biblioteca (adicione-as às Minhas fotos primeiro)",
"New Folder in “{name}”": "Nova pasta em “{name}”",
"Moved {} photo(s)": "{} foto(s) movida(s)"
}
+7 -1
View File
@@ -1916,5 +1916,11 @@
"No Recent Libraries": "Nenhuma biblioteca recente",
"New Library: Choose Where to Put It": "Nova biblioteca: escolha onde salvá-la",
"New Library": "Nova biblioteca",
"Library name": "Nome da biblioteca"
"Library name": "Nome da biblioteca",
"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"
}
+3 -1
View File
@@ -231,5 +231,7 @@
"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}: слияние отменено",
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Пропущено локальных фото: {n}{0:.0} — в альбомах только фото из библиотеки (сначала добавьте их в «Мои фото»)"
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Пропущено локальных фото: {n}{0:.0} — в альбомах только фото из библиотеки (сначала добавьте их в «Мои фото»)",
"New Folder in “{name}”": "Новая папка в «{name}»",
"Moved {} photo(s)": "Перемещено фото: {}"
}
+7 -1
View File
@@ -1630,5 +1630,11 @@
"No Recent Libraries": "Нет недавних библиотек",
"New Library: Choose Where to Put It": "Новая библиотека: выберите расположение",
"New Library": "Новая библиотека",
"Library name": "Название библиотеки"
"Library name": "Название библиотеки",
"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-файлы в эту папку; ничего не перезаписывает"
}
+3 -1
View File
@@ -231,5 +231,7 @@
"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} скасовано",
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Пропущено локальних фото: {n}{0:.0} — в альбомах лише фото з бібліотеки (спершу додайте їх до моїх фото)"
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Пропущено локальних фото: {n}{0:.0} — в альбомах лише фото з бібліотеки (спершу додайте їх до моїх фото)",
"New Folder in “{name}”": "Нова тека в «{name}»",
"Moved {} photo(s)": "Переміщено фото: {}"
}
+7 -1
View File
@@ -2110,5 +2110,11 @@
"No Recent Libraries": "Немає нещодавніх бібліотек",
"New Library: Choose Where to Put It": "Нова бібліотека: виберіть розташування",
"New Library": "Нова бібліотека",
"Library name": "Назва бібліотеки"
"Library name": "Назва бібліотеки",
"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-файли до цієї теки; нічого не перезаписує"
}
+3 -1
View File
@@ -231,5 +231,7 @@
"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} 合并",
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已跳过 {n} 张本地照片{0:.0}:相册只能收录图库中的照片(请先添加到我的照片)"
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已跳过 {n} 张本地照片{0:.0}:相册只能收录图库中的照片(请先添加到我的照片)",
"New Folder in “{name}”": "在“{name}”中新建文件夹",
"Moved {} photo(s)": "已移动 {} 张照片"
}
+7 -1
View File
@@ -1916,5 +1916,11 @@
"No Recent Libraries": "没有最近的图库",
"New Library: Choose Where to Put It": "新建图库:选择存放位置",
"New Library": "新建图库",
"Library name": "图库名称"
"Library name": "图库名称",
"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 附属文件移到此文件夹;绝不覆盖"
}
+3 -1
View File
@@ -231,5 +231,7 @@
"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}合併",
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已略過 {n} 張本機照片{0:.0}:相簿只能收錄圖庫中的照片(請先加入我的照片)"
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已略過 {n} 張本機照片{0:.0}:相簿只能收錄圖庫中的照片(請先加入我的照片)",
"New Folder in “{name}”": "在「{name}」中新增資料夾",
"Moved {} photo(s)": "已移動 {} 張照片"
}
+7 -1
View File
@@ -1916,5 +1916,11 @@
"No Recent Libraries": "沒有最近的圖庫",
"New Library: Choose Where to Put It": "新增圖庫:選擇存放位置",
"New Library": "新增圖庫",
"Library name": "圖庫名稱"
"Library name": "圖庫名稱",
"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 附屬檔案移至此資料夾;絕不覆寫"
}
+9
View File
@@ -984,6 +984,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!({}));
}
+24
View File
@@ -1369,6 +1369,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()
+1 -1
View File
@@ -687,7 +687,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 |
| 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/<host>`), NFS (`/Network/Servers/<host>`), 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 DiLine truncated
| 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/<host>`), NFS (`/Network/Servers/<host>`), 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, tLine truncated
| 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 | ⬜ | | |