# Conflicts:
#	crates/ui-egui/locales/de-formats.json
#	crates/ui-egui/locales/es-formats.json
#	crates/ui-egui/locales/fr-formats.json
#	crates/ui-egui/locales/ja-formats.json
#	crates/ui-egui/locales/pt-br-formats.json
#	crates/ui-egui/locales/ru-formats.json
#	crates/ui-egui/locales/uk-formats.json
#	crates/ui-egui/locales/zh-hans-formats.json
#	crates/ui-egui/locales/zh-hant-formats.json
#	crates/ui-egui/src/lib.rs
#	docs/parity-checklist.md
This commit is contained in:
Brandon Thomas committed 2026-10-14 22:33:10 -04:00
commit f8d1585c6f
18 files changed
+514 -20

No files matched your search

+98 -2
View File
@@ -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<Value> {
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<Value> {
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<CommandSpec> {
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
),
]
}
+95
View File
@@ -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<Option<String>> {
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<String> = 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<String> = 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"));
}
+3 -1
View File
@@ -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."
}
+3 -1
View File
@@ -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."
}
+3 -1
View File
@@ -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."
}
+3 -1
View File
@@ -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人になり、元に戻すと再び分かれます。"
}
+3 -1
View File
@@ -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."
}
+3 -1
View File
@@ -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}» уже есть среди людей. Объединение оставляет одного человека; отмена снова разделяет их."
}
+3 -1
View File
@@ -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}» уже є серед людей. Об’єднання лишає одну людину; скасування знову розділяє їх."
}
+3 -1
View File
@@ -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}”已经是另一个人。合并后成为一个人;撤销会再把他们分开。"
}
+3 -1
View File
@@ -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}」已經是另一個人。合併後成為一個人;還原會再把他們分開。"
}
+25
View File
@@ -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<Value, String> {
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
+96 -7
View File
@@ -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<serde_jso
}
Dialog::Rename { template, start } => 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
+15 -1
View File
@@ -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::*;
+5
View File
@@ -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| {
+7
View File
@@ -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.
+138
View File
@@ -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<lightcraft_meta::Region>) {
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<Option<String>> {
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<String> = 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"));
}
+8 -1
View File
@@ -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