diff --git a/crates/engine/src/cmd/face_recognize.rs b/crates/engine/src/cmd/face_recognize.rs index e6e850119..a1f1dc65d 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; @@ -1079,5 +1166,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 be8bd041d..ce601ae33 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -285,5 +285,7 @@ " · {n} skipped (could not be rendered)": " · {n} übersprungen (nicht darstellbar)", "{n} raws can now be developed from their raw data": "{n} RAW-Dateien können jetzt aus ihren Rohdaten entwickelt werden", "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft wurde unerwartet beendet: {e}\n\nEinzelheiten stehen in der Protokolldatei: {path}", - "LightCraft stopped unexpectedly: {e}": "LightCraft wurde unerwartet beendet: {e}" + "LightCraft stopped unexpectedly: {e}": "LightCraft wurde unerwartet beendet: {e}", + "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 c79604f83..fb2c80100 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -285,5 +285,7 @@ " · {n} skipped (could not be rendered)": " · {n} omitidas (no se pudieron representar)", "{n} raws can now be developed from their raw data": "{n} RAW ya se pueden revelar a partir de sus datos RAW", "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft se detuvo inesperadamente: {e}\n\nLos detalles están en el archivo de registro: {path}", - "LightCraft stopped unexpectedly: {e}": "LightCraft se detuvo inesperadamente: {e}" + "LightCraft stopped unexpectedly: {e}": "LightCraft se detuvo inesperadamente: {e}", + "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 91a56cd1a..45f40081a 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -285,5 +285,7 @@ " · {n} skipped (could not be rendered)": " · {n} ignorées (rendu impossible)", "{n} raws can now be developed from their raw data": "{n} fichiers RAW peuvent désormais être développés à partir de leurs données RAW", "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft s’est arrêté de façon inattendue : {e}\n\nConsultez le fichier journal pour plus de détails : {path}", - "LightCraft stopped unexpectedly: {e}": "LightCraft s’est arrêté de façon inattendue : {e}" + "LightCraft stopped unexpectedly: {e}": "LightCraft s’est arrêté de façon inattendue : {e}", + "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 0ab1d2958..d38dfdc48 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -285,5 +285,7 @@ " · {n} skipped (could not be rendered)": " · {n} 枚をスキップ(レンダリングできません)", "{n} raws can now be developed from their raw data": "{n}枚のRAWをRAWデータから現像できるようになりました", "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraftが予期せず終了しました: {e}\n\n詳しくはログファイルをご覧ください: {path}", - "LightCraft stopped unexpectedly: {e}": "LightCraftが予期せず終了しました: {e}" + "LightCraft stopped unexpectedly: {e}": "LightCraftが予期せず終了しました: {e}", + "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 5391dba20..2b6e1f046 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -285,5 +285,7 @@ " · {n} skipped (could not be rendered)": " · {n} ignoradas (não foi possível renderizar)", "{n} raws can now be developed from their raw data": "{n} RAW agora podem ser revelados a partir dos dados RAW", "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "O LightCraft parou inesperadamente: {e}\n\nO arquivo de log tem os detalhes: {path}", - "LightCraft stopped unexpectedly: {e}": "O LightCraft parou inesperadamente: {e}" + "LightCraft stopped unexpectedly: {e}": "O LightCraft parou inesperadamente: {e}", + "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 5f6dd0c16..0cc9dd881 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -285,5 +285,7 @@ " · {n} skipped (could not be rendered)": " · пропущено: {n} (не удалось отрисовать)", "{n} raws can now be developed from their raw data": "RAW-файлов, которые теперь можно проявлять из RAW-данных: {n}", "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft неожиданно остановился: {e}\n\nПодробности в файле журнала: {path}", - "LightCraft stopped unexpectedly: {e}": "LightCraft неожиданно остановился: {e}" + "LightCraft stopped unexpectedly: {e}": "LightCraft неожиданно остановился: {e}", + "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 54cf6505f..42675238c 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -285,5 +285,7 @@ " · {n} skipped (could not be rendered)": " · пропущено: {n} (не вдалося відтворити)", "{n} raws can now be developed from their raw data": "RAW-файлів, які тепер можна проявляти з RAW-даних: {n}", "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft несподівано зупинився: {e}\n\nПодробиці у файлі журналу: {path}", - "LightCraft stopped unexpectedly: {e}": "LightCraft несподівано зупинився: {e}" + "LightCraft stopped unexpectedly: {e}": "LightCraft несподівано зупинився: {e}", + "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 5deaf511e..6af55c88d 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -285,5 +285,7 @@ " · {n} skipped (could not be rendered)": " · 已跳过 {n} 张(无法渲染)", "{n} raws can now be developed from their raw data": "{n} 个 RAW 现在可以从 RAW 数据冲印", "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft 意外停止:{e}\n\n详细信息见日志文件:{path}", - "LightCraft stopped unexpectedly: {e}": "LightCraft 意外停止:{e}" + "LightCraft stopped unexpectedly: {e}": "LightCraft 意外停止:{e}", + "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 620bcde7c..1517e885d 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -285,5 +285,7 @@ " · {n} skipped (could not be rendered)": " · 已略過 {n} 張(無法算繪)", "{n} raws can now be developed from their raw data": "{n} 個 RAW 現在可以從 RAW 資料沖印", "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft 意外停止:{e}\n\n詳細資訊請見記錄檔:{path}", - "LightCraft stopped unexpectedly: {e}": "LightCraft 意外停止:{e}" + "LightCraft stopped unexpectedly: {e}": "LightCraft 意外停止:{e}", + "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 9d4b1ced0..f89ccc60b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -128,6 +128,8 @@ mod tests_recheck; #[cfg(test)] mod tests_sam_setup; #[cfg(test)] +mod tests_rename_person; +#[cfg(test)] mod tests_scroll; #[cfg(test)] mod tests_slideshow; @@ -551,6 +553,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 { self.run_typed(id, params).map_err(|e| e.message) @@ -637,6 +655,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 ec571de91..a7779fcd6 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -230,6 +230,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", @@ -605,6 +606,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") { @@ -1319,18 +1350,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); @@ -1689,6 +1729,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, @@ -2081,6 +2122,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 fb5c84e3a..0ec008c2c 100644 --- a/crates/ui-egui/src/panels/people.rs +++ b/crates/ui-egui/src/panels/people.rs @@ -183,11 +183,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 ef2357d49..81862d886 100644 --- a/crates/ui-egui/src/panels/person.rs +++ b/crates/ui-egui/src/panels/person.rs @@ -153,6 +153,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 3beeeba7d..1d22f2dd7 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -997,6 +997,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 41a39e0a8..cd6640aa3 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 @@ -332,7 +339,7 @@ reuses the pictures of its step instead of rendering every face again. 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