diff --git a/crates/engine/src/cmd/face_recognize.rs b/crates/engine/src/cmd/face_recognize.rs index e099f13ec..26b8c75ce 100644 --- a/crates/engine/src/cmd/face_recognize.rs +++ b/crates/engine/src/cmd/face_recognize.rs @@ -1,10 +1,11 @@ //! Recognition commands: `faces.index` (embed the faces in the library), `faces.suggest` (who might an unnamed //! face be?), `faces.person` (one person's faces, and the unnamed faces that look like them), `faces.unnamed` (every //! unnamed face, look-alikes together) and `faces.setName` / `faces.nameFaces` (name one face, or many at once, which is -//! what teaches the suggestions). +//! what teaches the suggestions). `faces.renamePerson` renames every in-library face of one person, or merges two +//! people when asked. //! //! Everything here is a suggestion for the user to confirm; nothing is ever named automatically. All of it is -//! catalog-only (no sidecar is written) and `faces.setName` is undoable. +//! catalog-only (no sidecar is written) and naming is undoable. use lightcraft_catalog::Op; use serde_json::{Value, json}; @@ -92,6 +93,92 @@ fn set_name(s: &mut Session, p: &Value) -> Result { Ok(json!({"id": id.0, "index": index, "name": name})) } +/// `faces.renamePerson {from, to, merge?}`: give every in-library face of `from` the exact name `to`, as one undo +/// step. Names match the People view (trimmed, case-insensitive). A `to` that is already someone else is refused +/// unless `merge` is set, and then the two people become one. The sidecar is not touched, and a refusal changes nothing. +fn rename_person(s: &mut Session, p: &Value) -> Result { + const C: &str = "faces.renamePerson"; + let from = match p.get("from") { + Some(Value::String(v)) => { + let t = v.trim(); + if t.is_empty() { + return Err(bad(C, "missing `from`")); + } + t.to_string() + } + Some(_) => return Err(bad(C, "`from` must be text")), + None => return Err(bad(C, "missing `from`")), + }; + let to = match p.get("to") { + Some(Value::String(v)) => { + let t = v.trim(); + if t.is_empty() { + return Err(bad(C, "`to` must be a name")); + } + if t.chars().count() > 200 || t.chars().any(char::is_control) { + return Err(bad(C, "a name is at most 200 characters, without control characters")); + } + t.to_string() + } + Some(_) => return Err(bad(C, "`to` must be text")), + None => return Err(bad(C, "missing `to`")), + }; + let merge = super::bool_or(p, "merge", false); + let from_key = from.to_lowercase(); + let to_key = to.to_lowercase(); + let mut matched = 0usize; + let mut other = false; + let mut faces = 0usize; + let mut ops = Vec::new(); + let ids: Vec<_> = s.catalog.photos().filter(|ph| ph.in_library()).map(|ph| ph.id).collect(); + for id in ids { + let Some(ph) = s.catalog.photo(id) else { continue }; + let mut indexes = Vec::new(); + for (i, r) in ph.meta.regions.iter().enumerate() { + if r.kind != lightcraft_meta::RegionKind::Face { + continue; + } + let Some(n) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue }; + let key = n.to_lowercase(); + if key == from_key { + matched += 1; + if r.name.as_deref() != Some(to.as_str()) { + indexes.push(i); + } + } else if key == to_key { + other = true; + } + } + if indexes.is_empty() { + continue; + } + let mut meta = ph.meta.clone(); + for i in indexes { + if let Some(region) = meta.regions.get_mut(i) { + region.name = Some(to.clone()); + faces += 1; + } + } + ops.push(Op::SetMeta { id, meta: Box::new(meta) }); + } + if matched == 0 { + return Err(bad(C, format!("no person named {from}"))); + } + if faces == 0 { + return Err(bad(C, format!("{to} is already their name"))); + } + if other && !merge { + return Err(bad(C, format!("{to} is already another person; pass merge: true to combine them"))); + } + let photos = ops.len(); + s.commit(if other { "Merge People" } else { "Rename Person" }, Op::Batch { ops })?; + s.skip_auto_write = true; + if s.filter.person.as_deref().is_some_and(|n| n.trim().to_lowercase() == from_key) { + s.filter.person = Some(to.clone()); + } + Ok(json!({"faces": faces, "photos": photos, "from": from, "to": to, "merged": other})) +} + /// Most faces `faces.unnamed` lists. const MAX_UNNAMED: usize = 3000; @@ -1047,5 +1134,14 @@ pub fn specs() -> Vec { super::always, set_name ), + cmd!( + "faces.renamePerson", + "Rename Person", + [], + None, + "{from, to, merge?: bool} → {faces, photos, from, to, merged} — give every in-library face of `from` the name `to` (trimmed, any capitalization), one undo step; refused when `to` is already someone else unless `merge` is true, which combines the two; the sidecar is not touched", + super::always, + rename_person + ), ] } diff --git a/crates/engine/src/tests_face_recognize.rs b/crates/engine/src/tests_face_recognize.rs index aabcb03c3..9f80ff8b8 100644 --- a/crates/engine/src/tests_face_recognize.rs +++ b/crates/engine/src/tests_face_recognize.rs @@ -523,3 +523,98 @@ fn face_scan_row_follows_pending_photos() { assert!(faces_rows(&s).is_empty(), "the row goes when the scan is done"); let _ = std::fs::remove_dir_all(&d); } + +fn face_names(s: &Session, id: PhotoId) -> Vec> { + s.catalog.photo(id).unwrap().meta.regions.iter().map(|r| r.name.clone()).collect() +} + +/// Every in-library spelling of a person becomes the exact new name, including a change of case only, +/// and a person filter follows it. One undo step puts the old names back. +#[test] +fn rename_person_rewrites_every_spelling_and_undoes() { + let mut s = Session::with_demo(); + let (a, b) = two_photos(&s); + set_regions(&mut s, a, vec![region(0.1, Some("Ann")), region(0.5, Some(" ANN ")), region(0.7, Some("Rex"))]); + let mut pet = region(0.2, Some("Ann")); + pet.kind = RegionKind::Pet; + set_regions(&mut s, b, vec![region(0.3, Some("ann")), pet]); + s.execute("library.filter", &json!({"person": "ANN"})).unwrap(); + let undo = s.undo.len(); + let r = s.execute("faces.renamePerson", &json!({"from": " ann ", "to": " Ada "})).unwrap(); + assert_eq!((r["faces"].clone(), r["photos"].clone(), r["to"].clone(), r["merged"].clone()), (json!(3), json!(2), json!("Ada"), json!(false))); + assert_eq!(face_names(&s, a), vec![Some("Ada".into()), Some("Ada".into()), Some("Rex".into())]); + assert_eq!(face_names(&s, b)[0], Some("Ada".into())); + assert_eq!(face_names(&s, b)[1], Some("Ann".into()), "a pet is not a person"); + assert_eq!(s.catalog.people().iter().find(|p| p.name.eq_ignore_ascii_case("ada")).map(|p| p.name.as_str()), Some("Ada")); + assert_eq!(s.filter.person.as_deref(), Some("Ada")); + assert_eq!(s.undo.len(), undo + 1); + assert_eq!(s.undo.last().map(|u| u.label.as_str()), Some("Rename Person")); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(face_names(&s, a), vec![Some("Ann".into()), Some(" ANN ".into()), Some("Rex".into())]); + assert_eq!(face_names(&s, b)[0], Some("ann".into())); +} + +/// Renaming onto someone else is refused until merge is asked for; then one undo step restores both people. +#[test] +fn rename_person_refuses_a_merge_until_it_is_asked_for() { + let mut s = Session::with_demo(); + let (a, b) = two_photos(&s); + set_regions(&mut s, a, vec![region(0.1, Some("Ann"))]); + set_regions(&mut s, b, vec![region(0.3, Some("Bea"))]); + let before = (face_names(&s, a), face_names(&s, b), s.undo.len()); + let err = s.execute("faces.renamePerson", &json!({"from": "Ann", "to": "Bea"})).unwrap_err(); + assert!(err.to_string().contains("already another person"), "{err}"); + assert_eq!((face_names(&s, a), face_names(&s, b), s.undo.len()), before, "a refusal changes nothing"); + let r = s.execute("faces.renamePerson", &json!({"from": "Ann", "to": "Bea", "merge": true})).unwrap(); + assert_eq!(r["merged"], true); + assert_eq!(s.undo.last().map(|u| u.label.as_str()), Some("Merge People")); + let names: Vec = s.catalog.people().into_iter().map(|p| p.name).collect(); + assert_eq!(names, vec!["Bea".to_string()]); + s.execute("edit.undo", &json!({})).unwrap(); + let mut names: Vec = s.catalog.people().into_iter().map(|p| p.name).collect(); + names.sort(); + assert_eq!(names, vec!["Ann".to_string(), "Bea".to_string()]); +} + +/// More faces than `faces.person` lists are still renamed. A bad call, a missing person, or a name that +/// is already exact changes nothing, and deleted or only-browsed photos are not part of the person. +#[test] +fn rename_person_covers_more_than_the_person_query_and_rejects_bad_input() { + let mut s = Session::with_demo(); + let (a, b) = two_photos(&s); + set_regions(&mut s, a, vec![region(0.1, Some("Ann"))]); + let undo = s.undo.len(); + for p in [ + json!({}), + json!({"from": "Ann"}), + json!({"from": " ", "to": "Ada"}), + json!({"from": 1, "to": "Ada"}), + json!({"from": "Ann", "to": ""}), + json!({"from": "Ann", "to": 5}), + json!({"from": "Ann", "to": "a\u{7}b"}), + json!({"from": "Ann", "to": "x".repeat(201)}), + json!({"from": "Nobody", "to": "Ada"}), + json!({"from": "Ann", "to": "Ann"}), + ] { + let err = s.execute("faces.renamePerson", &p).unwrap_err(); + assert!(s.undo.len() == undo, "{p} changed the catalog: {err}"); + } + assert_eq!(face_names(&s, a), vec![Some("Ann".into())]); + let regions: Vec<_> = (0..5001).map(|i| region(0.1, Some(if i == 0 { "ANN" } else { "Ann" }))).collect(); + set_regions(&mut s, a, regions); + set_regions(&mut s, b, vec![region(0.2, Some("Ann"))]); + s.commit("delete", Op::SetDeleted { id: b, deleted: true }).unwrap(); + let listed = s.execute("faces.person", &json!({"name": "Ann"})).unwrap(); + assert_eq!((listed["total"].clone(), listed["confirmed"].as_array().unwrap().len()), (json!(5001), 5000)); + let r = s.execute("faces.renamePerson", &json!({"from": "ann", "to": "Ada"})).unwrap(); + assert_eq!((r["faces"].clone(), r["photos"].clone()), (json!(5001), json!(1)), "the deleted photo is left alone"); + assert!(s.catalog.photo(a).unwrap().meta.regions.iter().all(|r| r.name.as_deref() == Some("Ada"))); + assert_eq!(s.catalog.photo(b).unwrap().meta.regions[0].name.as_deref(), Some("Ann")); + let local = s.catalog.photos().map(|p| p.id).find(|id| *id != a && *id != b).unwrap(); + set_regions(&mut s, local, vec![region(0.1, Some("Ada"))]); + s.commit("browse", Op::SetLocal { id: local, local: true }).unwrap(); + let r = s.execute("faces.renamePerson", &json!({"from": "Ada", "to": "Bea"})).unwrap(); + assert_eq!(r["photos"], 1, "the browsed photo is not another person and is not renamed"); + assert_eq!(s.catalog.photo(a).unwrap().meta.regions[0].name.as_deref(), Some("Bea")); + assert_eq!(s.catalog.photo(local).unwrap().meta.regions[0].name.as_deref(), Some("Ada")); +} diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index dff2ddb75..5378f425e 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt", "{name} opened in the other editor": "{name} im anderen Editor geöffnet", "{n} originals opened in the other editor": "{n} Originale im anderen Editor geöffnet", - "· {in_source} identical copies": "· {in_source} identische Kopien" + "· {in_source} identical copies": "· {in_source} identische Kopien", + "Renames {n} face{} across the library. The files are not changed.": "Benennt {n} Gesichter in der Bibliothek um. Die Dateien werden nicht geändert.{:.0}", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "„{to}“ ist bereits eine Person. Zusammenführen ergibt eine Person; Rückgängig trennt sie wieder." } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 452fc6621..4ad62befa 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original", "{name} opened in the other editor": "{name} abierto en el otro editor", "{n} originals opened in the other editor": "{n} originales abiertos en el otro editor", - "· {in_source} identical copies": "· {in_source} copias idénticas" + "· {in_source} identical copies": "· {in_source} copias idénticas", + "Renames {n} face{} across the library. The files are not changed.": "Cambia el nombre de {n} cara{} en toda la biblioteca. Los archivos no se modifican.", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” ya es una persona. Combinarlas deja una sola; Deshacer las separa de nuevo." } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index d85e837a3..e9e2572db 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original", "{name} opened in the other editor": "{name} ouvert dans l’autre éditeur", "{n} originals opened in the other editor": "{n} originaux ouverts dans l’autre éditeur", - "· {in_source} identical copies": "· {in_source} copies identiques" + "· {in_source} identical copies": "· {in_source} copies identiques", + "Renames {n} face{} across the library. The files are not changed.": "Renomme {n} visage{} dans la bibliothèque. Les fichiers ne sont pas modifiés.", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "« {to} » est déjà une personne. Les combiner n’en fait qu’une ; Annuler les sépare de nouveau." } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 211e47e29..ccafc9e92 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています", "{name} opened in the other editor": "{name}を別のエディタで開きました", "{n} originals opened in the other editor": "{n}個の元ファイルを別のエディタで開きました", - "· {in_source} identical copies": "・同一のコピー {in_source} 件" + "· {in_source} identical copies": "・同一のコピー {in_source} 件", + "Renames {n} face{} across the library. The files are not changed.": "ライブラリ全体で{n}件の顔の名前を変更します。ファイルは変更されません。{:.0}", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "「{to}」はすでに別の人物です。まとめると1人になり、元に戻すと再び分かれます。" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index e5f32f551..31858441e 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original", "{name} opened in the other editor": "{name} aberto no outro editor", "{n} originals opened in the other editor": "{n} originais abertos no outro editor", - "· {in_source} identical copies": "· {in_source} cópias idênticas" + "· {in_source} identical copies": "· {in_source} cópias idênticas", + "Renames {n} face{} across the library. The files are not changed.": "Renomeia {n} rosto{} na biblioteca. Os arquivos não são alterados.", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” já é uma pessoa. Combiná-las deixa uma só; Desfazer as separa de novo." } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 38e28e356..741c33e4e 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом", "{name} opened in the other editor": "{name} открыт в другом редакторе", "{n} originals opened in the other editor": "Открыто оригиналов в другом редакторе: {n}", - "· {in_source} identical copies": "· идентичных копий: {in_source}" + "· {in_source} identical copies": "· идентичных копий: {in_source}", + "Renames {n} face{} across the library. The files are not changed.": "Переименовывает {n} лиц в библиотеке. Файлы не изменяются.{:.0}", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "«{to}» уже есть среди людей. Объединение оставляет одного человека; отмена снова разделяет их." } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 55636422f..bc0b8d6f8 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом", "{name} opened in the other editor": "{name} відкрито в іншому редакторі", "{n} originals opened in the other editor": "Відкрито оригіналів в іншому редакторі: {n}", - "· {in_source} identical copies": "· ідентичних копій: {in_source}" + "· {in_source} identical copies": "· ідентичних копій: {in_source}", + "Renames {n} face{} across the library. The files are not changed.": "Перейменовує {n} облич у бібліотеці. Файли не змінюються.{:.0}", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "«{to}» уже є серед людей. Об’єднання лишає одну людину; скасування знову розділяє їх." } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index 782f8d731..0ce93aa3a 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起", "{name} opened in the other editor": "已在其他编辑器中打开 {name}", "{n} originals opened in the other editor": "已在其他编辑器中打开 {n} 个原始文件", - "· {in_source} identical copies": "· {in_source} 个相同副本" + "· {in_source} identical copies": "· {in_source} 个相同副本", + "Renames {n} face{} across the library. The files are not changed.": "在整个图库中重命名 {n} 张面孔。不会改动文件。{:.0}", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}”已经是另一个人。合并后成为一个人;撤销会再把他们分开。" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 56db559c6..8bad2999a 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -247,5 +247,7 @@ "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起", "{name} opened in the other editor": "已在其他編輯器中開啟 {name}", "{n} originals opened in the other editor": "已在其他編輯器中開啟 {n} 個原始檔案", - "· {in_source} identical copies": "· {in_source} 個相同副本" + "· {in_source} identical copies": "· {in_source} 個相同副本", + "Renames {n} face{} across the library. The files are not changed.": "在整個圖庫中重新命名 {n} 張面孔。不會改動檔案。{:.0}", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "「{to}」已經是另一個人。合併後成為一個人;還原會再把他們分開。" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 8248ee5a1..c6b4ad341 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -108,6 +108,8 @@ mod tests_preview_limit; #[cfg(test)] mod tests_quit_unsaved; #[cfg(test)] +mod tests_rename_person; +#[cfg(test)] mod tests_scroll; #[cfg(test)] mod tests_switch_library; @@ -498,6 +500,22 @@ impl LightcraftApp { self } + /// The open person page, the way back to it, and the photo it was opened from follow a rename. + fn follow_renamed_person(&mut self, from: &str, to: &str) { + let same = |n: &str| n.trim().to_lowercase() == from.trim().to_lowercase(); + if self.ui.person_page.as_deref().is_some_and(same) { + self.ui.person_page = Some(to.to_string()); + } + if self.ui.last_person.as_deref().is_some_and(same) { + self.ui.last_person = Some(to.to_string()); + } + if let Some((name, photo)) = self.ui.person_from.clone() + && same(&name) + { + self.ui.person_from = Some((to.to_string(), photo)); + } + } + /// Run a UI or engine command by id. The single entry point for every frontend path. pub fn run(&mut self, id: &str, params: Value) -> Result { if let Some(result) = model_setup::intercept(self, id, ¶ms) { @@ -573,6 +591,13 @@ impl LightcraftApp { } } Ok(v) if id == "faces.detect" => self.ui.status = detect_summary(v), + Ok(v) if id == "faces.renamePerson" => { + let to = v["to"].as_str().unwrap_or("").to_string(); + let from = params.get("from").and_then(Value::as_str).unwrap_or(""); + if !to.is_empty() { + self.follow_renamed_person(from, &to); + } + } Ok(_) => {} } r diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 08e3bd670..3cba502f5 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -229,6 +229,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::LabelNames { .. } => "Edit Color Label Names", Dialog::CaptureTime { .. } => "Edit Capture Time", Dialog::RenameKeyword { .. } => "Rename Keyword", + Dialog::RenamePerson { .. } => "Rename Person", Dialog::KeywordTag { editing: None, .. } => "Create Keyword Tag", Dialog::KeywordTag { .. } => "Edit Keyword Tag", Dialog::DeleteKeyword { .. } => "Delete Keyword", @@ -573,6 +574,36 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.label(egui::RichText::new(crate::i18n::tr_format!("… and {more} more", more = more)).color(t.text_dim)); } } + Dialog::RenamePerson { from, to, merge } => { + let submitted = dialog_field(ui, "field:personName", to, "Name"); + let plan = person_rename_plan(&app.session.catalog, from, to); + if !plan.needs_merge { + *merge = false; + } + if submitted && plan.block.is_none() && (!plan.needs_merge || *merge) { + confirm = true; + } + if let Some(why) = plan.block { + ui.label(egui::RichText::new(crate::i18n::tr(why)).color(t.caution)); + } else { + ui.label( + egui::RichText::new(crate::i18n::tr_format!( + "Renames {n} face{} across the library. The files are not changed.", + if plan.faces == 1 { "" } else { "s" }, + n = plan.faces + )) + .color(t.text_dim), + ); + } + if plan.needs_merge { + ui.label( + egui::RichText::new(crate::i18n::tr_format!("“{to}” is already a person. Combining them makes one person; Undo separates them again.", to = to.trim())) + .color(t.caution), + ); + let r = ui.checkbox(merge, crate::i18n::tr("Combine with the existing person")); + crate::widgets::register(ui.ctx(), "check:personMerge", r.rect); + } + } Dialog::RenameKeyword { from, to } => { let n = app.session.catalog.photos().filter(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::is_under(k, from))).count(); if dialog_field(ui, "field:keywordName", to, "New name") { @@ -1246,18 +1277,27 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::SamModel { .. } if sam_installed || sam_running || sam_nowhere => "", Dialog::SamModel { error, .. } if error.is_some() || sam_failed => "Try Again", Dialog::SamModel { .. } => "Download", + Dialog::RenamePerson { from, to, .. } if person_rename_plan(&app.session.catalog, from, to).needs_merge => "Merge", _ if informational => "Close", _ => "OK", }; // installing waits for the licence to be accepted // (and Synchronize waits for the scan) - let can_confirm = informational - || !matches!( - &dlg, - Dialog::DenoiseModel { accepted: false, .. } | Dialog::FaceModel { accepted: false, .. } | Dialog::SynchronizeFolder { counts: None, .. } - ) - // smart-album rules that can't mean anything wait to be fixed - && !matches!(&dlg, Dialog::SmartRules { id, rules, .. } if !rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)).is_empty()); + let rename_blocked = match &dlg { + Dialog::RenamePerson { from, to, merge } => { + let plan = person_rename_plan(&app.session.catalog, from, to); + plan.block.is_some() || (plan.needs_merge && !*merge) + } + _ => false, + }; + let can_confirm = !rename_blocked + && (informational + || !matches!( + &dlg, + Dialog::DenoiseModel { accepted: false, .. } | Dialog::FaceModel { accepted: false, .. } | Dialog::SynchronizeFolder { counts: None, .. } + ) + // smart-album rules that can't mean anything wait to be fixed + && !matches!(&dlg, Dialog::SmartRules { id, rules, .. } if !rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)).is_empty())); let r = (!ok.is_empty()).then(|| ui.add_enabled(can_confirm, egui::Button::new(crate::i18n::tr(ok)))); if let Some(r) = &r { crate::widgets::register(ui.ctx(), "button:dialogOk", r.rect); @@ -1575,6 +1615,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("photo.rename", json!({"template": template, "start": start})), Dialog::RenameKeyword { from, to } => app.run("keyword.rename", json!({"from": from, "to": to})), + Dialog::RenamePerson { from, to, merge } => app.run("faces.renamePerson", json!({"from": from, "to": to.trim(), "merge": merge})), Dialog::KeywordTag { editing, name, @@ -1908,6 +1949,54 @@ fn return_confirms(dlg: &Dialog) -> bool { matches!(dlg, Dialog::CopySettings { .. } | Dialog::PasteSettings { .. }) } +struct PersonRenamePlan { + faces: usize, + needs_merge: bool, + /// Why Rename stays disabled. `None` when the typed name can be applied. + block: Option<&'static str>, +} + +/// What renaming `from` to `to` would do, using the same rules as `faces.renamePerson`. +fn person_rename_plan(cat: &lightcraft_catalog::Catalog, from: &str, to: &str) -> PersonRenamePlan { + let to_trim = to.trim(); + if to_trim.is_empty() { + return PersonRenamePlan { faces: 0, needs_merge: false, block: Some("Type a name") }; + } + if to_trim.chars().count() > 200 || to_trim.chars().any(char::is_control) { + return PersonRenamePlan { faces: 0, needs_merge: false, block: Some("A name is at most 200 characters, without control characters") }; + } + let from_key = from.trim().to_lowercase(); + let to_key = to_trim.to_lowercase(); + let mut matched = 0usize; + let mut faces = 0usize; + let mut needs_merge = false; + if !from_key.is_empty() { + for photo in cat.photos().filter(|p| p.in_library()) { + for region in photo.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) { + let Some(name) = region.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue }; + let key = name.to_lowercase(); + if key == from_key { + matched += 1; + if region.name.as_deref() != Some(to_trim) { + faces += 1; + } + } else if key == to_key { + needs_merge = true; + } + } + } + } + let block = if matched == 0 { + Some("No person by that name") + } else if faces == 0 { + Some("That is already their name") + } else { + None + }; + // a case-only change is the same person, not a merge + PersonRenamePlan { faces, needs_merge: needs_merge && from_key != to_key, block } +} + /// A dialog's one text field: it keeps the focus (except while its own menu has it) and opens /// with its text selected, so typing replaces it. True when Return confirms the dialog; Esc /// cancels the dialog (`show`). Taking the focus back with a bare `TextEdit` every frame hid the diff --git a/crates/ui-egui/src/panels/people.rs b/crates/ui-egui/src/panels/people.rs index 6fdf032f3..ec9514b24 100644 --- a/crates/ui-egui/src/panels/people.rs +++ b/crates/ui-egui/src/panels/people.rs @@ -181,11 +181,25 @@ fn card(app: &mut LightcraftApp, ui: &mut egui::Ui, person: &Person, r: Rect, ed p.text(pos2(r.left() + 2.0, face.bottom() + 14.0), Align2::LEFT_CENTER, name, t.semibold(13.0), t.text); let photos = crate::i18n::tr_format!("{n} photo{}", if person.count == 1 { "" } else { "s" }, n = person.count); p.text(pos2(r.left() + 2.0, face.bottom() + 32.0), Align2::LEFT_CENTER, photos, t.font(12.0), t.text_dim); - if resp.on_hover_text(crate::i18n::tr_format!("{} — show their photos", person.name)).clicked() { + let resp = resp.on_hover_text(crate::i18n::tr_format!("{} — show their photos", person.name)); + let full = person.name.clone(); + resp.context_menu(|ui| { + let r = ui.button(crate::i18n::tr("Rename Person…")); + register(ui.ctx(), "personMenu:rename", r.rect); + if r.clicked() { + open_rename(app, &full); + } + }); + if resp.clicked() { let _ = app.run("view.person", json!({"name": person.name})); } } +/// The Rename Person dialog, prefilled with the name the card shows. +pub(crate) fn open_rename(app: &mut LightcraftApp, name: &str) { + app.ui.dialog = Some(crate::state::Dialog::RenamePerson { from: name.to_string(), to: name.to_string(), merge: false }); +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/panels/person.rs b/crates/ui-egui/src/panels/person.rs index 9ebf09a64..3439925ba 100644 --- a/crates/ui-egui/src/panels/person.rs +++ b/crates/ui-egui/src/panels/person.rs @@ -99,6 +99,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { let _ = app.run("view.people", json!({})); } ui.label(RichText::new(&shown_name).font(t.semibold(15.0)).color(t.text)); + let rename = ui.button(crate::i18n::tr("Rename Person…")); + register(ui.ctx(), "person:rename", rename.rect); + if rename.clicked() { + super::people::open_rename(app, &shown_name); + } let count = if page.total == 1 { "1 face".to_string() } else { format!("{} faces", page.total) }; ui.label(RichText::new(count).color(t.text_dim)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 6c4dd3f6a..f97054e77 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -825,6 +825,13 @@ pub enum Dialog { from: String, to: String, }, + /// Rename every face of `from` to `to`. `merge` is the user's yes to combining with the person + /// `to` already names. + RenamePerson { + from: String, + to: String, + merge: bool, + }, /// Create a keyword, or edit one (`editing`): Lightroom Classic's Create / Edit Keyword Tag. KeywordTag { /// The keyword being edited; `None`: a new one. diff --git a/crates/ui-egui/src/tests_rename_person.rs b/crates/ui-egui/src/tests_rename_person.rs new file mode 100644 index 000000000..9a69b1409 --- /dev/null +++ b/crates/ui-egui/src/tests_rename_person.rs @@ -0,0 +1,138 @@ +//! Rename Person from the People overview and from a person's page. + +use std::time::Duration; + +use lightcraft_catalog::Op; +use serde_json::json; + +use crate::headless::Headless; +use crate::state::Dialog; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn demo() -> Headless { + let services = Services { png: None, write_shared: Some(std::sync::Arc::new(|_: &str, _: &[u8]| Ok(()))), ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + h.settle(T); + h +} + +fn face(x: f64, name: &str) -> lightcraft_meta::Region { + lightcraft_meta::Region { + rect: lightcraft_geom::Rect { x0: x, y0: 0.2, x1: x + 0.25, y1: 0.6 }, + kind: lightcraft_meta::RegionKind::Face, + name: Some(name.to_string()), + description: None, + } +} + +fn name_faces(h: &mut Headless, id: lightcraft_catalog::PhotoId, regions: Vec) { + let mut meta = h.app.session.catalog.photo(id).unwrap().meta.clone(); + meta.regions = regions; + h.app.session.commit("setup", Op::SetMeta { id, meta: Box::new(meta) }).unwrap(); +} + +fn widget(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) +} + +fn click(h: &mut Headless, id: &str) { + let r = h.request("ui.clickWidget", json!({"id": id}), T); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.step(); + h.step(); +} + +fn right_click(h: &mut Headless, id: &str) { + let c = widget(h, id).center(); + let r = h.request("ui.click", json!({"x": c.x, "y": c.y, "button": "right"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); +} + +fn open_people(h: &mut Headless) { + let r = h.request("engine.execute", json!({"command": "view.people"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(T); + h.step(); +} + +fn stored(h: &Headless, id: lightcraft_catalog::PhotoId) -> Vec> { + h.app.session.catalog.photo(id).unwrap().meta.regions.iter().map(|r| r.name.clone()).collect() +} + +/// Both entry points open the dialog. Cancel and an empty name change nothing; a real rename updates +/// the person page, the way back to it, and a person filter. +#[test] +fn rename_person_from_the_card_and_the_page() { + let mut h = demo(); + let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(2).collect(); + name_faces(&mut h, ids[0], vec![face(0.1, "Jane Doe"), face(0.5, "jane doe")]); + name_faces(&mut h, ids[1], vec![face(0.3, "Jane Doe")]); + open_people(&mut h); + right_click(&mut h, "person:Jane Doe"); + click(&mut h, "personMenu:rename"); + assert!(matches!(&h.app.ui.dialog, Some(Dialog::RenamePerson { from, to, .. }) if from == "Jane Doe" && to == "Jane Doe")); + click(&mut h, "button:dialogCancel"); + assert!(h.app.ui.dialog.is_none()); + assert_eq!(stored(&h, ids[0]), vec![Some("Jane Doe".into()), Some("jane doe".into())]); + + h.request("engine.execute", json!({"command": "view.person", "params": {"name": "Jane Doe"}}), T); + h.step(); + click(&mut h, "person:rename"); + let Some(Dialog::RenamePerson { to, .. }) = &mut h.app.ui.dialog else { panic!("{:?}", h.app.ui.dialog) }; + to.clear(); + h.step(); + click(&mut h, "button:dialogOk"); + assert!(matches!(h.app.ui.dialog, Some(Dialog::RenamePerson { .. })), "an empty name does not rename"); + assert_eq!(stored(&h, ids[0])[0].as_deref(), Some("Jane Doe")); + + let Some(Dialog::RenamePerson { to, .. }) = &mut h.app.ui.dialog else { panic!("dialog closed") }; + *to = "Ada Lovelace".into(); + h.app.ui.last_person = Some("Jane Doe".into()); + h.app.ui.person_from = Some(("Jane Doe".into(), ids[0].0)); + h.app.session.execute("library.filter", &json!({"person": "jane doe"})).unwrap(); + h.step(); + click(&mut h, "button:dialogOk"); + assert!(h.app.ui.dialog.is_none(), "{:?}", h.app.ui.dialog); + assert!(stored(&h, ids[0]).iter().all(|n| n.as_deref() == Some("Ada Lovelace"))); + assert_eq!(stored(&h, ids[1])[0].as_deref(), Some("Ada Lovelace")); + assert_eq!(h.app.ui.person_page.as_deref(), Some("Ada Lovelace")); + assert_eq!(h.app.ui.last_person.as_deref(), Some("Ada Lovelace")); + assert_eq!(h.app.ui.person_from.as_ref().map(|(n, _)| n.as_str()), Some("Ada Lovelace")); + assert_eq!(h.app.session.filter.person.as_deref(), Some("Ada Lovelace")); + h.app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(stored(&h, ids[0]), vec![Some("Jane Doe".into()), Some("jane doe".into())]); +} + +/// Renaming onto someone else waits for Combine, then one undo step restores both people. +#[test] +fn rename_person_asks_before_merging() { + let mut h = demo(); + let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(2).collect(); + name_faces(&mut h, ids[0], vec![face(0.1, "Jane Doe")]); + name_faces(&mut h, ids[1], vec![face(0.3, "John Roe")]); + open_people(&mut h); + h.request("engine.execute", json!({"command": "view.person", "params": {"name": "Jane Doe"}}), T); + h.step(); + click(&mut h, "person:rename"); + let Some(Dialog::RenamePerson { to, .. }) = &mut h.app.ui.dialog else { panic!("{:?}", h.app.ui.dialog) }; + *to = "John Roe".into(); + h.step(); + click(&mut h, "button:dialogOk"); + assert!(matches!(h.app.ui.dialog, Some(Dialog::RenamePerson { .. })), "a merge is not silent"); + assert_eq!(stored(&h, ids[0])[0].as_deref(), Some("Jane Doe")); + click(&mut h, "check:personMerge"); + click(&mut h, "button:dialogOk"); + assert!(h.app.ui.dialog.is_none()); + assert_eq!(stored(&h, ids[0])[0].as_deref(), Some("John Roe")); + assert_eq!(h.app.ui.person_page.as_deref(), Some("John Roe")); + let names: Vec = h.app.session.catalog.people().into_iter().map(|p| p.name).collect(); + assert_eq!(names, vec!["John Roe".to_string()]); + h.app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(stored(&h, ids[0])[0].as_deref(), Some("Jane Doe")); + assert_eq!(stored(&h, ids[1])[0].as_deref(), Some("John Roe")); +} diff --git a/docs/faces.md b/docs/faces.md index 5c8e54317..0d8b09550 100644 --- a/docs/faces.md +++ b/docs/faces.md @@ -268,6 +268,13 @@ To name a group: click faces to select them (Shift-click selects a range, **Sele name in the bar that appears (people already named complete as you type), press Enter: all of them are named at once, as one undo step. Selecting names nothing. +Right-click a person's card, or use **Rename Person…** on their page, to rename every face of that person in the library +at once (`faces.renamePerson`). The name is matched the way the cards are grouped (trimmed, ignoring capitalization) and +every matching face is stored with the exact new spelling, so a change of capitalization alone updates them all. The +dialog says how many faces that is. If the new name is already someone else, it refuses until **Combine with the existing +person** is ticked; that makes one person, and Undo separates them again. Cancelling changes nothing. A person filter, +the open person page and the way back to it follow the new name. Nothing is written back to the files. + The thumbnail-size slider in the bottom bar sizes the faces here too, with limits of their own. Every face is shown by the same kind of box: the detector's own box once the scan has looked at it (kept beside its embedding), so a loosely drawn box from another tool does not make one face look farther away than the next; before the scan has looked at a face it is shown @@ -296,7 +303,7 @@ suggestions it would make and how many were right. All of this is reachable from the control channel, the CLI and MCP: `faces.models.list`, `faces.models.inspect {path}`, `faces.models.install {path, acknowledged: true, activate?}`, `faces.models.download {id, acknowledged: true}` (then `faces.models.downloads`, which also installs what has arrived, and `faces.models.downloadCancel {id}`), `faces.models.remove {id}`, `faces.models.select {id}`, -`faces.enable {enabled?}`, `faces.detect {ids?, apply?}`, `faces.index {budgetMs?, ids?}`, `faces.pump` (what the app calls every frame), `faces.suggest {ids?, threshold?, margin?}`, `faces.person {name, more?}` (a person's faces and the unnamed faces that look like them), `faces.unnamed {limit?}` (every unnamed face, look-alikes together, with suggested names), `faces.setName {id?, index, name}`, `faces.nameFaces {faces: [{photo, index}], name}` (name many at once, one undo step) and `faces.evaluate`. `acknowledged` must be `true`: the caller has shown the user the terms and the user agreed. Installing makes the model the one in use and switches recognition on unless `activate` is `false`. +`faces.enable {enabled?}`, `faces.detect {ids?, apply?}`, `faces.index {budgetMs?, ids?}`, `faces.pump` (what the app calls every frame), `faces.suggest {ids?, threshold?, margin?}`, `faces.person {name, more?}` (a person's faces and the unnamed faces that look like them), `faces.unnamed {limit?}` (every unnamed face, look-alikes together, with suggested names), `faces.setName {id?, index, name}`, `faces.nameFaces {faces: [{photo, index}], name}` (name many at once, one undo step), `faces.renamePerson {from, to, merge?}` (rename every in-library face of one person, or merge two when `merge` is true, one undo step) and `faces.evaluate`. `acknowledged` must be `true`: the caller has shown the user the terms and the user agreed. Installing makes the model the one in use and switches recognition on unless `activate` is `false`. ## Find Blemishes diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 76d91ba3e..ac9f742b0 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -104,7 +104,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | P pick, X reject, U clear; Shift+P picks and advances in both grids (Presets elsewhere); Shift+X/U and legacy Shift+Z advance; no flag cycle; rejection does not delete the file | | LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot plus translucent colour on square-grid/filmstrip surrounds and photo-grid footers (photo pixels unchanged); colour-label toasts have pale matching backgrounds; setting/clearing through keys (including Shift), menus or Info swatches shows the same bottom toast as ratings, with the colour/custom name; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | | LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; keywords reorganize by drag and drop in the Keyword List (LRC-LIB-KEYWORDS) | -| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:faces.detect`, `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. Photo ▸ Detect Faces (`cmd:faces.detect`) finds faces with the YuNet detector (pure Rust, about 200 ms a photo, matches OpenCV's own YuNet on a 45-photo test set; the 232 KB model is a one-time download offered in the same step, never part of LightCraft) and adds them as unnamed boxes; names are suggested from the faces already named (recognition models are opt-in downloads, run on the CPU in a background thread; a face is named only when you confirm it); a person's page shows their cropped faces and the unnamed faces that look like them (LRC-LIB-PEOPLE); with recognition on, a background scan finds faces in photos that have none and embeds every face (one to three worker threads, raws through their embedded preview); an Unnamed faces section orders faces by likeness and names a selected group at once; no separated clusters, no manual tagging, no writing regions back | +| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:faces.detect`, `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `cmd:faces.renamePerson`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. Photo ▸ Detect Faces (`cmd:faces.detect`) finds faces with the YuNet detector (pure Rust, about 200 ms a photo, matches OpenCV's own YuNet on a 45-photo test set; the 232 KB model is a one-time download offered in the same step, never part of LightCraft) and adds them as unnamed boxes; names are suggested from the faces already named (recognition models are opt-in downloads, run on the CPU in a background thread; a face is named only when you confirm it); a person's page shows their cropped faces and the unnamed faces that look like them (LRC-LIB-PEOPLE); with recognition on, a background scan finds faces in photos that have none and embeds every face (one to three worker threads, raws through their embedded preview); an Unnamed faces section orders faces by likeness and names a selected group at once; no separated clusters, no manual tagging, no writing regions back. Rename Person… (`cmd:faces.renamePerson`) on a card's menu and on the person page renames every in-library face of that person in one undo step, including a change of capitalization; renaming onto another person is refused until Combine is ticked, and Undo restores both | | LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | | LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Delete and Delete Permanently retain surviving selected photos; if none remain, select the next visible survivor (previous at the end) in the current source/filter/sort; permanent deletion removes catalog records, not files on disk; Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); importing a file that is in Recently Deleted again restores the photo with its edits, rating and albums (issues #298, #706: before, the import skipped it as "already in the library" while the grid showed it deleted) — the review dialog checks it, badges it "Will be restored", counts it as "in Recently Deleted" rather than "already in the library", and its choice Restore them / Import as new / Leave them starts on Restore; with Leave them such a file starts unchecked and Check All and the file-type toggles leave it out, but a click still checks it and it is restored (issue #1285: before, the click did nothing and the "In Recently Deleted" badge was drawn over the checkbox, which now sits above it); dropped files restore too ("Restored 1 photo from Recently Deleted"); a deleted photo matched by content whose file is gone is relinked to the imported file; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` restore (default) / skip / fresh for files in the trash (`cmd:library.import`; the report lists `restored`, and with skip flags the duplicate `existingDeleted`); browsing a folder in Local (`cmd:library.browse`), Auto Import and a Lightroom catalog import never restore a deleted photo; no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog); Delete asks first ("Move this photo to Recently Deleted?") from the key, the Photo menu and the photo context menu, on by default and switched off in Settings ▸ General (#1239: before, it was off by default and the context menu never asked); no auto-purge after N days (needs a deleted-at date in the catalog) | @@ -706,7 +706,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`) or saved as a new set (`new`, a taken name refused). File ▸ Import Keywords… / Export Keywords… (`cmd:keyword.import`, `cmd:keyword.export`): keyword list files in Lightroom Classic's format (tab-indented, `{synonym}`, `[left out of export]`), which Capture One, darktable, Bridge and Photo Supreme (its Formatted Vocabulary File) read and write too; import adds what is missing in one undo step, export names the keywords Capture One refuses (; , < >). Not yet: synonyms in search, person keywords tied to faces, Keyword Shortcut, IPTC keywords in exported files (Piwigo, RawTherapee), hierarchy from digiKam / ACDSee / Microsoft XMP fields | | LRC-LIB-METADATA | Metadata panel & presets | P1 | ✅ | `cmd:photo.setMeta`, `cmd:metadata.savePreset`, `cmd:metadata.applyPreset`, `cmd:metadata.presets`, `cmd:metadata.deletePreset`, `cmd:dialog.captureTime` | IPTC core, accessibility, place, capture-time edit; copyright status (unknown / copyrighted / public domain), rights usage terms and copyright info URL (`photo.setMeta` `copyrightStatus`, `usageTerms`, `copyrightUrl`; XMP Rights Management fields, kept by "copyright only" exports, a smart-album rule); metadata presets (Photo → Metadata Preset, Save Metadata Preset… from the active photo; applied on import from Settings) | | LRC-LIB-QUICKDEV | Quick develop | P2 | ✅ | `cmd:develop.quickAdjust`, `crates/ui-egui/src/panels/edit.rs` (`quick_develop`) | grid with several photos selected: Edit panel ▸ Quick Develop steps (exposure ⅓ / 1 stop, contrast, highlights, shadows, whites, blacks, clarity, vibrance, temperature) added to each photo's own value, one undo step | -| LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `cmd:view.person`, `cmd:faces.person`, `cmd:faces.unnamed`, `cmd:faces.nameFaces`, `crates/ui-egui/src/panels/people.rs`, `crates/ui-egui/src/panels/person.rs`, `crates/ui-egui/src/panels/unnamed.rs` | Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the **Unnamed faces**: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step. A click on a person opens their page: only cropped faces, then **More** (unnamed faces that look like them, best first; click to confirm, × to hide). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. No clusters with separators between groups, no persistent reject, no merge of two people +| LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `cmd:view.person`, `cmd:faces.person`, `cmd:faces.unnamed`, `cmd:faces.nameFaces`, `cmd:faces.renamePerson`, `crates/ui-egui/src/panels/people.rs`, `crates/ui-egui/src/panels/person.rs`, `crates/ui-egui/src/panels/unnamed.rs` | Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the **Unnamed faces**: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step. A click on a person opens their page: only cropped faces, then **More** (unnamed faces that look like them, best first; click to confirm, × to hide). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. No clusters with separators between groups, no persistent reject. Rename Person… (`cmd:faces.renamePerson`) renames every face of that person in one undo step, or merges two people after a warning; Undo restores both | LRC-LIB-COMMENTS | Comments panel | P2 | ⬜ | | | | LRC-LIB-VISUALSEARCH | Find similar photos | P2 | ✅ | `cmd:library.findSimilar`, `crates/pipeline/src/cull.rs` (`signature`) | photo menu / Photo ▸ Find Similar Photos: look-alike photos (composition and tones), most similar first, as a filter (`only`) | | LRC-LIB-MISSING | Missing files & relink | P1 | ✅ | `cmd:library.missing`, `cmd:photo.relink`, `cmd:library.findMissing`, `cmd:file.findMissing`, `cmd:photo.locate`, `crates/engine/src/cmd/missing.rs` (`checked_path`), `crates/ui-egui/src/panels/left.rs` (`locate_missing_item`) | File → Find Missing Photos… (anywhere in a folder: same name, size and content hash; renamed files by size + content hash; same name and size alone only when unambiguous — look-alikes are skipped and reported; searched on a worker thread, one undo step), photo menu → Locate Missing File…; a folder or disk row's Locate Missing Files… chooses its new location and scopes the same recursive background finder to that folder and its subfolders. Unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. Missing Photos in the sidebar (counted on a worker thread; the view, the Info panel, the grid's context menu and thumbnails read file availability from a cache a worker thread fills, so an offline NAS never stalls a frame); it covers library photos only — Local browse records are never checked, for the count, the view, `library.missing` and Find Missing alike |