LRC-LIB-KEYWORDS: Edit Keyword Set refuses taken names in the user's words

- Edit Set… on Recent Keywords saves a new set (`new: true`): a name
  another set has is refused, where it used to replace that set.
- The dialog checks names before saving and says why in the user's
  language ("There is a keyword set “…” already"; Recent Keywords is
  built in), instead of showing the engine's message.
- A set deleted while its dialog was open is saved again, as a new set,
  rather than refused with no way out but Cancel.
- Save as a new set (Lightroom Classic's Save as New Preset) keeps the
  edited set and saves the edits under the new name.
Russian gets "Recent Keywords", which showed in English.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford
2026-10-09 16:09:01 -04:00
co-authored by Claude Opus 5.5
parent efa758a6ba
commit 94d88dd4cc
19 changed files with 117 additions and 21 deletions
+2 -1
View File
@@ -215,5 +215,6 @@
"There is a “{name}” at the top level already. Merge the two?": "Auf der obersten Ebene gibt es bereits „{name}“. Beide zusammenführen?",
"Added “{keyword}” to {n} photo{}": "„{keyword}“ zu {n} Foto{} hinzugefügt",
"On {have} of {of} selected photos": "Auf {have} von {of} ausgewählten Fotos",
"Remove “{name}”": "„{name}“ entfernen"
"Remove “{name}”": "„{name}“ entfernen",
"There is a keyword set “{name}” already": "Es gibt bereits einen Stichwortsatz „{name}“"
}
+3 -1
View File
@@ -1919,5 +1919,7 @@
"Edit Set…": "Satz bearbeiten…",
"Edit Keyword Set": "Stichwortsatz bearbeiten",
"Keywords (⌥1–⌥9)": "Stichwörter (⌥1–⌥9)",
"A keyword set needs a name": "Ein Stichwortsatz braucht einen Namen"
"A keyword set needs a name": "Ein Stichwortsatz braucht einen Namen",
"Save as a new set": "Als neuen Satz speichern",
"Recent Keywords is built in: choose another name": "„Letzte Stichwörter“ ist vorgegeben: Wählen Sie einen anderen Namen"
}
+2 -1
View File
@@ -215,5 +215,6 @@
"There is a “{name}” at the top level already. Merge the two?": "Ya hay “{name}” en el nivel superior. ¿Combinar las dos?",
"Added “{keyword}” to {n} photo{}": "Se añadió “{keyword}” a {n} foto{}",
"On {have} of {of} selected photos": "En {have} de {of} fotos seleccionadas",
"Remove “{name}”": "Quitar “{name}”"
"Remove “{name}”": "Quitar “{name}”",
"There is a keyword set “{name}” already": "Ya hay un conjunto de palabras clave “{name}”"
}
+3 -1
View File
@@ -1764,5 +1764,7 @@
"Edit Set…": "Editar conjunto…",
"Edit Keyword Set": "Editar conjunto de palabras clave",
"Keywords (⌥1–⌥9)": "Palabras clave (⌥1–⌥9)",
"A keyword set needs a name": "Un conjunto de palabras clave necesita un nombre"
"A keyword set needs a name": "Un conjunto de palabras clave necesita un nombre",
"Save as a new set": "Guardar como conjunto nuevo",
"Recent Keywords is built in: choose another name": "“Palabras clave recientes” es fijo: elige otro nombre"
}
+2 -1
View File
@@ -215,5 +215,6 @@
"There is a “{name}” at the top level already. Merge the two?": "最上位にはすでに「{name}」があります。統合しますか?",
"Added “{keyword}” to {n} photo{}": "写真{n}枚に「{keyword}」を追加しました{:.0}",
"On {have} of {of} selected photos": "選択した写真{of}枚のうち{have}枚",
"Remove “{name}”": "「{name}」を削除"
"Remove “{name}”": "「{name}」を削除",
"There is a keyword set “{name}” already": "キーワードセット「{name}」はすでにあります"
}
+3 -1
View File
@@ -1764,5 +1764,7 @@
"Edit Set…": "セットを編集…",
"Edit Keyword Set": "キーワードセットを編集",
"Keywords (⌥1–⌥9)": "キーワード (⌥1–⌥9)",
"A keyword set needs a name": "キーワードセットには名前が必要です"
"A keyword set needs a name": "キーワードセットには名前が必要です",
"Save as a new set": "新しいセットとして保存",
"Recent Keywords is built in: choose another name": "「最近使用したキーワード」は組み込みです。別の名前を選んでください"
}
+2 -1
View File
@@ -215,5 +215,6 @@
"There is a “{name}” at the top level already. Merge the two?": "Já existe “{name}” no nível superior. Mesclar as duas?",
"Added “{keyword}” to {n} photo{}": "“{keyword}” adicionada a {n} foto{}",
"On {have} of {of} selected photos": "Em {have} de {of} fotos selecionadas",
"Remove “{name}”": "Remover “{name}”"
"Remove “{name}”": "Remover “{name}”",
"There is a keyword set “{name}” already": "Já existe um conjunto de palavras-chave “{name}”"
}
+3 -1
View File
@@ -1764,5 +1764,7 @@
"Edit Set…": "Editar conjunto…",
"Edit Keyword Set": "Editar conjunto de palavras-chave",
"Keywords (⌥1–⌥9)": "Palavras-chave (⌥1–⌥9)",
"A keyword set needs a name": "Um conjunto de palavras-chave precisa de um nome"
"A keyword set needs a name": "Um conjunto de palavras-chave precisa de um nome",
"Save as a new set": "Salvar como novo conjunto",
"Recent Keywords is built in: choose another name": "“Palavras-chave recentes” é fixo: escolha outro nome"
}
+2 -1
View File
@@ -215,5 +215,6 @@
"There is a “{name}” at the top level already. Merge the two?": "На верхнем уровне уже есть «{name}». Объединить их?",
"Added “{keyword}” to {n} photo{}": "«{keyword}» добавлено к фото ({n}){:.0}",
"On {have} of {of} selected photos": "На {have} из {of} выбранных фото",
"Remove “{name}”": "Убрать «{name}»"
"Remove “{name}”": "Убрать «{name}»",
"There is a keyword set “{name}” already": "Набор ключевых слов «{name}» уже есть"
}
+4 -1
View File
@@ -1439,5 +1439,8 @@
"Edit Keyword Set": "Изменить набор ключевых слов",
"Keywords (⌥1–⌥9)": "Ключевые слова (⌥1–⌥9)",
"A keyword set needs a name": "Набору ключевых слов нужно название",
"Set name": "Название набора"
"Set name": "Название набора",
"Save as a new set": "Сохранить как новый набор",
"Recent Keywords is built in: choose another name": "«Недавние ключевые слова» — встроенный набор: выберите другое название",
"Recent Keywords": "Недавние ключевые слова"
}
+2 -1
View File
@@ -215,5 +215,6 @@
"There is a “{name}” at the top level already. Merge the two?": "顶层已有“{name}”。要合并两者吗?",
"Added “{keyword}” to {n} photo{}": "已将“{keyword}”添加到 {n} 张照片{:.0}",
"On {have} of {of} selected photos": "所选 {of} 张照片中的 {have} 张",
"Remove “{name}”": "移除“{name}”"
"Remove “{name}”": "移除“{name}”",
"There is a keyword set “{name}” already": "已有关键字集“{name}”"
}
+3 -1
View File
@@ -1764,5 +1764,7 @@
"Edit Set…": "编辑集…",
"Edit Keyword Set": "编辑关键字集",
"Keywords (⌥1–⌥9)": "关键字 (⌥1–⌥9)",
"A keyword set needs a name": "关键字集需要名称"
"A keyword set needs a name": "关键字集需要名称",
"Save as a new set": "另存为新关键字集",
"Recent Keywords is built in: choose another name": "“最近使用的关键字”为内置集:请另选名称"
}
+2 -1
View File
@@ -215,5 +215,6 @@
"There is a “{name}” at the top level already. Merge the two?": "最上層已有「{name}」。要合併兩者嗎?",
"Added “{keyword}” to {n} photo{}": "已將「{keyword}」加入 {n} 張照片{:.0}",
"On {have} of {of} selected photos": "選取的 {of} 張照片中的 {have} 張",
"Remove “{name}”": "移除「{name}」"
"Remove “{name}”": "移除「{name}」",
"There is a keyword set “{name}” already": "已有關鍵字集「{name}」"
}
+3 -1
View File
@@ -1764,5 +1764,7 @@
"Edit Set…": "編輯集…",
"Edit Keyword Set": "編輯關鍵字集",
"Keywords (⌥1–⌥9)": "關鍵字 (⌥1–⌥9)",
"A keyword set needs a name": "關鍵字集需要名稱"
"A keyword set needs a name": "關鍵字集需要名稱",
"Save as a new set": "另存為新關鍵字集",
"Recent Keywords is built in: choose another name": "「最近使用的關鍵字」為內建集:請另選名稱"
}
+24 -6
View File
@@ -773,7 +773,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
crate::widgets::register(ui.ctx(), id, r.rect);
}
}
Dialog::KeywordSet { name, slots, .. } => {
Dialog::KeywordSet { replaces, name, slots, as_new } => {
ui.label(egui::RichText::new(crate::i18n::tr("Set name")).color(t.text_dim));
let r = crate::text_field::TextField::singleline("field:keywordSetName", name)
.hint(crate::i18n::tr("Set name"))
@@ -801,6 +801,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
}
}
});
if replaces.is_some() {
ui.add_space(6.0);
let r = ui.checkbox(as_new, crate::i18n::tr("Save as a new set"));
crate::widgets::register(ui.ctx(), "check:keywordSetAsNew", r.rect);
}
// Return in any field saves the set
if returned {
confirm = true;
@@ -1225,13 +1230,26 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso
}
}
Dialog::DeleteKeyword { keyword, .. } => app.run("keyword.delete", json!({"keyword": keyword})),
Dialog::KeywordSet { replaces, name, slots } => {
if name.trim().is_empty() {
Dialog::KeywordSet { replaces, name, slots, as_new } => {
use lightcraft_catalog::keywords::same;
let name = name.trim();
if name.is_empty() {
return Err(crate::i18n::tr("A keyword set needs a name").to_string());
}
let mut params = json!({"name": name.trim(), "keywords": slots});
if let Some(old) = replaces {
params["replace"] = json!(old);
if same(name, lightcraft_engine::cmd::keywords::RECENT) {
return Err(crate::i18n::tr("Recent Keywords is built in: choose another name").to_string());
}
// the set it renames: none when saving as new, or when it was deleted meanwhile (then it is
// saved anew rather than refused)
let sets = &app.session.keyword_sets;
let renames = replaces.as_deref().filter(|_| !as_new).filter(|old| sets.iter().any(|x| same(&x.name, old)));
if sets.iter().any(|x| same(&x.name, name) && !renames.is_some_and(|old| same(&x.name, old))) {
return Err(crate::i18n::tr_format!("There is a keyword set “{name}” already", name = name));
}
let mut params = json!({"name": name, "keywords": slots});
match renames {
Some(old) => params["replace"] = json!(old),
None => params["new"] = json!(true),
}
app.run("keyword.saveSet", params)
}
@@ -602,6 +602,9 @@ mod tests {
"Edit Set…",
"Edit Keyword Set",
"A keyword set needs a name",
"Recent Keywords",
"Keywords (⌥1–⌥9)",
"Save as a new set",
];
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales");
for code in ["de", "es", "pt-br", "ru", "ja", "zh-hans", "zh-hant"] {
+1
View File
@@ -889,6 +889,7 @@ fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui) {
replaces: named.then(|| current.clone()),
name: if named { current.clone() } else { String::new() },
slots,
as_new: false,
});
}
if ui.button(crate::i18n::tr("Save Current Keywords as Set…")).clicked() {
+3
View File
@@ -603,6 +603,9 @@ pub enum Dialog {
replaces: Option<String>,
name: String,
slots: Vec<String>,
/// Save as a new set, leaving the edited one (Lightroom Classic's Save as New Preset).
#[serde(default)]
as_new: bool,
},
/// Move a keyword inside `parent` (`None`: the top level) where one of its name is already:
/// asks before merging the two.
+50 -1
View File
@@ -49,7 +49,7 @@ fn editing_recent_keywords_saves_them_as_a_set() {
let mut h = keywords_panel();
let recent = sets(&mut h)["keywords"].clone();
open_editor(&mut h);
let Some(crate::state::Dialog::KeywordSet { replaces, name, slots }) = h.app.ui.dialog.clone() else { panic!("{:?}", h.app.ui.dialog) };
let Some(crate::state::Dialog::KeywordSet { replaces, name, slots, .. }) = h.app.ui.dialog.clone() else { panic!("{:?}", h.app.ui.dialog) };
assert_eq!((replaces, name.as_str()), (None, ""));
assert_eq!(json!(slots.iter().filter(|k| !k.is_empty()).collect::<Vec<_>>()), recent, "the current nine");
ask(&mut h, "ui.text", json!({"text": "Beach Weddings"}));
@@ -111,3 +111,52 @@ fn the_slots_are_alike() {
assert!((r.width() - slots[0].width()).abs() < 1.0, "{r:?} vs {:?}", slots[0]);
}
}
/// Naming Recent Keywords like a set that exists says so, in the user's words, and keeps the
/// dialog (and that set) as they were.
#[test]
fn a_taken_name_says_so() {
let mut h = keywords_panel();
h.app.session.execute("keyword.saveSet", &json!({"name": "Travel", "keywords": ["harbour"]})).unwrap();
h.app.session.execute("keyword.useSet", &json!({"name": "Recent Keywords"})).unwrap();
h.settle(SETTLE);
open_editor(&mut h);
ask(&mut h, "ui.text", json!({"text": "travel"}));
ask(&mut h, "ui.clickWidget", json!({"id": "button:dialogOk"}));
assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::KeywordSet { .. })), "still open");
let toast = h.app.ui.toast.clone().map(|t| t.0).unwrap_or_default();
assert!(toast.starts_with("There is a keyword set"), "{toast}");
let s = sets(&mut h);
let travel = s["sets"].as_array().unwrap().iter().find(|x| x["name"] == "Travel").cloned().unwrap();
assert_eq!(travel["keywords"], json!(["harbour"]), "left alone");
}
/// A set deleted while its dialog was open is saved again, as a new set, rather than refused.
#[test]
fn a_set_deleted_meanwhile_is_saved_anew() {
let mut h = keywords_panel();
h.app.session.execute("keyword.saveSet", &json!({"name": "Weddings", "keywords": ["ceremony"]})).unwrap();
h.settle(SETTLE);
open_editor(&mut h);
h.app.session.execute("keyword.deleteSet", &json!({"name": "Weddings"})).unwrap();
ask(&mut h, "ui.clickWidget", json!({"id": "button:dialogOk"}));
assert_eq!(h.app.ui.dialog, None);
assert_eq!(sets(&mut h)["current"], "Weddings");
}
/// Save as a new set (Lightroom Classic's Save as New Preset): the edited set stays, and the new
/// one, under its own name, becomes current.
#[test]
fn save_as_a_new_set_keeps_the_original() {
let mut h = keywords_panel();
h.app.session.execute("keyword.saveSet", &json!({"name": "Weddings", "keywords": ["ceremony"]})).unwrap();
h.settle(SETTLE);
open_editor(&mut h);
ask(&mut h, "ui.text", json!({"text": "Ceremonies"}));
ask(&mut h, "ui.clickWidget", json!({"id": "check:keywordSetAsNew"}));
ask(&mut h, "ui.clickWidget", json!({"id": "button:dialogOk"}));
let s = sets(&mut h);
let names: Vec<&str> = s["sets"].as_array().unwrap().iter().map(|x| x["name"].as_str().unwrap()).collect();
assert_eq!(names, ["Recent Keywords", "Weddings", "Ceremonies"]);
assert_eq!(s["current"], "Ceremonies");
}