From 21cd3b75fd5b39d8e3a2840e2d3fed6b77782276 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:00:13 -0400 Subject: [PATCH 01/36] LRC-LIB-KEYWORDS: a keyword list that holds keywords without photos Keywords existed only as `a|b|c` strings on photos: one couldn't be created before a photo had it, nor a parent made to organize under. The catalog now has a keyword list (`Op::SetKeyword`, format version 4): a keyword listed on its own is in the tree with no photos, its parents too, and carries Lightroom Classic's keyword tag options (`KeywordInfo`: synonyms, include on export, export containing keywords, export synonyms, person), exported by default. Undo restores what was listed, as it was written. Version 3 libraries open and are rewritten as 4. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/journal.rs | 3 +- crates/catalog/src/keywords.rs | 100 ++++++++++++++++++++- crates/catalog/src/lib.rs | 32 +++++++ crates/catalog/src/local.rs | 1 + crates/catalog/src/tests_format_version.rs | 19 +++- crates/catalog/src/tests_journal.rs | 8 ++ 6 files changed, 156 insertions(+), 7 deletions(-) diff --git a/crates/catalog/src/journal.rs b/crates/catalog/src/journal.rs index 5973975d6..796cd0eaf 100644 --- a/crates/catalog/src/journal.rs +++ b/crates/catalog/src/journal.rs @@ -46,6 +46,7 @@ //! | 1 | up to v0.2.0 | | //! | 2 | after v0.2.0 | `Op::SetBrowsed`, `Catalog.browsed`, `Photo.local_baseline` | //! | 3 | albums by hand | `Op::SetAlbumOrder`, `Album.order` | +//! | 4 | the keyword list | `Op::SetKeyword`, `Catalog.keyword_list` | //! //! Rules: //! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a @@ -70,7 +71,7 @@ pub const LOG: &str = "catalog.log"; const FORMAT: &str = "lightcraft-catalog"; /// The catalog format this build writes (and the newest it reads). See the module docs → /// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added. -pub const VERSION: u32 = 3; +pub const VERSION: u32 = 4; /// When [`Journal::wants_snapshot`] says it's time to compact the log. #[derive(Clone, Copy, Debug)] diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index dde020157..9f128e83f 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -5,8 +5,12 @@ //! changes — so they are a single undo step and replay from the op log like any other edit. //! Keyword names compare case-insensitively; renaming a keyword renames its children too //! (`travel|italy` → `trips|italy`). +//! +//! The **keyword list** holds keywords on their own ([`Op::SetKeyword`]): created before any +//! photo has them, or given attributes ([`KeywordInfo`]: synonyms, export options, person). The +//! tree is the keyword list together with the keywords photos carry. -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::{Catalog, CatalogError, Meta, Op, Result}; @@ -36,6 +40,45 @@ fn dedupe(v: &mut Vec) { v.retain(|k| !k.is_empty() && seen.insert(k.to_lowercase())); } +/// What the keyword list knows about a keyword besides the photos that carry it: Lightroom +/// Classic's keyword tag options. A keyword is listed when it was created on its own or given +/// attributes; one that only photos carry has the defaults. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct KeywordInfo { + /// Other words for it, exported with it (when `export_synonyms`) and found by search. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub synonyms: Vec, + /// Written into exported files at all. + #[serde(default = "yes")] + pub include_on_export: bool, + /// The keywords containing it are exported with it (`travel|italy` also gives `travel`). + #[serde(default = "yes")] + pub export_containing: bool, + #[serde(default = "yes")] + pub export_synonyms: bool, + /// The keyword names a person. + #[serde(default)] + pub person: bool, +} + +fn yes() -> bool { + true +} + +impl Default for KeywordInfo { + fn default() -> Self { + KeywordInfo { synonyms: Vec::new(), include_on_export: true, export_containing: true, export_synonyms: true, person: false } + } +} + +/// A keyword in the library's keyword list: its path as written and its attributes. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ListedKeyword { + pub path: String, + pub info: KeywordInfo, +} + /// One node of the keyword tree. #[derive(Clone, Debug, PartialEq, Serialize)] pub struct KeywordNode { @@ -63,9 +106,12 @@ impl Catalog { } let mut levels: HashMap = HashMap::new(); let mut this_photo: HashSet = HashSet::new(); - for p in self.photos().filter(|p| p.in_library()) { + // the keyword list first (no photos counted): a listed keyword is there without photos + let listed: Vec = self.listed_keywords().map(|k| k.path.clone()).collect(); + let photos = self.photos().filter(|p| p.in_library()).map(|p| (true, p.meta.keywords.as_slice())); + for (counts, keywords) in std::iter::once((false, listed.as_slice())).chain(photos) { this_photo.clear(); - for k in &p.meta.keywords { + for k in keywords { let mut path = String::new(); let mut lower = String::new(); let mut parent: Option = None; @@ -82,7 +128,7 @@ impl Catalog { count: 0, parent: parent.clone(), }); - if this_photo.insert(lower.clone()) { + if counts && this_photo.insert(lower.clone()) { l.count += 1; } parent = Some(lower.clone()); @@ -263,6 +309,52 @@ mod tests { assert_eq!(clean(" a | |b "), "a|b"); } + fn tree_paths(nodes: &[KeywordNode]) -> Vec<(String, usize)> { + let mut out = Vec::new(); + for n in nodes { + out.push((n.path.clone(), n.count)); + out.extend(tree_paths(&n.children)); + } + out + } + + /// A keyword can be listed before any photo has it (Lightroom Classic's Create Keyword Tag): + /// it is in the tree with no photos, its parents too, and undo takes it away. + #[test] + fn a_keyword_listed_with_no_photos_is_in_the_tree() { + let (mut c, _) = lib(&[&["travel|France"]]); + let undo = c.apply(Op::SetKeyword { path: " travel | Italy ".into(), info: Some(KeywordInfo::default()) }).unwrap(); + assert_eq!(tree_paths(&c.keyword_tree()), [("travel".to_string(), 1), ("travel|France".to_string(), 1), ("travel|Italy".to_string(), 0)]); + assert_eq!(c.keyword_info("TRAVEL|italy"), Some(&KeywordInfo::default()), "found whatever the case"); + c.apply(undo).unwrap(); + assert_eq!(tree_paths(&c.keyword_tree()), [("travel".to_string(), 1), ("travel|France".to_string(), 1)]); + assert_eq!(c.keyword_info("travel|Italy"), None); + } + + /// A listed keyword needs a name, and its attributes start as Lightroom Classic's do: exported, + /// with the keywords containing it and its synonyms; not a person. + #[test] + fn a_listed_keyword_needs_a_name_and_starts_exported() { + let mut c = Catalog::new(); + assert!(c.apply(Op::SetKeyword { path: " | ".into(), info: Some(KeywordInfo::default()) }).is_err()); + let info = KeywordInfo::default(); + assert!(info.include_on_export && info.export_containing && info.export_synonyms && !info.person && info.synonyms.is_empty()); + } + + /// Changing a listed keyword's attributes is undone to the attributes it had, under the name as + /// it was written. + #[test] + fn changing_a_listed_keyword_is_undone_to_what_it_was() { + let mut c = Catalog::new(); + c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); + let person = KeywordInfo { person: true, synonyms: vec!["Marriage".into()], ..KeywordInfo::default() }; + let undo = c.apply(Op::SetKeyword { path: "weddings".into(), info: Some(person.clone()) }).unwrap(); + assert_eq!(c.keyword_info("Weddings"), Some(&person)); + assert_eq!(undo, Op::SetKeyword { path: "Weddings".into(), info: Some(KeywordInfo::default()) }); + c.apply(undo).unwrap(); + assert_eq!(c.keyword_info("weddings"), Some(&KeywordInfo::default())); + } + #[test] fn suggestions_rank_co_occurrence_and_prefixes() { let (c, _) = lib(&[&["dog", "park"], &["dog", "park", "ball"], &["dog", "beach"], &["cat"], &["cat"], &["cat"], &["parade"], &["eagle"]]); diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 884ec6583..4ddb171e5 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -217,6 +217,12 @@ pub enum Op { folder: String, at: Option, }, + /// List a keyword in the library's keyword list with these attributes, or take it off the list + /// (`None`; photos that carry it keep it). Format version 4. + SetKeyword { + path: String, + info: Option, + }, /// Several ops as one step (undo applies the inverses in reverse). Batch { ops: Vec, @@ -239,6 +245,9 @@ pub struct Catalog { /// When each Local folder was last browsed (folder path → ISO 8601), see [`local`]. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] browsed: BTreeMap, + /// Keywords listed on their own or given attributes, by lower-case path (see [`keywords`]). + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + keyword_list: BTreeMap, /// Increments on every applied op. #[serde(skip)] pub revision: u64, @@ -366,6 +375,14 @@ impl Catalog { pub fn label_name(&self, l: ColorLabel) -> String { self.label_names.get(&l).cloned().unwrap_or_else(|| format!("{l:?}")) } + /// A listed keyword's attributes (any case), `None` when it isn't listed. + pub fn keyword_info(&self, path: &str) -> Option<&keywords::KeywordInfo> { + self.keyword_list.get(&keywords::clean(path).to_lowercase()).map(|k| &k.info) + } + /// The keyword list: keywords listed on their own or given attributes, by path. + pub fn listed_keywords(&self) -> impl Iterator { + self.keyword_list.values() + } /// The custom name of a colour label, if any. pub fn custom_label_name(&self, l: ColorLabel) -> Option<&str> { self.label_names.get(&l).map(String::as_str) @@ -612,6 +629,21 @@ impl Catalog { }; Op::SetLabelName { label, name: old } } + Op::SetKeyword { path, info } => { + let path = keywords::clean(&path); + if path.is_empty() { + return Err(CatalogError::Invalid("keyword names can't be empty".into())); + } + let key = path.to_lowercase(); + let old = match info { + Some(info) => self.keyword_list.insert(key, keywords::ListedKeyword { path: path.clone(), info }), + None => self.keyword_list.remove(&key), + }; + match old { + Some(old) => Op::SetKeyword { path: old.path, info: Some(old.info) }, + None => Op::SetKeyword { path, info: None }, + } + } Op::SetBrowsed { folder, at } => { let folder = crate::query::folder_key(&folder); let old = match at { diff --git a/crates/catalog/src/local.rs b/crates/catalog/src/local.rs index 4291f45fa..054179519 100644 --- a/crates/catalog/src/local.rs +++ b/crates/catalog/src/local.rs @@ -299,6 +299,7 @@ impl Op { | Op::SetAlbumRules { .. } | Op::RemoveStack { .. } | Op::SetLabelName { .. } + | Op::SetKeyword { .. } | Op::SetBrowsed { .. } => {} } } diff --git a/crates/catalog/src/tests_format_version.rs b/crates/catalog/src/tests_format_version.rs index cb80ce67e..9d4b323a6 100644 --- a/crates/catalog/src/tests_format_version.rs +++ b/crates/catalog/src/tests_format_version.rs @@ -57,7 +57,21 @@ fn v2_library_loads_and_is_upgraded() { let (_, c, r) = Journal::open(Box::new(m.clone())).unwrap(); assert_eq!(c.to_snapshot(), full.to_snapshot()); assert_eq!((r.replayed, r.upgraded_from), (1, Some(2))); - assert_eq!(snapshot_version(&m), 3); + assert_eq!(snapshot_version(&m), u64::from(VERSION)); +} + +/// A library saved before keywords could be listed on their own opens as it was and is rewritten +/// as v4. +#[test] +fn v3_library_loads_and_is_upgraded() { + let (base, log, full) = legacy_parts(); + let m = MemStore::new(); + m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":3,\"seq\":2,\"catalog\":{}}}\n", base.to_snapshot()).into_bytes()); + m.set(LOG, log.into_bytes()); + let (_, c, r) = Journal::open(Box::new(m.clone())).unwrap(); + assert_eq!(c.to_snapshot(), full.to_snapshot()); + assert_eq!((r.replayed, r.upgraded_from), (1, Some(3))); + assert_eq!(snapshot_version(&m), 4); } #[test] @@ -193,8 +207,9 @@ fn op_variants_are_versioned() { | Op::Batch { .. } => 1, Op::SetBrowsed { .. } => 2, Op::SetAlbumOrder { .. } => 3, + Op::SetKeyword { .. } => 4, } } - let newest = since(&Op::SetAlbumOrder { id: AlbumId(0), order: None }); + let newest = since(&Op::SetKeyword { path: "travel".into(), info: None }); assert_eq!(newest, VERSION, "the newest op's version must be the current format version"); } diff --git a/crates/catalog/src/tests_journal.rs b/crates/catalog/src/tests_journal.rs index 0594b6d83..3bcb6526b 100644 --- a/crates/catalog/src/tests_journal.rs +++ b/crates/catalog/src/tests_journal.rs @@ -78,6 +78,14 @@ fn op_for(c: &mut Catalog, kind: u8, a: u8, b: u8) -> Op { 13 => Op::Batch { ops: c.remove_from_stacks_ops(&[pid]) }, 14 => c.set_top_ops(pid).unwrap_or(Op::Batch { ops: vec![] }), 9 if b.is_multiple_of(5) => Op::SetLabelName { label: ColorLabel::ALL[a as usize % 5], name: (a % 3 != 1).then(|| format!("Label {b}")) }, + 9 if b % 7 == 5 => Op::SetKeyword { + path: format!("travel|place {}", b % 4), + info: (!b.is_multiple_of(3)).then(|| crate::keywords::KeywordInfo { + person: a.is_multiple_of(2), + synonyms: vec![format!("trip {a}")], + ..Default::default() + }), + }, 9 if b % 7 == 3 => Op::SetAlbumOrder { id: aid, order: (!b.is_multiple_of(3)).then_some(u32::from(b % 5)) }, 9 if b.is_multiple_of(2) => { Op::SetFile { id: pid, file_name: format!("Renamed_{b}.jpg"), source: Source::File { path: format!("/p/Renamed_{b}.jpg") } } From 411daefa6836daba39bc8d03b3969884214a585e Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:02:27 -0400 Subject: [PATCH 02/36] LRC-LIB-KEYWORDS: rename, merge and delete carry the keyword list along Renaming or moving a keyword takes its listing and its listed children along, photos or not; a keyword only the list holds can be renamed too. Merging keeps the target's attributes and gathers the synonyms (a target not listed yet takes the merged keyword's attributes). Deleting takes the keyword and those below it off the list. Each is still one undo step: the list's changes join the photos' in the batch. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/keywords.rs | 135 +++++++++++++++++++++++++++++++-- 1 file changed, 129 insertions(+), 6 deletions(-) diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index 9f128e83f..432821921 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -10,6 +10,8 @@ //! photo has them, or given attributes ([`KeywordInfo`]: synonyms, export options, person). The //! tree is the keyword list together with the keywords photos carry. +use std::collections::BTreeMap; + use serde::{Deserialize, Serialize}; use crate::{Catalog, CatalogError, Meta, Op, Result}; @@ -164,8 +166,47 @@ impl Catalog { .collect() } - /// Rename `from` (and the keywords below it) to `to` on every photo — one batch. Renaming onto - /// an existing keyword merges the two. + /// `SetKeyword` ops turning the keyword list into what `f` makes of it (lower-case path → + /// listing): what it no longer has is taken off first, then what is new or changed is set. + fn list_ops(&self, f: impl FnOnce(&mut BTreeMap)) -> Vec { + let before = &self.keyword_list; + let mut after = before.clone(); + f(&mut after); + let gone = before.iter().filter(|(k, _)| !after.contains_key(*k)).map(|(_, l)| Op::SetKeyword { path: l.path.clone(), info: None }); + let set = after + .iter() + .filter(|(k, l)| before.get(*k) != Some(*l)) + .map(|(_, l)| Op::SetKeyword { path: l.path.clone(), info: Some(l.info.clone()) }); + gone.chain(set).collect() + } + + /// The keyword list once the keywords under each of `from` move to `to`: a listing landing on + /// one already there merges into it (that one's attributes stay, the synonyms gather). + fn list_move_ops(&self, from: &[String], to: &str) -> Vec { + self.list_ops(|list| { + let moving: Vec = list.iter().filter(|(_, l)| from.iter().any(|f| is_under(&l.path, f))).map(|(k, _)| k.clone()).collect(); + for key in moving { + let Some(l) = list.remove(&key) else { continue }; + let Some(f) = from.iter().find(|f| is_under(&l.path, f)) else { continue }; + let path = reparent(&l.path, f, to); + match list.get_mut(&path.to_lowercase()) { + Some(there) => { + for s in l.info.synonyms { + if !there.info.synonyms.iter().any(|x| x.eq_ignore_ascii_case(&s)) { + there.info.synonyms.push(s); + } + } + } + None => { + list.insert(path.to_lowercase(), ListedKeyword { path, info: l.info }); + } + } + } + }) + } + + /// Rename `from` (and the keywords below it) to `to` on every photo and in the keyword list — + /// one batch. Renaming onto an existing keyword merges the two. pub fn rename_keyword_ops(&self, from: &str, to: &str) -> Result { let (from, to) = (clean(from), clean(to)); if from.is_empty() || to.is_empty() { @@ -174,17 +215,20 @@ impl Catalog { if is_under(&to, &from) && !to.eq_ignore_ascii_case(&from) { return Err(CatalogError::Invalid("can't move a keyword below itself".into())); } - let ops = self.keyword_ops(|kws| kws.iter().map(|k| if is_under(k, &from) { reparent(k, &from, &to) } else { k.clone() }).collect()); + let mut ops = self.keyword_ops(|kws| kws.iter().map(|k| if is_under(k, &from) { reparent(k, &from, &to) } else { k.clone() }).collect()); + ops.extend(self.list_move_ops(std::slice::from_ref(&from), &to)); Ok(Op::Batch { ops }) } - /// Remove `keyword` and the keywords below it from every photo. + /// Remove `keyword` and the keywords below it from every photo and from the keyword list. pub fn delete_keyword_ops(&self, keyword: &str) -> Result { let k = clean(keyword); if k.is_empty() { return Err(CatalogError::Invalid("empty keyword".into())); } - Ok(Op::Batch { ops: self.keyword_ops(|kws| kws.iter().filter(|x| !is_under(x, &k)).cloned().collect()) }) + let mut ops = self.keyword_ops(|kws| kws.iter().filter(|x| !is_under(x, &k)).cloned().collect()); + ops.extend(self.list_ops(|list| list.retain(|_, l| !is_under(&l.path, &k)))); + Ok(Op::Batch { ops }) } /// Merge several keywords (with their children) into `into`. @@ -197,9 +241,10 @@ impl Catalog { if from.iter().any(|f| is_under(&into, f)) { return Err(CatalogError::Invalid("can't merge a keyword into one below it".into())); } - let ops = self.keyword_ops(|kws| { + let mut ops = self.keyword_ops(|kws| { kws.iter().map(|k| from.iter().find(|f| is_under(k, f)).map(|f| reparent(k, f, &into)).unwrap_or_else(|| k.clone())).collect() }); + ops.extend(self.list_move_ops(&from, &into)); Ok(Op::Batch { ops }) } @@ -355,6 +400,84 @@ mod tests { assert_eq!(c.keyword_info("weddings"), Some(&KeywordInfo::default())); } + fn listed(c: &Catalog) -> Vec<(String, KeywordInfo)> { + let mut v: Vec<(String, KeywordInfo)> = c.listed_keywords().map(|k| (k.path.clone(), k.info.clone())).collect(); + v.sort_by_key(|(p, _)| p.to_lowercase()); + v + } + + fn with_synonyms(words: &[&str]) -> KeywordInfo { + KeywordInfo { synonyms: words.iter().map(|w| w.to_string()).collect(), ..KeywordInfo::default() } + } + + /// Renaming (or moving) a keyword takes its attributes and its listed children along, photos + /// or not, in the same undo step. + #[test] + fn renaming_a_keyword_carries_its_listing_along() { + let (mut c, ids) = lib(&[&["travel|italy"]]); + c.apply(Op::SetKeyword { path: "travel".into(), info: Some(with_synonyms(&["trip"])) }).unwrap(); + c.apply(Op::SetKeyword { path: "travel|Spain".into(), info: Some(KeywordInfo::default()) }).unwrap(); + let before = c.to_snapshot(); + let op = c.rename_keyword_ops("Travel", "Places|Europe").unwrap(); + let undo = c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["Places|Europe|italy"]); + assert_eq!( + listed(&c), + [("Places|Europe".to_string(), with_synonyms(&["trip"])), ("Places|Europe|Spain".to_string(), KeywordInfo::default())] + ); + c.apply(undo).unwrap(); + assert_eq!(c.to_snapshot(), before); + } + + /// A keyword only the list holds (no photos) can be renamed too. + #[test] + fn a_keyword_without_photos_can_be_renamed() { + let mut c = Catalog::new(); + c.apply(Op::SetKeyword { path: "weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); + let op = c.rename_keyword_ops("weddings", "Events|Weddings").unwrap(); + c.apply(op).unwrap(); + assert_eq!(listed(&c), [("Events|Weddings".to_string(), KeywordInfo::default())]); + } + + /// Merging keeps the target's attributes and adds the merged keyword's synonyms to them; a target + /// not listed yet takes the merged keyword's attributes. + #[test] + fn merging_keeps_the_targets_attributes_and_gathers_synonyms() { + let (mut c, _) = lib(&[&["holiday"], &["travel"]]); + let person = KeywordInfo { person: true, ..with_synonyms(&["trip"]) }; + c.apply(Op::SetKeyword { path: "travel".into(), info: Some(person.clone()) }).unwrap(); + c.apply(Op::SetKeyword { + path: "holiday".into(), + info: Some(KeywordInfo { include_on_export: false, ..with_synonyms(&["vacation", "Trip"]) }), + }) + .unwrap(); + let op = c.merge_keywords_ops(&["holiday".into()], "travel").unwrap(); + c.apply(op).unwrap(); + assert_eq!(listed(&c), [("travel".to_string(), KeywordInfo { person: true, ..with_synonyms(&["trip", "vacation"]) })]); + // onto a keyword that isn't listed: it takes the merged one's attributes + let mut c = Catalog::new(); + c.apply(Op::SetKeyword { path: "holiday".into(), info: Some(with_synonyms(&["vacation"])) }).unwrap(); + let op = c.merge_keywords_ops(&["holiday".into()], "Travel").unwrap(); + c.apply(op).unwrap(); + assert_eq!(listed(&c), [("Travel".to_string(), with_synonyms(&["vacation"]))]); + } + + /// Deleting a keyword takes it, and the keywords below it, off the list as well as off photos. + #[test] + fn deleting_a_keyword_takes_it_off_the_list() { + let (mut c, _) = lib(&[&["travel|italy"], &["beach"]]); + for path in ["travel", "travel|spain", "beach"] { + c.apply(Op::SetKeyword { path: path.into(), info: Some(KeywordInfo::default()) }).unwrap(); + } + let before = c.to_snapshot(); + let op = c.delete_keyword_ops("travel").unwrap(); + let undo = c.apply(op).unwrap(); + assert_eq!(listed(&c), [("beach".to_string(), KeywordInfo::default())]); + assert_eq!(tree_paths(&c.keyword_tree()), [("beach".to_string(), 1)]); + c.apply(undo).unwrap(); + assert_eq!(c.to_snapshot(), before); + } + #[test] fn suggestions_rank_co_occurrence_and_prefixes() { let (c, _) = lib(&[&["dog", "park"], &["dog", "park", "ball"], &["dog", "beach"], &["cat"], &["cat"], &["cat"], &["parade"], &["eagle"]]); From cd8dfa72c262e44568c4b76f164be6f0784f950d Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:05:25 -0400 Subject: [PATCH 03/36] LRC-LIB-KEYWORDS: renaming keeps the levels below whatever the case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renaming a keyword cut the keywords below it at the byte length of the name as typed. Names match whatever their case, but some letters change length with it (ẞ is three bytes, ß two): `STRAẞE` → `Road` turned `straße|nord` into `Roadnord`, and a cut inside a letter would panic. The levels below are now kept whole. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/keywords.rs | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index 432821921..2b4a878c2 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -30,10 +30,12 @@ pub fn is_under(k: &str, parent: &str) -> bool { k == p || k.strip_prefix(&p).is_some_and(|rest| rest.starts_with(SEP)) } -/// Replace the `from` prefix of `k` (which [`is_under`] `from`) with `to`. +/// Replace the `from` prefix of `k` (which [`is_under`] `from`) with `to`. By levels, not bytes: +/// `from` may be written in another case, which can change a letter's length (ẞ / ß). fn reparent(k: &str, from: &str, to: &str) -> String { - let rest = &k[from.len().min(k.len())..]; - if to.is_empty() { rest.trim_start_matches(SEP).to_string() } else { format!("{to}{rest}") } + let skip = from.split(SEP).filter(|s| !s.trim().is_empty()).count(); + let rest: Vec<&str> = k.split(SEP).filter(|s| !s.trim().is_empty()).skip(skip).collect(); + std::iter::once(to).filter(|t| !t.is_empty()).chain(rest).collect::>().join("|") } /// Keep the first of case-insensitively equal keywords. @@ -478,6 +480,19 @@ mod tests { assert_eq!(c.to_snapshot(), before); } + /// Renaming matches names whatever their case, also where a letter's case changes its length + /// (ẞ is three bytes, ß two): the levels below are kept whole, never cut mid-letter. + #[test] + fn renaming_keeps_the_levels_below_whatever_the_case() { + let (mut c, ids) = lib(&[&["straße|nord"], &["ßa|ü"]]); + let op = c.rename_keyword_ops("STRAẞE", "Road").unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["Road|nord"]); + let op = c.rename_keyword_ops("ẞA", "Weg").unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[1]), ["Weg|ü"]); + } + #[test] fn suggestions_rank_co_occurrence_and_prefixes() { let (c, _) = lib(&[&["dog", "park"], &["dog", "park", "ball"], &["dog", "beach"], &["cat"], &["cat"], &["cat"], &["parade"], &["eagle"]]); From fba3bdef2c212565a464d8ee10980899e898ae9b Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:05:38 -0400 Subject: [PATCH 04/36] LRC-LIB-KEYWORDS: create, move, edit and purge keywords The keyword list's own actions, each one undo step: - create a keyword with its attributes, and tag photos with it at once; one that exists already (listed or on a photo) is refused; - move a keyword (with those below it) inside another or to the top level; never inside itself or its children, and onto a name that is taken only when merging is allowed (`CatalogError::KeywordExists` otherwise, so the UI can ask); - edit: rename its last level and set its attributes together; a taken name is refused, merging being its own action; - purge: take off the list the keywords no photo has. Typed in any case, keywords keep the spelling the library has (`keyword_path`). Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/keywords.rs | 189 +++++++++++++++++++++++++++++++++ crates/catalog/src/lib.rs | 3 + 2 files changed, 192 insertions(+) diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index 2b4a878c2..9bd67700d 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -250,6 +250,106 @@ impl Catalog { Ok(Op::Batch { ops }) } + /// The keyword is in the tree: listed, or on a photo (itself or one below it). + pub fn has_keyword(&self, path: &str) -> bool { + let k = clean(path); + !k.is_empty() && (self.keyword_info(&k).is_some() || self.photos().any(|p| p.meta.keywords.iter().any(|x| is_under(x, &k)))) + } + + /// The keyword as written in the library (whatever the case of `path`): its listing, or the + /// levels a photo's keyword spells it with. `None` when there is no such keyword. + pub fn keyword_path(&self, path: &str) -> Option { + let k = clean(path); + if k.is_empty() { + return None; + } + if let Some(l) = self.keyword_list.get(&k.to_lowercase()) { + return Some(l.path.clone()); + } + let levels = k.split(SEP).count(); + self.photos() + .flat_map(|p| p.meta.keywords.iter()) + .find(|x| is_under(x, &k)) + .map(|x| x.split(SEP).map(str::trim).filter(|s| !s.is_empty()).take(levels).collect::>().join("|")) + } + + /// Create a keyword with these attributes, and give it to `photos` — one batch. A keyword that + /// exists already is refused. + pub fn create_keyword_ops(&self, path: &str, info: KeywordInfo, photos: &[crate::PhotoId]) -> Result { + let path = clean(path); + if path.is_empty() { + return Err(CatalogError::Invalid("keyword names can't be empty".into())); + } + if self.has_keyword(&path) { + return Err(CatalogError::KeywordExists(path)); + } + let mut ops = vec![Op::SetKeyword { path: path.clone(), info: Some(info) }]; + for id in photos { + let p = self.photo(*id).ok_or(CatalogError::NoPhoto(*id))?; + let mut meta = p.meta.clone(); + meta.keywords.push(path.clone()); + dedupe(&mut meta.keywords); + ops.push(Op::SetMeta { id: *id, meta: Box::new(meta) }); + } + Ok(Op::Batch { ops }) + } + + /// Move `keyword` (with the keywords below it) inside `parent`, or to the top level (`None`). + /// It keeps its name. Onto a keyword that is there already the two merge, which only happens + /// with `merge`; otherwise [`CatalogError::KeywordExists`]. + pub fn move_keyword_ops(&self, keyword: &str, parent: Option<&str>, merge: bool) -> Result { + let Some(from) = self.keyword_path(keyword) else { + return Err(CatalogError::Invalid(format!("no keyword “{}”", clean(keyword)))); + }; + let leaf = from.rsplit(SEP).next().unwrap_or(&from); + let to = match parent.map(clean).filter(|p| !p.is_empty()) { + Some(p) if is_under(&p, &from) => return Err(CatalogError::Invalid("can't move a keyword inside itself".into())), + Some(p) => format!("{}{SEP}{leaf}", self.keyword_path(&p).unwrap_or(p)), + None => leaf.to_string(), + }; + if to.eq_ignore_ascii_case(&from) { + return Ok(Op::Batch { ops: vec![] }); + } + if !merge && self.has_keyword(&to) { + return Err(CatalogError::KeywordExists(to)); + } + self.rename_keyword_ops(&from, &to) + } + + /// Rename `keyword`'s last level to `name` (it stays where it is) and set its attributes — one + /// batch. A name that is taken is refused ([`CatalogError::KeywordExists`]): merging is its own + /// action. + pub fn edit_keyword_ops(&self, keyword: &str, name: &str, info: KeywordInfo) -> Result { + let Some(from) = self.keyword_path(keyword) else { + return Err(CatalogError::Invalid(format!("no keyword “{}”", clean(keyword)))); + }; + let name = name.trim(); + if name.is_empty() || name.contains(SEP) { + return Err(CatalogError::Invalid("a keyword's name is one level, without “|”".into())); + } + let to = match from.rsplit_once(SEP) { + Some((parent, _)) => format!("{parent}{SEP}{name}"), + None => name.to_string(), + }; + let mut ops = Vec::new(); + if to != from { + if !to.eq_ignore_ascii_case(&from) && self.has_keyword(&to) { + return Err(CatalogError::KeywordExists(to)); + } + if let Op::Batch { ops: renamed } = self.rename_keyword_ops(&from, &to)? { + ops = renamed; + } + } + ops.push(Op::SetKeyword { path: to, info: Some(info) }); + Ok(Op::Batch { ops }) + } + + /// Take off the keyword list the keywords no photo has, nor any keyword below them — one batch. + pub fn purge_unused_keywords_ops(&self) -> Op { + let used: Vec<&String> = self.photos().flat_map(|p| p.meta.keywords.iter()).collect(); + Op::Batch { ops: self.list_ops(|list| list.retain(|_, l| used.iter().any(|k| is_under(k, &l.path)))) } + } + /// Keyword suggestions for a photo that has `current` keywords: with a typed `prefix`, the /// library's keywords containing it (those starting with it first); otherwise keywords that /// appear together with `current` on other photos, then the most used ones. Most frequent @@ -480,6 +580,95 @@ mod tests { assert_eq!(c.to_snapshot(), before); } + /// Creating a keyword lists it with its attributes, and can tag photos with it in the same undo + /// step. A keyword that exists already (listed, or on a photo) isn't created again. + #[test] + fn creating_a_keyword_lists_it_and_can_tag_photos() { + let (mut c, ids) = lib(&[&["beach"], &[]]); + let before = c.to_snapshot(); + let op = c.create_keyword_ops("Events | Weddings", with_synonyms(&["marriage"]), &[ids[1]]).unwrap(); + let undo = c.apply(op).unwrap(); + assert_eq!(listed(&c), [("Events|Weddings".to_string(), with_synonyms(&["marriage"]))]); + assert_eq!(kws(&c, ids[1]), ["Events|Weddings"]); + c.apply(undo).unwrap(); + assert_eq!(c.to_snapshot(), before); + for exists in ["BEACH", "events|weddings"] { + if exists == "events|weddings" { + let op = c.create_keyword_ops("Events|Weddings", KeywordInfo::default(), &[]).unwrap(); + c.apply(op).unwrap(); + } + assert!(c.create_keyword_ops(exists, KeywordInfo::default(), &[]).is_err(), "{exists}"); + } + assert!(c.create_keyword_ops(" | ", KeywordInfo::default(), &[]).is_err()); + } + + /// Moving a keyword nests it inside another (or takes it to the top level), with its children, + /// on photos and in the list. It can't go inside itself or one of its children, and moving it + /// where it already is changes nothing. + #[test] + fn moving_a_keyword_nests_it_inside_another() { + let (mut c, ids) = lib(&[&["Italy|Rome"], &["Europe"]]); + // typed in any case, the keywords keep the spelling the library has + let op = c.move_keyword_ops("italy", Some("EUROPE"), false).unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["Europe|Italy|Rome"]); + let op = c.move_keyword_ops("europe|italy", None, false).unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["Italy|Rome"], "back at the top level"); + assert!(c.move_keyword_ops("italy", Some("italy|rome"), false).is_err(), "inside its own child"); + assert!(c.move_keyword_ops("italy", Some("Italy"), false).is_err(), "inside itself"); + assert_eq!(c.move_keyword_ops("italy|rome", Some("italy"), false).unwrap(), Op::Batch { ops: vec![] }, "already there"); + assert!(c.move_keyword_ops("lisbon", Some("europe"), false).is_err(), "no such keyword"); + } + + /// Moving onto a name that is taken (Europe already has a Rome) merges the two, so the move + /// asks first: refused unless merging is allowed. + #[test] + fn moving_onto_a_taken_name_merges_only_when_allowed() { + let (mut c, ids) = lib(&[&["rome"], &["europe|rome"]]); + let err = c.move_keyword_ops("rome", Some("europe"), false).unwrap_err(); + assert!(matches!(err, CatalogError::KeywordExists(ref k) if k == "europe|rome"), "{err:?}"); + let op = c.move_keyword_ops("rome", Some("europe"), true).unwrap(); + c.apply(op).unwrap(); + assert_eq!((kws(&c, ids[0]), kws(&c, ids[1])), (vec!["europe|rome".to_string()], vec!["europe|rome".to_string()])); + } + + /// Editing a keyword renames it (its last level: it stays where it is) and sets its attributes, + /// in one undo step. A name that is taken is refused: merging is its own action. + #[test] + fn editing_a_keyword_renames_it_and_sets_its_attributes() { + let (mut c, ids) = lib(&[&["travel|italy"], &["travel|spain"]]); + let before = c.to_snapshot(); + let person = KeywordInfo { person: true, ..KeywordInfo::default() }; + let op = c.edit_keyword_ops("travel|italy", "Italia", person.clone()).unwrap(); + let undo = c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["travel|Italia"]); + assert_eq!(c.keyword_info("travel|italia"), Some(&person)); + c.apply(undo).unwrap(); + assert_eq!(c.to_snapshot(), before); + // attributes alone + let op = c.edit_keyword_ops("travel", "travel", with_synonyms(&["trip"])).unwrap(); + c.apply(op).unwrap(); + assert_eq!(listed(&c), [("travel".to_string(), with_synonyms(&["trip"]))]); + assert!(matches!(c.edit_keyword_ops("travel|italy", "Spain", KeywordInfo::default()), Err(CatalogError::KeywordExists(_)))); + assert!(c.edit_keyword_ops("travel|italy", "a|b", KeywordInfo::default()).is_err(), "a name, not a path"); + assert!(c.edit_keyword_ops("travel|italy", " ", KeywordInfo::default()).is_err()); + } + + /// Purging takes off the list the keywords no photo has (nor any keyword below them), in one + /// undo step; keywords photos carry stay. + #[test] + fn purging_takes_unused_keywords_off_the_list() { + let (mut c, _) = lib(&[&["travel|italy"]]); + for path in ["travel", "travel|italy", "travel|spain", "weddings"] { + c.apply(Op::SetKeyword { path: path.into(), info: Some(KeywordInfo::default()) }).unwrap(); + } + let op = c.purge_unused_keywords_ops(); + c.apply(op).unwrap(); + assert_eq!(listed(&c).iter().map(|(p, _)| p.as_str()).collect::>(), ["travel", "travel|italy"]); + assert_eq!(tree_paths(&c.keyword_tree()), [("travel".to_string(), 1), ("travel|italy".to_string(), 1)]); + } + /// Renaming matches names whatever their case, also where a letter's case changes its length /// (ẞ is three bytes, ß two): the levels below are kept whole, never cut mid-letter. #[test] diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 4ddb171e5..a9e6acab7 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -52,6 +52,9 @@ pub enum CatalogError { NoStack(StackId), #[error("invalid: {0}")] Invalid(String), + /// A keyword of that name is there already: moving or renaming onto it would merge the two. + #[error("there is a keyword “{0}” already")] + KeywordExists(String), #[error("corrupt catalog data: {0}")] Corrupt(String), #[error("catalog storage: {0}")] From 7029c8fbbdeedeaf9ebaa2e4b7b9b3202f0d948d Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:06:28 -0400 Subject: [PATCH 05/36] LRC-LIB-KEYWORDS: the keywords an exported file carries `Catalog::export_keywords` applies Lightroom Classic's keyword tag options: a keyword left out of export goes nowhere; otherwise its name and the keywords containing it that are exported themselves (unless "export containing keywords" is off) and its synonyms (unless "export synonyms" is off) go out flat, for `dc:subject`, and its full path for `lr:hierarchicalSubject`. Each name once, whatever its case. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/keywords.rs | 97 ++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index 9bd67700d..d351941d6 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -83,6 +83,16 @@ pub struct ListedKeyword { pub info: KeywordInfo, } +/// A photo's keywords as an exported file carries them. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ExportKeywords { + /// Names, flat (`dc:subject`): each keyword's own, the keywords containing it and synonyms, as + /// their options say. + pub flat: Vec, + /// Full paths (`lr:hierarchicalSubject`). + pub hierarchical: Vec, +} + /// One node of the keyword tree. #[derive(Clone, Debug, PartialEq, Serialize)] pub struct KeywordNode { @@ -250,6 +260,52 @@ impl Catalog { Ok(Op::Batch { ops }) } + /// What exported files carry for a photo with these keywords (Lightroom Classic's keyword tag + /// options, [`KeywordInfo`]): a keyword left out of export goes nowhere; otherwise its name + /// and full path, the keywords containing it that are exported themselves (unless "export + /// containing keywords" is off) and synonyms (unless "export synonyms" is off). Each name once, + /// whatever its case. + pub fn export_keywords(&self, keywords: &[String]) -> ExportKeywords { + let defaults = KeywordInfo::default(); + let info = |path: &str| self.keyword_info(path).unwrap_or(&defaults); + let mut out = ExportKeywords::default(); + let push = |v: &mut Vec, s: &str| { + if !s.is_empty() && !v.iter().any(|x| x.eq_ignore_ascii_case(s)) { + v.push(s.to_string()); + } + }; + for k in keywords { + let path = clean(k); + let me = info(&path); + if path.is_empty() || !me.include_on_export { + continue; + } + let levels: Vec<&str> = path.split(SEP).collect(); + let mut names = |at: usize, i: &KeywordInfo| { + if let Some(name) = levels.get(at) { + push(&mut out.flat, name); + } + if i.export_synonyms { + for s in &i.synonyms { + push(&mut out.flat, s.trim()); + } + } + }; + names(levels.len().saturating_sub(1), me); + if me.export_containing { + for at in (0..levels.len().saturating_sub(1)).rev() { + let parent = levels.get(..=at).map(|l| l.join("|")).unwrap_or_default(); + let pi = info(&parent); + if pi.include_on_export { + names(at, pi); + } + } + } + push(&mut out.hierarchical, &path); + } + out + } + /// The keyword is in the tree: listed, or on a photo (itself or one below it). pub fn has_keyword(&self, path: &str) -> bool { let k = clean(path); @@ -682,6 +738,47 @@ mod tests { assert_eq!(kws(&c, ids[1]), ["Weg|ü"]); } + fn exported(c: &Catalog, keywords: &[&str]) -> (Vec, Vec) { + let e = c.export_keywords(&keywords.iter().map(|k| k.to_string()).collect::>()); + (e.flat, e.hierarchical) + } + + /// By default a keyword is exported as its name together with the keywords containing it (flat, + /// `dc:subject`), and as its full path (`lr:hierarchicalSubject`); each name once. + #[test] + fn a_keyword_exports_its_name_its_parents_and_its_path() { + let c = Catalog::new(); + let (flat, paths) = exported(&c, &["travel|Italy|Rome", "Travel|Spain", "beach"]); + assert_eq!(flat, ["Rome", "Italy", "travel", "Spain", "beach"]); + assert_eq!(paths, ["travel|Italy|Rome", "Travel|Spain", "beach"]); + } + + /// A keyword not included on export is left out, path and all; a parent left out (a keyword that + /// only organizes others) isn't exported as a containing keyword, but its children are. + #[test] + fn keywords_left_out_of_export_stay_out() { + let mut c = Catalog::new(); + let out = KeywordInfo { include_on_export: false, ..KeywordInfo::default() }; + c.apply(Op::SetKeyword { path: "Places".into(), info: Some(out.clone()) }).unwrap(); + c.apply(Op::SetKeyword { path: "draft".into(), info: Some(out) }).unwrap(); + let (flat, paths) = exported(&c, &["Places|Lisbon", "draft"]); + assert_eq!(flat, ["Lisbon"]); + assert_eq!(paths, ["Places|Lisbon"]); + } + + /// Without "export containing keywords" only the keyword's own name goes out flat; synonyms go + /// out with it unless "export synonyms" is off. + #[test] + fn containing_keywords_and_synonyms_follow_their_options() { + let mut c = Catalog::new(); + let alone = KeywordInfo { export_containing: false, ..with_synonyms(&["Lisboa"]) }; + c.apply(Op::SetKeyword { path: "Places|Lisbon".into(), info: Some(alone) }).unwrap(); + let quiet = KeywordInfo { export_synonyms: false, ..with_synonyms(&["marriage"]) }; + c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(quiet) }).unwrap(); + let (flat, _) = exported(&c, &["Places|Lisbon", "Events|Weddings"]); + assert_eq!(flat, ["Lisbon", "Lisboa", "Weddings", "Events"]); + } + #[test] fn suggestions_rank_co_occurrence_and_prefixes() { let (c, _) = lib(&[&["dog", "park"], &["dog", "park", "ball"], &["dog", "beach"], &["cat"], &["cat"], &["cat"], &["parade"], &["eagle"]]); From cdca2b9cf835816babc1b05938b3b2272c5f53ac Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:08:37 -0400 Subject: [PATCH 06/36] LRC-LIB-KEYWORDS: the keyword filter follows a rename without panicking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a keyword was renamed or merged, the keyword filter followed it by cutting the old name off by bytes: names match whatever their case, and where a letter's case changes its length (ẞ / ß) the cut could land inside a letter and panic (`ẞẞA` → `Road` with the filter on `ßßa|é`). It now uses the catalog's level-based `reparent`. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/keywords.rs | 2 +- crates/engine/src/cmd/keywords.rs | 8 +++----- crates/engine/src/lib.rs | 2 ++ crates/engine/src/tests_keyword_list.rs | 21 +++++++++++++++++++++ 4 files changed, 27 insertions(+), 6 deletions(-) create mode 100644 crates/engine/src/tests_keyword_list.rs diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index d351941d6..debdec9d8 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -32,7 +32,7 @@ pub fn is_under(k: &str, parent: &str) -> bool { /// Replace the `from` prefix of `k` (which [`is_under`] `from`) with `to`. By levels, not bytes: /// `from` may be written in another case, which can change a letter's length (ẞ / ß). -fn reparent(k: &str, from: &str, to: &str) -> String { +pub fn reparent(k: &str, from: &str, to: &str) -> String { let skip = from.split(SEP).filter(|s| !s.trim().is_empty()).count(); let rest: Vec<&str> = k.split(SEP).filter(|s| !s.trim().is_empty()).skip(skip).collect(); std::iter::once(to).filter(|t| !t.is_empty()).chain(rest).collect::>().join("|") diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index 876cf485e..8c21d6579 100644 --- a/crates/engine/src/cmd/keywords.rs +++ b/crates/engine/src/cmd/keywords.rs @@ -1,7 +1,7 @@ //! Library-wide keyword commands: list (tree with counts), suggestions, rename, delete, merge; //! keyword sets (nine keywords a keystroke away: ⌥1–⌥9) and Recent Keywords. -use lightcraft_catalog::keywords::{clean, is_under}; +use lightcraft_catalog::keywords::{clean, is_under, reparent}; use serde_json::{Value, json}; use super::{CommandSpec, always, bad, cmd, str_param}; @@ -197,9 +197,7 @@ pub fn specs() -> Vec { let to = str_param(p, "to").ok_or_else(|| bad("keyword.rename", "missing `to`"))?.to_string(); let op = s.catalog.rename_keyword_ops(&from, &to).map_err(|e| bad("keyword.rename", e.to_string()))?; let (f, t) = (clean(&from), clean(&to)); - commit_keywords(s, "Rename Keyword", op, |k| { - Some(if is_under(k, &f) { format!("{t}{}", &k[f.len().min(k.len())..]) } else { k.to_string() }) - }) + commit_keywords(s, "Rename Keyword", op, |k| Some(if is_under(k, &f) { reparent(k, &f, &t) } else { k.to_string() })) } ), cmd!( @@ -230,7 +228,7 @@ pub fn specs() -> Vec { let (from, into) = (from.iter().map(|f| clean(f)).collect::>(), clean(&into)); commit_keywords(s, "Merge Keywords", op, |k| { Some(match from.iter().find(|f| is_under(k, f)) { - Some(f) => format!("{into}{}", &k[f.len().min(k.len())..]), + Some(f) => reparent(k, f, &into), None => k.to_string(), }) }) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a6c5f6420..aab6c0277 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -859,6 +859,8 @@ mod tests_import; #[cfg(test)] mod tests_import_move; #[cfg(test)] +mod tests_keyword_list; +#[cfg(test)] mod tests_libops; #[cfg(test)] mod tests_library; diff --git a/crates/engine/src/tests_keyword_list.rs b/crates/engine/src/tests_keyword_list.rs new file mode 100644 index 000000000..04a78fa8c --- /dev/null +++ b/crates/engine/src/tests_keyword_list.rs @@ -0,0 +1,21 @@ +//! The keyword list's commands (Lightroom Classic's Keyword List): create, edit, move and purge +//! keywords, their attributes, and the default parent for new keywords. + +use serde_json::json; + +use crate::Session; + +/// The keyword filter follows a renamed keyword whatever the case it was typed in, also where a +/// letter's case changes its length (ẞ / ß): it used to be cut by bytes, which could land inside +/// a letter and panic. +#[test] +fn the_filter_follows_a_rename_whatever_the_case() { + let mut s = Session::with_demo(); + let id = s.visible_cloned()[0].0; + s.execute("photo.setMeta", &json!({"ids": [id], "keywords": ["ßßa|é"]})).unwrap(); + s.execute("library.filter", &json!({"keyword": "ßßa|é"})).unwrap(); + s.execute("keyword.rename", &json!({"from": "ẞẞA", "to": "Road"})).unwrap(); + assert_eq!(s.filter.keyword.as_deref(), Some("Road|é")); + s.execute("keyword.merge", &json!({"from": ["ROAD"], "into": "Weg"})).unwrap(); + assert_eq!(s.filter.keyword.as_deref(), Some("Weg|é")); +} From 10c98459fedf60f124c2d7c6c57dfab356c22725 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:12:15 -0400 Subject: [PATCH 07/36] LRC-LIB-KEYWORDS: keyword list commands and the default parent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `keyword.create {name, parent?, attributes…, addToSelected?}`: a keyword inside a parent with its attributes, given to the selection at once; one undo step. - `keyword.edit {keyword, name?, attributes…}`: what isn't given keeps its value. - `keyword.move {keyword, parent | null, merge?}`: onto a taken name it says so, and merges only with `merge: true`. - `keyword.purgeUnused`, `keyword.info` (attributes, listed, count). - `keyword.setDefaultParent` (Lightroom Classic's "Put New Keywords Inside this Keyword"): new keywords go inside it unless `parent` is given (null = the top level). It is kept with the library, follows its keyword's rename or move, and goes when the keyword is deleted. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/keywords.rs | 171 +++++++++++++++++++++++- crates/engine/src/lib.rs | 3 + crates/engine/src/library.rs | 4 + crates/engine/src/tests_keyword_list.rs | 101 ++++++++++++++ 4 files changed, 275 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index 8c21d6579..bf5f95fbd 100644 --- a/crates/engine/src/cmd/keywords.rs +++ b/crates/engine/src/cmd/keywords.rs @@ -1,10 +1,10 @@ //! Library-wide keyword commands: list (tree with counts), suggestions, rename, delete, merge; //! keyword sets (nine keywords a keystroke away: ⌥1–⌥9) and Recent Keywords. -use lightcraft_catalog::keywords::{clean, is_under, reparent}; +use lightcraft_catalog::keywords::{KeywordInfo, clean, is_under, reparent}; use serde_json::{Value, json}; -use super::{CommandSpec, always, bad, cmd, str_param}; +use super::{CommandSpec, always, bad, bool_or, cmd, str_param}; use crate::{Result, Session}; fn strs(p: &Value, key: &str) -> Vec { @@ -15,8 +15,8 @@ fn strs(p: &Value, key: &str) -> Vec { } } -/// Commit a keyword batch; returns how many photos changed. The keyword filter follows a renamed -/// keyword and is cleared when its keyword is deleted. +/// Commit a keyword batch; returns how many changes it made. The keyword filter and the default +/// parent follow a renamed keyword and are cleared when their keyword is deleted. fn commit_keywords(s: &mut Session, label: &str, op: lightcraft_catalog::Op, follow: impl Fn(&str) -> Option) -> Result { let n = match &op { lightcraft_catalog::Op::Batch { ops } => ops.len(), @@ -28,9 +28,45 @@ fn commit_keywords(s: &mut Session, label: &str, op: lightcraft_catalog::Op, fol if let Some(k) = s.filter.keyword.clone() { s.filter.keyword = follow(&k); } + if let Some(k) = s.keyword_parent.clone() { + let followed = follow(&k).filter(|k| s.catalog.has_keyword(k)); + if followed.as_deref() != Some(k.as_str()) { + s.keyword_parent = followed; + let _ = s.save_prefs(); + } + } Ok(json!({"changed": n})) } +/// A keyword's attributes from command params over `base`: those not given keep their value. +fn info_from(p: &Value, base: KeywordInfo) -> KeywordInfo { + KeywordInfo { + synonyms: if p.get("synonyms").is_some() { + strs(p, "synonyms").into_iter().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect() + } else { + base.synonyms + }, + include_on_export: bool_or(p, "includeOnExport", base.include_on_export), + export_containing: bool_or(p, "exportContaining", base.export_containing), + export_synonyms: bool_or(p, "exportSynonyms", base.export_synonyms), + person: bool_or(p, "person", base.person), + } +} + +/// The keyword a command names (`keyword`), as the library writes it. +fn named_keyword(s: &Session, p: &Value, command: &str) -> Result { + let k = str_param(p, "keyword").ok_or_else(|| bad(command, "missing `keyword`"))?; + s.catalog.keyword_path(k).ok_or_else(|| bad(command, format!("no keyword “{}”", clean(k)))) +} + +/// A catalog refusal as a command error; a taken name says how to merge. +fn refused(command: &str, e: lightcraft_catalog::CatalogError, how_to_merge: &str) -> crate::EngineError { + match e { + lightcraft_catalog::CatalogError::KeywordExists(k) => bad(command, format!("there is a keyword “{k}” already{how_to_merge}")), + e => bad(command, e.to_string()), + } +} + /// A named set of up to nine keywords. #[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] pub struct KeywordSet { @@ -185,6 +221,133 @@ pub fn specs() -> Vec { Ok(json!(s.catalog.keyword_suggestions(¤t, str_param(p, "prefix").unwrap_or(""), n))) } ), + cmd!( + query "keyword.info", + "Keyword Info", + [], + None, + "{keyword} → {path, listed, count, synonyms, includeOnExport, exportContaining, exportSynonyms, person}", + always, + |s, p| { + let path = named_keyword(s, p, "keyword.info")?; + let info = s.catalog.keyword_info(&path).cloned(); + let count = s.catalog.photos().filter(|ph| ph.in_library() && ph.meta.keywords.iter().any(|k| is_under(k, &path))).count(); + let mut v = serde_json::to_value(info.clone().unwrap_or_default()).unwrap_or_default(); + if let Some(o) = v.as_object_mut() { + o.insert("path".into(), json!(path)); + o.insert("listed".into(), json!(info.is_some())); + o.insert("count".into(), json!(count)); + o.entry("synonyms").or_insert(json!([])); + } + Ok(v) + } + ), + cmd!( + "keyword.create", + "Create Keyword", + [], + None, + "{name, parent?: keyword | null (default: the default parent; null = the top level), synonyms?, includeOnExport?, exportContaining?, exportSynonyms?, person?, addToSelected?: bool, ids?} → {keyword}", + always, + |s, p| { + let name = str_param(p, "name").map(clean).filter(|n| !n.is_empty()).ok_or_else(|| bad("keyword.create", "missing `name`"))?; + let parent = match p.get("parent") { + Some(Value::String(k)) => Some(s.catalog.keyword_path(k).unwrap_or_else(|| clean(k))), + Some(_) => None, + None => s.keyword_parent.clone(), + }; + let path = clean(&match parent { + Some(parent) => format!("{parent}|{name}"), + None => name, + }); + let photos = if bool_or(p, "addToSelected", false) { s.targets(p) } else { Vec::new() }; + let op = s + .catalog + .create_keyword_ops(&path, info_from(p, KeywordInfo::default()), &photos) + .map_err(|e| refused("keyword.create", e, ""))?; + s.commit("Create Keyword", op)?; + if !photos.is_empty() { + note_recent(s, std::slice::from_ref(&path)); + } + Ok(json!({"keyword": path})) + } + ), + cmd!( + "keyword.edit", + "Edit Keyword", + [], + None, + "{keyword, name?: its new name (one level), synonyms?, includeOnExport?, exportContaining?, exportSynonyms?, person?} — what isn't given keeps its value", + always, + |s, p| { + let from = named_keyword(s, p, "keyword.edit")?; + let leaf = from.rsplit('|').next().unwrap_or(&from).to_string(); + let name = str_param(p, "name").unwrap_or(&leaf).to_string(); + let info = info_from(p, s.catalog.keyword_info(&from).cloned().unwrap_or_default()); + let op = + s.catalog.edit_keyword_ops(&from, &name, info).map_err(|e| refused("keyword.edit", e, " (to merge them, use keyword.merge)"))?; + let to = match from.rsplit_once('|') { + Some((parent, _)) => format!("{parent}|{}", name.trim()), + None => name.trim().to_string(), + }; + commit_keywords(s, "Edit Keyword", op, |k| Some(if is_under(k, &from) { reparent(k, &from, &to) } else { k.to_string() })) + } + ), + cmd!( + "keyword.move", + "Move Keyword", + [], + None, + "{keyword, parent: keyword | null (the top level), merge?: bool} — with the keywords below it; onto a name that is taken only with `merge: true`", + always, + |s, p| { + let from = named_keyword(s, p, "keyword.move")?; + let parent = match p.get("parent") { + Some(Value::String(k)) => Some(k.clone()), + Some(Value::Null) => None, + _ => return Err(bad("keyword.move", "`parent` is a keyword, or null for the top level")), + }; + let op = s + .catalog + .move_keyword_ops(&from, parent.as_deref(), bool_or(p, "merge", false)) + .map_err(|e| refused("keyword.move", e, ": moving would merge the two (merge: true)"))?; + let leaf = from.rsplit('|').next().unwrap_or(&from).to_string(); + let to = match parent.as_deref().and_then(|k| s.catalog.keyword_path(k)) { + Some(parent) => format!("{parent}|{leaf}"), + None => leaf, + }; + commit_keywords(s, "Move Keyword", op, |k| Some(if is_under(k, &from) { reparent(k, &from, &to) } else { k.to_string() })) + } + ), + cmd!( + "keyword.purgeUnused", + "Purge Unused Keywords", + [], + None, + "{} — takes off the keyword list the keywords no photo has → {purged}", + always, + |s, _| { + let op = s.catalog.purge_unused_keywords_ops(); + let r = commit_keywords(s, "Purge Unused Keywords", op, |k| Some(k.to_string()))?; + Ok(json!({"purged": r["changed"]})) + } + ), + cmd!( + "keyword.setDefaultParent", + "Put New Keywords Inside", + [], + None, + "{keyword: keyword | null} — the parent keyword.create puts new keywords inside (null: the top level)", + always, + |s, p| { + s.keyword_parent = match p.get("keyword") { + Some(Value::Null) | None => None, + Some(_) => Some(named_keyword(s, p, "keyword.setDefaultParent")?), + }; + let _ = s.save_prefs(); + Ok(json!({"keyword": s.keyword_parent})) + } + ), cmd!( "keyword.rename", "Rename Keyword", diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index aab6c0277..b676c8fba 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -247,6 +247,8 @@ pub struct Session { pub keyword_sets: Vec, pub keyword_set: Option, pub recent_keywords: Vec, + /// "Put new keywords inside this keyword": the parent `keyword.create` uses unless told. + pub keyword_parent: Option, /// Before/After: the "before" settings chosen per photo (this session; default: the photo's /// import state). See `cmd/before.rs`. pub before: std::collections::HashMap>, @@ -332,6 +334,7 @@ impl Session { keyword_sets: Vec::new(), keyword_set: None, recent_keywords: Vec::new(), + keyword_parent: None, before: Default::default(), import_probes: Default::default(), preview_build: None, diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index a83dc4a82..1524e7032 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -149,6 +149,8 @@ struct PrefsFile { keyword_sets: Vec, keyword_set: Option, recent_keywords: Vec, + /// The parent new keywords go inside. + keyword_parent: Option, /// Develop defaults for imported photos. import: crate::import::ImportDefaults, /// Thumbnail disk cache budget (MB, 0 = default). @@ -338,6 +340,7 @@ impl Session { self.keyword_sets = prefs.keyword_sets; self.keyword_set = prefs.keyword_set; self.recent_keywords = prefs.recent_keywords; + self.keyword_parent = prefs.keyword_parent; self.import_defaults = prefs.import; self.cache_mb = prefs.cache_mb; self.forget_local_days = prefs.forget_local_days.unwrap_or(lightcraft_catalog::DEFAULT_FORGET_DAYS); @@ -567,6 +570,7 @@ impl Session { keyword_sets: self.keyword_sets.clone(), keyword_set: self.keyword_set.clone(), recent_keywords: self.recent_keywords.clone(), + keyword_parent: self.keyword_parent.clone(), import: self.import_defaults.clone(), cache_mb: self.cache_mb, smart_previews_dir: self.smart_previews_dir.as_ref().map(|d| d.to_string_lossy().to_string()), diff --git a/crates/engine/src/tests_keyword_list.rs b/crates/engine/src/tests_keyword_list.rs index 04a78fa8c..261ad99a8 100644 --- a/crates/engine/src/tests_keyword_list.rs +++ b/crates/engine/src/tests_keyword_list.rs @@ -19,3 +19,104 @@ fn the_filter_follows_a_rename_whatever_the_case() { s.execute("keyword.merge", &json!({"from": ["ROAD"], "into": "Weg"})).unwrap(); assert_eq!(s.filter.keyword.as_deref(), Some("Weg|é")); } + +fn temp_dir(tag: &str) -> std::path::PathBuf { + let d = std::env::temp_dir().join(format!("lc-keyword-list-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn keywords_of(s: &Session, id: u64) -> Vec { + s.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().meta.keywords.clone() +} + +/// Create Keyword: a name inside a parent, with attributes, given to the selected photos at once, +/// in one undo step; `keyword.info` reads it back. A keyword that exists isn't created again. +#[test] +fn creating_a_keyword_with_attributes_and_the_selection() { + let mut s = Session::with_demo(); + let id = s.visible_cloned()[0].0; + s.execute("library.select", &json!({"ids": [id]})).unwrap(); + let undo = s.undo.len(); + let r = s + .execute("keyword.create", &json!({"name": "Weddings", "parent": "Events", "synonyms": ["marriage"], "person": false, "addToSelected": true})) + .unwrap(); + assert_eq!(r["keyword"], "Events|Weddings", "{r}"); + assert_eq!(s.undo.len(), undo + 1, "one undo step"); + assert!(keywords_of(&s, id).contains(&"Events|Weddings".to_string())); + let info = s.execute("keyword.info", &json!({"keyword": "events|weddings"})).unwrap(); + assert_eq!(info["path"], "Events|Weddings"); + assert_eq!(info["synonyms"], json!(["marriage"])); + assert_eq!((info["includeOnExport"].as_bool(), info["listed"].as_bool(), info["count"].as_u64()), (Some(true), Some(true), Some(1))); + let err = s.execute("keyword.create", &json!({"name": "weddings", "parent": "events"})).unwrap_err().to_string(); + assert!(err.contains("already"), "{err}"); + assert!(s.execute("keyword.info", &json!({"keyword": "lisbon"})).is_err(), "no such keyword"); +} + +/// "Put new keywords inside this keyword": new keywords go inside the default parent unless the +/// command says otherwise (`parent: null` = the top level). It follows the parent's rename, goes +/// when the parent is deleted, and is kept with the library. +#[test] +fn new_keywords_go_inside_the_default_parent() { + let dir = temp_dir("default-parent"); + let mut s = Session::new(); + s.open_library(&dir, true).unwrap(); + s.execute("keyword.create", &json!({"name": "Events"})).unwrap(); + s.execute("keyword.setDefaultParent", &json!({"keyword": "events"})).unwrap(); + assert_eq!(s.keyword_parent.as_deref(), Some("Events")); + assert_eq!(s.execute("keyword.create", &json!({"name": "Birthdays"})).unwrap()["keyword"], "Events|Birthdays"); + assert_eq!(s.execute("keyword.create", &json!({"name": "Travel", "parent": null})).unwrap()["keyword"], "Travel"); + s.execute("keyword.rename", &json!({"from": "Events", "to": "Occasions"})).unwrap(); + assert_eq!(s.keyword_parent.as_deref(), Some("Occasions"), "follows the rename"); + drop(s); + let mut s = Session::new(); + s.open_library(&dir, false).unwrap(); + assert_eq!(s.keyword_parent.as_deref(), Some("Occasions"), "kept with the library"); + s.execute("keyword.delete", &json!({"keyword": "occasions"})).unwrap(); + assert_eq!(s.keyword_parent, None, "gone with its keyword"); + assert!(s.execute("keyword.setDefaultParent", &json!({"keyword": "lisbon"})).is_err(), "no such keyword"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Edit Keyword: a new name and attributes in one step; attributes not given keep their values. +#[test] +fn editing_a_keyword_changes_what_it_is_given() { + let mut s = Session::with_demo(); + s.execute("keyword.create", &json!({"name": "Weddings", "synonyms": ["marriage"]})).unwrap(); + let undo = s.undo.len(); + s.execute("keyword.edit", &json!({"keyword": "weddings", "name": "Marriages", "includeOnExport": false})).unwrap(); + assert_eq!(s.undo.len(), undo + 1); + let info = s.execute("keyword.info", &json!({"keyword": "Marriages"})).unwrap(); + assert_eq!((info["includeOnExport"].as_bool(), info["synonyms"].clone()), (Some(false), json!(["marriage"]))); + assert!(s.execute("keyword.info", &json!({"keyword": "Weddings"})).is_err()); +} + +/// Move Keyword: inside another or to the top level. Onto a name that is taken it is refused with +/// a message saying it would merge, unless `merge: true`. +#[test] +fn moving_a_keyword_merges_only_when_asked() { + let mut s = Session::with_demo(); + let ids: Vec = s.visible_cloned().iter().take(2).map(|p| p.0).collect(); + s.execute("photo.setMeta", &json!({"ids": [ids[0]], "keywords": ["Rome"]})).unwrap(); + s.execute("photo.setMeta", &json!({"ids": [ids[1]], "keywords": ["Europe|Rome"]})).unwrap(); + let err = s.execute("keyword.move", &json!({"keyword": "rome", "parent": "europe"})).unwrap_err().to_string(); + assert!(err.contains("merge"), "{err}"); + s.execute("keyword.move", &json!({"keyword": "rome", "parent": "europe", "merge": true})).unwrap(); + assert_eq!(keywords_of(&s, ids[0]), ["Europe|Rome"]); + s.execute("keyword.move", &json!({"keyword": "europe|rome", "parent": null})).unwrap(); + assert_eq!((keywords_of(&s, ids[0]), keywords_of(&s, ids[1])), (vec!["Rome".to_string()], vec!["Rome".to_string()])); + assert!(s.execute("keyword.move", &json!({"keyword": "rome"})).is_err(), "`parent` must be said, null for the top level"); +} + +/// Purge Unused Keywords takes off the list the keywords no photo has, in one undo step. +#[test] +fn purging_unused_keywords() { + let mut s = Session::with_demo(); + s.execute("keyword.create", &json!({"name": "Weddings"})).unwrap(); + let r = s.execute("keyword.purgeUnused", &json!({})).unwrap(); + assert_eq!(r["purged"], 1, "{r}"); + assert!(s.execute("keyword.info", &json!({"keyword": "Weddings"})).is_err()); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.execute("keyword.info", &json!({"keyword": "Weddings"})).is_ok()); +} From 296fc17376d1a45bff8e502faba223d54626b961 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:13:14 -0400 Subject: [PATCH 08/36] LRC-LIB-KEYWORDS: exported files carry keywords as their options say Export wrote each keyword's `a|b` path into `dc:subject` and nothing into `lr:hierarchicalSubject`. It now writes what `Catalog::export_keywords` gives: names flat in `dc:subject` (with the keywords containing them and synonyms, as their options say), full paths in `lr:hierarchicalSubject`, and nothing of a keyword left out of export. `export_metadata` takes the catalog for the keyword list. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/export.rs | 38 +++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 33366285e..737b771f6 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -1242,7 +1242,8 @@ fn parse_shutter(s: &str) -> Option { } /// The metadata to embed for `photo` under `o.metadata` / `o.remove_location`. `None` = embed nothing. -pub fn export_metadata(photo: &lightcraft_catalog::Photo, o: &ExportOptions) -> Option { +/// Keywords follow the catalog's keyword list (`Catalog::export_keywords`). +pub fn export_metadata(photo: &lightcraft_catalog::Photo, catalog: &lightcraft_catalog::Catalog, o: &ExportOptions) -> Option { let m = &photo.meta; let text = |s: &str| (!s.trim().is_empty()).then(|| s.to_string()); // copyright info (also under "copyright only"): notice, creator, status, usage terms, info URL @@ -1270,7 +1271,9 @@ pub fn export_metadata(photo: &lightcraft_catalog::Photo, o: &ExportOptions) -> out.state = text(&m.state); out.country = text(&m.country); } - out.keywords = m.keywords.clone(); + let keywords = catalog.export_keywords(&m.keywords); + out.keywords = keywords.flat; + out.hierarchical_keywords = keywords.hierarchical; out.capture_time = photo.captured.as_deref().and_then(DateTime::parse_iso); out.rating = (photo.rating > 0).then_some(photo.rating as i8); // Pixels are exported upright: orientation is baked in. @@ -1375,7 +1378,7 @@ fn prepare_guarded( let file_name = o.file_name_for(p, seq); let work = if o.format.is_rendered() { let (w, h) = output_size(p, o); - let meta = export_metadata(p, o); + let meta = export_metadata(p, &session.catalog, o); let job = session.export_job(id, w, h, o.effective_space(), o.effective_depth())?; Work::Render(Box::new(RenderWork { job, meta, opts: o.clone() })) } else { @@ -1626,9 +1629,28 @@ mod tests { assert!(img.data[3][0] < 80 && img.data[4][0] > 170); } + /// Exported files carry keywords as Lightroom Classic writes them: names flat in `dc:subject` + /// (with the keywords containing them), full paths in `lr:hierarchicalSubject`, and nothing of + /// a keyword left out of export. They used to carry the `a|b` paths in `dc:subject`. + #[test] + fn exported_keywords_follow_their_options() { + use lightcraft_catalog::{Catalog, Op, Photo, PhotoId, Source, keywords::KeywordInfo}; + let mut c = Catalog::new(); + c.apply(Op::SetKeyword { path: "Places".into(), info: Some(KeywordInfo { include_on_export: false, ..KeywordInfo::default() }) }).unwrap(); + c.apply(Op::SetKeyword { path: "draft".into(), info: Some(KeywordInfo { include_on_export: false, ..KeywordInfo::default() }) }).unwrap(); + let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.jpg", "jpeg", 10, 10, "2026-09-30T00:00:00"); + p.meta.keywords = vec!["Places|Lisbon".into(), "travel|Italy".into(), "draft".into()]; + let m = export_metadata(&p, &c, &ExportOptions::default()).unwrap(); + assert_eq!(m.keywords, ["Lisbon", "Italy", "travel"]); + assert_eq!(m.hierarchical_keywords, ["Places|Lisbon", "travel|Italy"]); + let jpg = encode_with_metadata(&test_image(), &ExportOptions::default(), Some(&m)).unwrap(); + let back = lightcraft_meta::extract(&jpg); + assert_eq!((back.keywords, back.hierarchical_keywords), (m.keywords.clone(), m.hierarchical_keywords.clone())); + } + #[test] fn metadata_policies() { - use lightcraft_catalog::{Photo, PhotoId, Source}; + use lightcraft_catalog::{Catalog, Photo, PhotoId, Source}; let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, "a.jpg", "jpeg", 10, 10, "2026-09-30T00:00:00"); p.meta.camera = "Synthetic X2".into(); p.meta.copyright = "(c) Me".into(); @@ -1637,21 +1659,21 @@ mod tests { p.meta.copyright_url = "https://example.com/rights".into(); p.meta.shutter = "1/250".into(); p.meta.gps = Some((43.0, -110.0)); - let all = export_metadata(&p, &ExportOptions::default()).unwrap(); + let all = export_metadata(&p, &Catalog::new(), &ExportOptions::default()).unwrap(); assert_eq!(all.model.as_deref(), Some("Synthetic X2")); assert!((all.exposure_time.unwrap() - 0.004).abs() < 1e-9); assert!(all.gps.is_some()); let o = ExportOptions { metadata: MetadataPolicy::AllExceptCamera, remove_location: true, ..Default::default() }; - let m = export_metadata(&p, &o).unwrap(); + let m = export_metadata(&p, &Catalog::new(), &o).unwrap(); assert!(m.model.is_none() && m.gps.is_none() && m.copyright.is_some()); - let c = export_metadata(&p, &ExportOptions { metadata: MetadataPolicy::Copyright, ..Default::default() }).unwrap(); + let c = export_metadata(&p, &Catalog::new(), &ExportOptions { metadata: MetadataPolicy::Copyright, ..Default::default() }).unwrap(); assert!(c.model.is_none() && c.gps.is_none() && c.copyright.as_deref() == Some("(c) Me")); // "copyright only" keeps all the copyright info: status, usage terms, info URL assert_eq!( (c.copyright_marked, c.usage_terms.as_deref(), c.copyright_url.as_deref()), (Some(true), Some("Editorial use only"), Some("https://example.com/rights")) ); - assert!(export_metadata(&p, &ExportOptions { metadata: MetadataPolicy::None, ..Default::default() }).is_none()); + assert!(export_metadata(&p, &Catalog::new(), &ExportOptions { metadata: MetadataPolicy::None, ..Default::default() }).is_none()); // embedded and readable back from the JPEG let jpg = encode_with_metadata(&test_image(), &ExportOptions::default(), Some(&all)).unwrap(); let back = lightcraft_meta::extract(&jpg); From 96dcc2f6437ec7ea575612373b587cb71be91a9d Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:16:53 -0400 Subject: [PATCH 09/36] LRC-LIB-KEYWORDS: keyword names match whatever the case of any letter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `is_under` and `dedupe` compared names by lower-casing them, but other checks used `eq_ignore_ascii_case`: changing only the case of "Ärzte" was refused as a clash with itself, and moving a keyword where it is wasn't seen as staying put. Every comparison now uses `keywords::same`. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/keywords.rs | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index debdec9d8..d297d34b3 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -24,6 +24,11 @@ pub fn clean(k: &str) -> String { k.split(SEP).map(str::trim).filter(|s| !s.is_empty()).collect::>().join("|") } +/// The same name, whatever the case of any of its letters (not only ASCII ones). +pub fn same(a: &str, b: &str) -> bool { + a == b || a.to_lowercase() == b.to_lowercase() +} + /// `k` is `parent` or one of its descendants (`parent|…`), ignoring case. pub fn is_under(k: &str, parent: &str) -> bool { let (k, p) = (k.to_lowercase(), parent.to_lowercase()); @@ -204,7 +209,7 @@ impl Catalog { match list.get_mut(&path.to_lowercase()) { Some(there) => { for s in l.info.synonyms { - if !there.info.synonyms.iter().any(|x| x.eq_ignore_ascii_case(&s)) { + if !there.info.synonyms.iter().any(|x| same(x, &s)) { there.info.synonyms.push(s); } } @@ -224,7 +229,7 @@ impl Catalog { if from.is_empty() || to.is_empty() { return Err(CatalogError::Invalid("keyword names can't be empty".into())); } - if is_under(&to, &from) && !to.eq_ignore_ascii_case(&from) { + if is_under(&to, &from) && !same(&to, &from) { return Err(CatalogError::Invalid("can't move a keyword below itself".into())); } let mut ops = self.keyword_ops(|kws| kws.iter().map(|k| if is_under(k, &from) { reparent(k, &from, &to) } else { k.clone() }).collect()); @@ -246,7 +251,7 @@ impl Catalog { /// Merge several keywords (with their children) into `into`. pub fn merge_keywords_ops(&self, from: &[String], into: &str) -> Result { let into = clean(into); - let from: Vec = from.iter().map(|f| clean(f)).filter(|f| !f.is_empty() && !f.eq_ignore_ascii_case(&into)).collect(); + let from: Vec = from.iter().map(|f| clean(f)).filter(|f| !f.is_empty() && !same(f, &into)).collect(); if into.is_empty() || from.is_empty() { return Err(CatalogError::Invalid("merge needs keywords and a target".into())); } @@ -270,7 +275,7 @@ impl Catalog { let info = |path: &str| self.keyword_info(path).unwrap_or(&defaults); let mut out = ExportKeywords::default(); let push = |v: &mut Vec, s: &str| { - if !s.is_empty() && !v.iter().any(|x| x.eq_ignore_ascii_case(s)) { + if !s.is_empty() && !v.iter().any(|x| same(x, s)) { v.push(s.to_string()); } }; @@ -363,7 +368,7 @@ impl Catalog { Some(p) => format!("{}{SEP}{leaf}", self.keyword_path(&p).unwrap_or(p)), None => leaf.to_string(), }; - if to.eq_ignore_ascii_case(&from) { + if same(&to, &from) { return Ok(Op::Batch { ops: vec![] }); } if !merge && self.has_keyword(&to) { @@ -389,7 +394,7 @@ impl Catalog { }; let mut ops = Vec::new(); if to != from { - if !to.eq_ignore_ascii_case(&from) && self.has_keyword(&to) { + if !same(&to, &from) && self.has_keyword(&to) { return Err(CatalogError::KeywordExists(to)); } if let Op::Batch { ops: renamed } = self.rename_keyword_ops(&from, &to)? { @@ -412,7 +417,7 @@ impl Catalog { /// first, at most `n`, never one of `current`. pub fn keyword_suggestions(&self, current: &[String], prefix: &str, n: usize) -> Vec { let all = self.keywords(); - let has = |k: &str| current.iter().any(|c| c.eq_ignore_ascii_case(k)); + let has = |k: &str| current.iter().any(|c| same(c, k)); let q = prefix.trim().to_lowercase(); let mut scored: Vec<(i64, String)> = if !q.is_empty() { all.into_iter() @@ -779,6 +784,21 @@ mod tests { assert_eq!(flat, ["Lisbon", "Lisboa", "Weddings", "Events"]); } + /// Names match whatever the case of any letter, not only ASCII ones: changing only the case of + /// "Ärzte" is a rename, not a clash with itself, and moving it where it is changes nothing. + #[test] + fn names_match_whatever_the_case_of_any_letter() { + let (mut c, ids) = lib(&[&["Ärzte|Wien"]]); + let op = c.edit_keyword_ops("ärzte", "ÄRZTE", KeywordInfo::default()).unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["ÄRZTE|Wien"]); + assert_eq!(c.move_keyword_ops("ärzte|wien", Some("ärzte"), false).unwrap(), Op::Batch { ops: vec![] }); + let op = c.rename_keyword_ops("ärzte", "Ärzte").unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["Ärzte|Wien"]); + assert!(c.create_keyword_ops("ärzte", KeywordInfo::default(), &[]).is_err(), "exists"); + } + #[test] fn suggestions_rank_co_occurrence_and_prefixes() { let (c, _) = lib(&[&["dog", "park"], &["dog", "park", "ball"], &["dog", "beach"], &["cat"], &["cat"], &["cat"], &["parade"], &["eagle"]]); From a29c9f0de59e349ba9f4dcda82cc713b1d59dd46 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:21:04 -0400 Subject: [PATCH 10/36] LRC-LIB-KEYWORDS: a Keyword List in the right panel The Keywords panel ends with Lightroom Classic's Keyword List: every keyword of the library, those without photos too, as a tree with photo counts (`panels::keyword_list`). - A filter box keeps the keywords whose name holds the text, with the keywords containing them opened down to them. - The triangle opens a level (kept with the UI state). - The tick box says whether every, some (a dash) or none of the selected photos have the keyword, and gives it to all of them or takes it away. - The arrow shown on hover shows the photos with the keyword. - A click picks a row, for the actions that follow. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de.json | 6 +- crates/ui-egui/locales/es.json | 6 +- crates/ui-egui/locales/ja.json | 6 +- crates/ui-egui/locales/pt-br.json | 6 +- crates/ui-egui/locales/ru.json | 6 +- crates/ui-egui/locales/zh-hans.json | 6 +- crates/ui-egui/locales/zh-hant.json | 6 +- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/keyword_list.rs | 285 ++++++++++++++++++++++ crates/ui-egui/src/panels/left.rs | 2 +- crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/panels/right.rs | 1 + crates/ui-egui/src/state.rs | 7 + crates/ui-egui/src/tests_keyword_list.rs | 100 ++++++++ 14 files changed, 432 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/src/panels/keyword_list.rs create mode 100644 crates/ui-egui/src/tests_keyword_list.rs diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index c24b59cc0..b2caeff54 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1889,5 +1889,9 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Keyword List": "Stichwortliste", + "Filter Keywords": "Stichwörter filtern", + "No keywords yet": "Noch keine Stichwörter", + "No keywords match": "Keine passenden Stichwörter" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 387ac9717..588575c4b 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1734,5 +1734,9 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Keyword List": "Lista de palabras clave", + "Filter Keywords": "Filtrar palabras clave", + "No keywords yet": "Aún no hay palabras clave", + "No keywords match": "Ninguna palabra clave coincide" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index b05baac24..f6504989d 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1734,5 +1734,9 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Keyword List": "キーワードリスト", + "Filter Keywords": "キーワードを絞り込む", + "No keywords yet": "キーワードはまだありません", + "No keywords match": "一致するキーワードはありません" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index ab7f39734..eda58e989 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1734,5 +1734,9 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Keyword List": "Lista de palavras-chave", + "Filter Keywords": "Filtrar palavras-chave", + "No keywords yet": "Ainda não há palavras-chave", + "No keywords match": "Nenhuma palavra-chave corresponde" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 517e2613f..3aaf92c57 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1407,5 +1407,9 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Keyword List": "Список ключевых слов", + "Filter Keywords": "Фильтр ключевых слов", + "No keywords yet": "Ключевых слов пока нет", + "No keywords match": "Нет подходящих ключевых слов" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 46728b628..0cbba6d54 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1734,5 +1734,9 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Keyword List": "关键字列表", + "Filter Keywords": "过滤关键字", + "No keywords yet": "还没有关键字", + "No keywords match": "没有匹配的关键字" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 083c98c2e..d45c83718 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1734,5 +1734,9 @@ "Pending photo action cancelled": "Pending photo action cancelled", "Pending photo action cancelled because its library or photo changed": "Pending photo action cancelled because its library or photo changed", "ONNX model": "ONNX model", - "commercial use subject to GPL conditions": "commercial use subject to GPL conditions" + "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", + "Keyword List": "關鍵字清單", + "Filter Keywords": "篩選關鍵字", + "No keywords yet": "尚無關鍵字", + "No keywords match": "沒有符合的關鍵字" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c9d0d27f6..e0487c497 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -42,6 +42,8 @@ mod tests_grid; #[cfg(test)] mod tests_keymap; #[cfg(test)] +mod tests_keyword_list; +#[cfg(test)] mod tests_labels; #[cfg(test)] mod tests_library_problem; diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs new file mode 100644 index 000000000..4df3bb6be --- /dev/null +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -0,0 +1,285 @@ +//! The Keyword List (Lightroom Classic's Keyword List panel), in the right panel's Keywords: every +//! keyword of the library, those without photos too, as a tree with photo counts. A tick box per +//! keyword gives it to the selected photos or takes it away; the arrow shows the photos with it. + +use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use lightcraft_catalog::keywords::{KeywordNode, same}; +use lightcraft_catalog::{Catalog, PhotoId}; +use serde_json::json; + +use crate::LightcraftApp; +use crate::theme::Tokens; +use crate::widgets::register; + +/// Rows are this tall; each level is indented this much. +const ROW_H: f32 = 24.0; +const INDENT: f32 = 14.0; + +/// The Keyword List section: a filter box, then the rows. +pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let t = Tokens::get(ui.ctx()); + crate::widgets::divider(ui); + super::right::header(ui, "Keyword List"); + let fid = egui::Id::new("keyword-list-filter"); + let mut filter: String = ui.data(|d| d.get_temp(fid)).unwrap_or_default(); + ui.horizontal(|ui| { + ui.add_space(24.0); + crate::text_field::TextField::singleline("field:keywordFilter", &mut filter) + .hint(crate::i18n::tr("Filter Keywords")) + .width(ui.available_width() - 22.0) + .show(ui); + }); + ui.data_mut(|d| d.insert_temp(fid, filter.clone())); + ui.add_space(6.0); + let tree = app.caches.keyword_tree(&app.session.catalog); + let open = app.ui.keyword_list_open.clone(); + let rows = rows(&tree, &filter, &|p| open.iter().any(|o| same(o, p))); + if rows.is_empty() { + let none = if filter.trim().is_empty() { "No keywords yet" } else { "No keywords match" }; + ui.horizontal(|ui| { + ui.add_space(24.0); + ui.label(egui::RichText::new(crate::i18n::tr(none)).color(t.text_dim)); + }); + } + let selection = app.session.selection.ids.clone(); + for r in &rows { + row(app, ui, r, &selection, filter.trim().is_empty()); + } + ui.add_space(12.0); +} + +/// One keyword's row: the triangle, the tick box, the name, the count, and on hover the arrow. +/// `can_fold`: the triangle opens and closes the level (not while a filter opens it). +fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId], can_fold: bool) { + let t = Tokens::get(ui.ctx()); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click()); + register(ui.ctx(), format!("keywordRow:{}", r.path), rect); + let picked = app.ui.keyword_list_selected.as_deref().is_some_and(|k| same(k, &r.path)); + let inner = Rect::from_min_max(rect.min + vec2(16.0, 0.0), rect.max - vec2(16.0, 0.0)); + if picked { + ui.painter().rect_filled(inner, 4.0, t.canvas); + } else if resp.hovered() { + ui.painter().rect_filled(inner, 4.0, t.hover.gamma_multiply(0.6)); + } + let x = inner.left() + 4.0 + r.depth as f32 * INDENT; + let cy = rect.center().y; + // the triangle + if r.has_children { + let c = pos2(x + 6.0, cy); + let tri = Rect::from_center_size(c, vec2(14.0, 14.0)); + let tr = ui.interact(tri, egui::Id::new(("keyword-list-tri", r.path.to_lowercase())), Sense::click()); + register(ui.ctx(), format!("keywordRowToggle:{}", r.path), tri); + let pts = if r.open { + vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)] + } else { + vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)] + }; + ui.painter().add(egui::Shape::convex_polygon(pts, if tr.hovered() { t.text } else { t.text_dim }, Stroke::NONE)); + if tr.clicked() && can_fold { + let key = r.path.to_lowercase(); + if r.open { + app.ui.keyword_list_open.retain(|o| !same(o, &key)); + } else { + app.ui.keyword_list_open.push(key); + } + } + } + // the tick box: does the selection have it? + let state = tick(&app.session.catalog, selection, &r.path); + let boxr = Rect::from_center_size(pos2(x + 22.0, cy), vec2(13.0, 13.0)); + let on = !selection.is_empty(); + let tb = + ui.interact(boxr.expand(3.0), egui::Id::new(("keyword-list-tick", r.path.to_lowercase())), if on { Sense::click() } else { Sense::hover() }); + register(ui.ctx(), format!("keywordCheck:{}", r.path), boxr); + let edge = if on { t.text_label } else { t.text_dim.gamma_multiply(0.5) }; + match state { + Tick::All => { + ui.painter().rect_filled(boxr, 2.0, t.accent); + let pts = vec![boxr.left_center() + vec2(2.5, 0.0), boxr.center_bottom() + vec2(-1.0, -3.0), boxr.right_top() + vec2(-2.5, 3.0)]; + ui.painter().add(egui::Shape::line(pts, Stroke::new(1.6, Color32::WHITE))); + } + Tick::Some => { + ui.painter().rect_stroke(boxr, 2.0, Stroke::new(1.0, edge), StrokeKind::Inside); + ui.painter().line_segment([boxr.left_center() + vec2(3.0, 0.0), boxr.right_center() - vec2(3.0, 0.0)], Stroke::new(1.6, t.text)); + } + Tick::No => { + ui.painter().rect_stroke(boxr, 2.0, Stroke::new(1.0, edge), StrokeKind::Inside); + } + } + let tb = tb.on_hover_text(crate::i18n::tr(match state { + Tick::All => "Remove from Selected Photos", + _ => "Add to Selected Photos", + })); + if tb.clicked() { + let key = if state == Tick::All { "removeKeywords" } else { "addKeywords" }; + let _ = app.run("photo.setMeta", json!({key: [r.path]})); + } + // the count, then the arrow left of it (shown on hover) + let count = ui.painter().layout_no_wrap(r.count.to_string(), t.font(12.0), t.text_dim); + let count_rect = Rect::from_min_size(pos2(inner.right() - 6.0 - count.size().x, cy - count.size().y / 2.0), count.size()); + register(ui.ctx(), format!("keywordCount:{}", r.path), count_rect); + ui.painter().galley(count_rect.min, count, t.text_dim); + let arrow = Rect::from_center_size(pos2(count_rect.left() - 12.0, cy), vec2(16.0, 16.0)); + let ar = ui.interact(arrow, egui::Id::new(("keyword-list-show", r.path.to_lowercase())), Sense::click()); + register(ui.ctx(), format!("keywordShow:{}", r.path), arrow); + if resp.hovered() || ar.hovered() { + ui.painter().text(arrow.center(), Align2::CENTER_CENTER, "→", t.font(13.0), if ar.hovered() { t.text } else { t.text_dim }); + } + if ar.on_hover_text(crate::i18n::tr("Show Photos with Keyword")).clicked() { + super::left::browse_all_photos(app, true); + let _ = app.run("library.filter", json!({"keyword": r.path})); + } + // the name, cut short before the arrow + let room = (arrow.left() - 4.0 - (x + 34.0)).max(0.0); + let font = t.font(13.0); + let measure = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), t.text_label).size().x; + let name = if measure(&r.name) <= room { r.name.clone() } else { crate::widgets::elide_head(&r.name, room, measure) }; + ui.painter().text(pos2(x + 34.0, cy), Align2::LEFT_CENTER, name, font.clone(), if picked { t.text } else { t.text_label }); + let resp = resp.on_hover_text(r.path.replace('|', " › ")); + if resp.clicked() { + app.ui.keyword_list_selected = Some(r.path.clone()); + } +} + +/// One row of the Keyword List as drawn. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Row { + pub path: String, + pub name: String, + /// Levels below the top (0 = a top-level keyword). + pub depth: usize, + pub count: usize, + pub has_children: bool, + /// Its children are shown. + pub open: bool, +} + +/// The rows to draw: the tree with its levels open as `open` says (by path, any case). With a +/// `filter`, the keywords whose name contains it (any case) and the keywords containing them, +/// opened so that they show. +pub(crate) fn rows(tree: &[KeywordNode], filter: &str, open: &dyn Fn(&str) -> bool) -> Vec { + let filter = filter.trim().to_lowercase(); + let mut out = Vec::new(); + add_rows(tree, 0, &filter, open, &mut out); + out +} + +/// A node holds a match: its name, or a name below it. +fn holds(n: &KeywordNode, filter: &str) -> bool { + n.name.to_lowercase().contains(filter) || n.children.iter().any(|c| holds(c, filter)) +} + +fn add_rows(nodes: &[KeywordNode], depth: usize, filter: &str, open: &dyn Fn(&str) -> bool, out: &mut Vec) { + for n in nodes { + // filtering: a match, or a keyword containing one, opened down to it + let below = n.children.iter().any(|c| holds(c, filter)); + if !filter.is_empty() && !below && !n.name.to_lowercase().contains(filter) { + continue; + } + let is_open = if filter.is_empty() { open(&n.path) } else { below }; + out.push(Row { path: n.path.clone(), name: n.name.clone(), depth, count: n.count, has_children: !n.children.is_empty(), open: is_open }); + if is_open { + add_rows(&n.children, depth + 1, filter, open, out); + } + } +} + +/// Whether the selected photos have a keyword: the tick box's state. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Tick { + /// None of them (or nothing is selected). + No, + /// Some of them: the box shows a dash. + Some, + /// All of them. + All, +} + +/// The tick box of `path` for `photos`: a photo counts when it has the keyword itself (any case); +/// one with only a keyword below it doesn't. +pub(crate) fn tick(catalog: &Catalog, photos: &[PhotoId], path: &str) -> Tick { + let path = lightcraft_catalog::keywords::clean(path); + let has = photos + .iter() + .filter(|id| { + catalog + .photo(**id) + .is_some_and(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::same(&lightcraft_catalog::keywords::clean(k), &path))) + }) + .count(); + match has { + 0 => Tick::No, + n if n == photos.len() => Tick::All, + _ => Tick::Some, + } +} + +#[cfg(test)] +mod tests { + use lightcraft_catalog::{Op, Photo, Source}; + + use super::*; + + fn library(keywords: &[&[&str]]) -> (Catalog, Vec) { + let mut c = Catalog::new(); + let mut ids = Vec::new(); + for k in keywords { + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, "a.jpg", "JPEG", 3, 2, "2026-01-01"); + p.meta.keywords = k.iter().map(|s| s.to_string()).collect(); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + ids.push(id); + } + (c, ids) + } + + fn shown(rows: &[Row]) -> Vec<(String, usize, usize)> { + rows.iter().map(|r| (r.path.clone(), r.depth, r.count)).collect() + } + + /// The list shows the top level, and the keywords below a level once it is open. + #[test] + fn the_list_shows_the_levels_that_are_open() { + let (c, _) = library(&[&["travel|Italy|Rome", "beach"], &["travel|Spain"]]); + let tree = c.keyword_tree(); + let closed = rows(&tree, "", &|_| false); + assert_eq!(shown(&closed), [("beach".to_string(), 0, 1), ("travel".to_string(), 0, 2)]); + assert!(closed[1].has_children && !closed[1].open && !closed[0].has_children); + let open = rows(&tree, "", &|p| p.eq_ignore_ascii_case("TRAVEL")); + assert_eq!( + shown(&open), + [("beach".to_string(), 0, 1), ("travel".to_string(), 0, 2), ("travel|Italy".to_string(), 1, 1), ("travel|Spain".to_string(), 1, 1)] + ); + } + + /// Filtering shows the keywords whose name holds the text, whatever its case, with the keywords + /// containing them opened down to them; keywords below a match aren't shown unless they match. + #[test] + fn filtering_shows_the_matches_with_their_parents() { + let (c, _) = library(&[&["travel|Italy|Rome", "travel|Spain|Ronda", "beach"]]); + let tree = c.keyword_tree(); + let found = rows(&tree, "RO", &|_| false); + assert_eq!( + found.iter().map(|r| r.path.as_str()).collect::>(), + ["travel", "travel|Italy", "travel|Italy|Rome", "travel|Spain", "travel|Spain|Ronda"] + ); + assert!(found.iter().filter(|r| r.has_children).all(|r| r.open)); + assert_eq!(rows(&tree, "italy", &|_| false).iter().map(|r| r.path.as_str()).collect::>(), ["travel", "travel|Italy"]); + assert!(rows(&tree, "lisbon", &|_| false).is_empty()); + } + + /// The tick box: ticked when every selected photo has the keyword itself, a dash when some do, + /// empty when none do or nothing is selected. + #[test] + fn the_tick_box_says_how_many_selected_photos_have_the_keyword() { + let (c, ids) = library(&[&["Travel|Italy"], &["travel|italy", "beach"], &["travel|spain"]]); + assert_eq!(tick(&c, &ids[..2], "travel|Italy"), Tick::All, "whatever the case"); + assert_eq!(tick(&c, &ids, "travel|Italy"), Tick::Some); + assert_eq!(tick(&c, &ids, "travel"), Tick::No, "a keyword below doesn't tick its parent"); + assert_eq!(tick(&c, &[], "beach"), Tick::No); + assert_eq!(tick(&c, &ids[2..], "beach"), Tick::No); + // any letter's case, not only ASCII + let (c, ids) = library(&[&["Ärzte"]]); + assert_eq!(tick(&c, &ids, "ärzte"), Tick::All); + } +} diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 2676f59ca..a7c553469 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -674,7 +674,7 @@ fn date_row(app: &mut LightcraftApp, ui: &mut egui::Ui, key: &str, label: &str, /// By Date and Keywords count every photo in the library, so choosing a row shows those photos /// from All Photos, not from whatever album or folder happened to be open, where they could be /// missing (issue #341). Only when choosing (`on`), not when clearing the row again. -fn browse_all_photos(app: &mut LightcraftApp, on: bool) { +pub(crate) fn browse_all_photos(app: &mut LightcraftApp, on: bool) { if on && app.session.source != LibrarySource::All { let _ = app.run("library.source", json!({"kind": "all"})); } diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index abb015518..c1dc9ce1f 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -12,6 +12,7 @@ pub mod faces; pub mod filterbar; pub mod grid; pub mod keymap; +pub mod keyword_list; pub mod left; pub mod library_problem; pub mod masking; diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 7df03d72e..9f2788a16 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -840,6 +840,7 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); } }); + super::keyword_list::show(app, ui); } /// The keyword set: pick a set, then nine buttons (⌥1–⌥9) that toggle its keywords on the diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 83b3df292..8974fc300 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -449,6 +449,11 @@ pub struct UiState { /// The keyword painter: clicking a photo in the grid toggles this keyword on it. #[serde(skip)] pub keyword_painter: Option, + /// The Keyword List's open levels (lower-case paths). + pub keyword_list_open: Vec, + /// The keyword picked in the Keyword List (− deletes it, Edit edits it). + #[serde(skip)] + pub keyword_list_selected: Option, /// A running slideshow (full screen): seconds per photo, when the next one is due (egui /// time), paused. #[serde(skip)] @@ -757,6 +762,8 @@ impl Default for UiState { slideshow: None, second_window: false, keyword_painter: None, + keyword_list_open: Vec::new(), + keyword_list_selected: None, info_overlay: InfoOverlay::Off, navigator: true, settings: AppSettings::default(), diff --git a/crates/ui-egui/src/tests_keyword_list.rs b/crates/ui-egui/src/tests_keyword_list.rs new file mode 100644 index 000000000..b0294dc15 --- /dev/null +++ b/crates/ui-egui/src/tests_keyword_list.rs @@ -0,0 +1,100 @@ +//! The Keyword List in the right panel's Keywords (Lightroom Classic's Keyword List panel), +//! driven through the control channel. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +/// The demo library in the Library grid with the Keywords panel open, two photos selected. +fn keywords_panel() -> (Headless, Vec) { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, 1000.0], 1.0); + let ids: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); + for (method, params) in [ + ("ui.set", json!({"view": "photoGrid", "right": "keywords"})), + ("engine.execute", json!({"command": "library.select", "params": {"ids": ids}})), + ] { + let r = h.request(method, params, T); + assert_eq!(r["ok"], true, "{r}"); + } + h.settle(SETTLE); + (h, ids) +} + +fn run(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); + h.settle(SETTLE); + r +} + +fn ask(h: &mut Headless, method: &str, params: serde_json::Value) { + let r = h.request(method, params.clone(), T); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + h.settle(SETTLE); +} + +fn has(h: &Headless, id: &str) -> bool { + h.app.widgets.iter().any(|(w, _)| w == id) +} + +fn keywords_of(h: &Headless, id: u64) -> Vec { + h.app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).unwrap().meta.keywords.clone() +} + +/// The list shows every keyword with its photo count, a keyword without photos too; the triangle +/// opens a level. +#[test] +fn the_keyword_list_shows_every_keyword() { + let (mut h, _) = keywords_panel(); + run(&mut h, "keyword.create", json!({"name": "Weddings", "parent": "Events"})); + assert!(has(&h, "keywordRow:Events"), "a keyword with no photos is listed"); + assert!(!has(&h, "keywordRow:Events|Weddings"), "inside a closed level"); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordRowToggle:Events"})); + assert!(has(&h, "keywordRow:Events|Weddings")); + assert!(has(&h, "keywordCount:mountains"), "photo counts are shown"); +} + +/// The tick box gives the keyword to every selected photo, then takes it away; with only some of +/// them having it, a click gives it to all. +#[test] +fn the_tick_box_gives_a_keyword_to_the_selection() { + let (mut h, ids) = keywords_panel(); + run(&mut h, "keyword.create", json!({"name": "Weddings"})); + // the demo has many keywords: find it with the filter box + ask(&mut h, "ui.clickWidget", json!({"id": "field:keywordFilter"})); + ask(&mut h, "ui.text", json!({"text": "wedd"})); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordCheck:Weddings"})); + assert!(ids.iter().all(|id| keywords_of(&h, *id).contains(&"Weddings".to_string())), "given to both"); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordCheck:Weddings"})); + assert!(ids.iter().all(|id| !keywords_of(&h, *id).contains(&"Weddings".to_string())), "taken from both"); + run(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "addKeywords": ["Weddings"]})); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordCheck:Weddings"})); + assert!(ids.iter().all(|id| keywords_of(&h, *id).contains(&"Weddings".to_string())), "some had it: now all do"); +} + +/// Typing in the filter box keeps the keywords whose name holds the text, with their parents. +#[test] +fn the_filter_box_narrows_the_list() { + let (mut h, _) = keywords_panel(); + run(&mut h, "keyword.create", json!({"name": "Weddings", "parent": "Events"})); + ask(&mut h, "ui.clickWidget", json!({"id": "field:keywordFilter"})); + ask(&mut h, "ui.text", json!({"text": "wed"})); + let rows: Vec = h.app.widgets.iter().filter_map(|(w, _)| w.strip_prefix("keywordRow:").map(str::to_string)).collect(); + assert_eq!(rows, ["Events", "Events|Weddings"]); +} + +/// The arrow on a row shows the photos with that keyword. +#[test] +fn the_arrow_shows_the_photos_with_the_keyword() { + let (mut h, _) = keywords_panel(); + ask(&mut h, "ui.hoverWidget", json!({"id": "keywordRow:mountains"})); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordShow:mountains"})); + assert_eq!(h.app.session.filter.keyword.as_deref(), Some("mountains")); +} From 41386353e9776579301b4d8317c8517ff67d6240 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:25:02 -0400 Subject: [PATCH 11/36] LRC-LIB-KEYWORDS: create, edit and delete keywords from the Keyword List MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The + button opens Create Keyword Tag: a name, synonyms, "Put inside" the keyword picked in the list (or the default parent), "Add to Selected Photos", and the keyword tag options (Include on Export, Export Containing Keywords, Export Synonyms, Person). Return creates it; a name that is taken says so and keeps the dialog open. - Double-clicking a keyword opens Edit Keyword Tag with its name and attributes; a new name and options apply in one undo step. - The − button deletes the picked keyword after saying how many photos lose it. The name takes the focus once when the dialog opens: the one-field dialogs' `dialog_field` takes it back every frame, which would steal it from Synonyms. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de-formats.json | 5 +- crates/ui-egui/locales/de.json | 13 ++- crates/ui-egui/locales/es-formats.json | 5 +- crates/ui-egui/locales/es.json | 13 ++- crates/ui-egui/locales/ja-formats.json | 5 +- crates/ui-egui/locales/ja.json | 13 ++- crates/ui-egui/locales/pt-br-formats.json | 5 +- crates/ui-egui/locales/pt-br.json | 13 ++- crates/ui-egui/locales/ru-formats.json | 5 +- crates/ui-egui/locales/ru.json | 13 ++- crates/ui-egui/locales/zh-hans-formats.json | 5 +- crates/ui-egui/locales/zh-hans.json | 13 ++- crates/ui-egui/locales/zh-hant-formats.json | 5 +- crates/ui-egui/locales/zh-hant.json | 13 ++- crates/ui-egui/src/panels/dialogs.rs | 121 +++++++++++++++++++- crates/ui-egui/src/panels/keyword_list.rs | 70 ++++++++++- crates/ui-egui/src/state.rs | 24 ++++ crates/ui-egui/src/tests_keyword_list.rs | 79 +++++++++++++ 18 files changed, 403 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 126723949..6babc6e9e 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -206,5 +206,8 @@ "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", "Running on {}.": "Running on {}.", "{file} · {size}": "{file} · {size}", - "• {assumption}": "• {assumption}" + "• {assumption}": "• {assumption}", + "Put inside “{parent}”": "In „{parent}“ einfügen", + "Delete the keyword “{name}”?": "Das Stichwort „{name}“ löschen?", + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Es wird von {count} Foto{} entfernt, mit den Stichwörtern darunter. Rückgängig stellt es wieder her." } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index b2caeff54..1cfc9bcac 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1893,5 +1893,16 @@ "Keyword List": "Stichwortliste", "Filter Keywords": "Stichwörter filtern", "No keywords yet": "Noch keine Stichwörter", - "No keywords match": "Keine passenden Stichwörter" + "No keywords match": "Keine passenden Stichwörter", + "Keyword Name": "Stichwortname", + "Synonyms": "Synonyme", + "Separated by commas": "Durch Kommas getrennt", + "Keyword Tag Options": "Stichwort-Optionen", + "Include on Export": "Beim Export einschließen", + "Export Containing Keywords": "Übergeordnete Stichwörter exportieren", + "Export Synonyms": "Synonyme exportieren", + "Create Keyword Tag": "Stichwort erstellen", + "Edit Keyword Tag": "Stichwort bearbeiten", + "Create": "Erstellen", + "No photo has it; the keywords below it go too.": "Kein Foto hat es; die Stichwörter darunter werden ebenfalls gelöscht." } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 54418c768..da9c85435 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -206,5 +206,8 @@ "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", "Running on {}.": "Running on {}.", "{file} · {size}": "{file} · {size}", - "• {assumption}": "• {assumption}" + "• {assumption}": "• {assumption}", + "Put inside “{parent}”": "Poner dentro de “{parent}”", + "Delete the keyword “{name}”?": "¿Eliminar la palabra clave “{name}”?", + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Se quita de {count} foto{}, con las palabras clave que contiene. Deshacer la recupera." } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 588575c4b..d368e72eb 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1738,5 +1738,16 @@ "Keyword List": "Lista de palabras clave", "Filter Keywords": "Filtrar palabras clave", "No keywords yet": "Aún no hay palabras clave", - "No keywords match": "Ninguna palabra clave coincide" + "No keywords match": "Ninguna palabra clave coincide", + "Keyword Name": "Nombre de la palabra clave", + "Synonyms": "Sinónimos", + "Separated by commas": "Separados por comas", + "Keyword Tag Options": "Opciones de palabra clave", + "Include on Export": "Incluir al exportar", + "Export Containing Keywords": "Exportar palabras clave contenedoras", + "Export Synonyms": "Exportar sinónimos", + "Create Keyword Tag": "Crear palabra clave", + "Edit Keyword Tag": "Editar palabra clave", + "Create": "Crear", + "No photo has it; the keywords below it go too.": "Ninguna foto la tiene; las palabras clave que contiene también se eliminan." } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index b0e0f2f99..fb5c8bbf3 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -206,5 +206,8 @@ "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", "Running on {}.": "Running on {}.", "{file} · {size}": "{file} · {size}", - "• {assumption}": "• {assumption}" + "• {assumption}": "• {assumption}", + "Put inside “{parent}”": "「{parent}」の中に入れる", + "Delete the keyword “{name}”?": "キーワード「{name}」を削除しますか?", + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "写真{count}枚から下位のキーワードとともに削除されます。取り消すと元に戻ります。{:.0}" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index f6504989d..c13be07fd 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1738,5 +1738,16 @@ "Keyword List": "キーワードリスト", "Filter Keywords": "キーワードを絞り込む", "No keywords yet": "キーワードはまだありません", - "No keywords match": "一致するキーワードはありません" + "No keywords match": "一致するキーワードはありません", + "Keyword Name": "キーワード名", + "Synonyms": "同義語", + "Separated by commas": "カンマ区切り", + "Keyword Tag Options": "キーワードタグのオプション", + "Include on Export": "書き出し時に含める", + "Export Containing Keywords": "親キーワードを書き出す", + "Export Synonyms": "同義語を書き出す", + "Create Keyword Tag": "キーワードタグを作成", + "Edit Keyword Tag": "キーワードタグを編集", + "Create": "作成", + "No photo has it; the keywords below it go too.": "このキーワードを持つ写真はありません。下位のキーワードも削除されます。" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index ab64e082d..23c3c759f 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -206,5 +206,8 @@ "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", "Running on {}.": "Running on {}.", "{file} · {size}": "{file} · {size}", - "• {assumption}": "• {assumption}" + "• {assumption}": "• {assumption}", + "Put inside “{parent}”": "Colocar dentro de “{parent}”", + "Delete the keyword “{name}”?": "Excluir a palavra-chave “{name}”?", + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Ela é removida de {count} foto{}, com as palavras-chave abaixo dela. Desfazer a traz de volta." } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index eda58e989..728433726 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1738,5 +1738,16 @@ "Keyword List": "Lista de palavras-chave", "Filter Keywords": "Filtrar palavras-chave", "No keywords yet": "Ainda não há palavras-chave", - "No keywords match": "Nenhuma palavra-chave corresponde" + "No keywords match": "Nenhuma palavra-chave corresponde", + "Keyword Name": "Nome da palavra-chave", + "Synonyms": "Sinônimos", + "Separated by commas": "Separados por vírgulas", + "Keyword Tag Options": "Opções da palavra-chave", + "Include on Export": "Incluir na exportação", + "Export Containing Keywords": "Exportar palavras-chave que a contêm", + "Export Synonyms": "Exportar sinônimos", + "Create Keyword Tag": "Criar palavra-chave", + "Edit Keyword Tag": "Editar palavra-chave", + "Create": "Criar", + "No photo has it; the keywords below it go too.": "Nenhuma foto a tem; as palavras-chave abaixo dela também são excluídas." } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index f3a0f647b..12ab6b544 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -206,5 +206,8 @@ "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", "Running on {}.": "Running on {}.", "{file} · {size}": "{file} · {size}", - "• {assumption}": "• {assumption}" + "• {assumption}": "• {assumption}", + "Put inside “{parent}”": "Поместить в «{parent}»", + "Delete the keyword “{name}”?": "Удалить ключевое слово «{name}»?", + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Оно будет снято с фото ({count}){:.0} вместе с вложенными ключевыми словами. Отмена вернёт его." } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 3aaf92c57..c8aa367f8 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1411,5 +1411,16 @@ "Keyword List": "Список ключевых слов", "Filter Keywords": "Фильтр ключевых слов", "No keywords yet": "Ключевых слов пока нет", - "No keywords match": "Нет подходящих ключевых слов" + "No keywords match": "Нет подходящих ключевых слов", + "Keyword Name": "Название ключевого слова", + "Synonyms": "Синонимы", + "Separated by commas": "Через запятую", + "Keyword Tag Options": "Параметры ключевого слова", + "Include on Export": "Включать при экспорте", + "Export Containing Keywords": "Экспортировать родительские ключевые слова", + "Export Synonyms": "Экспортировать синонимы", + "Create Keyword Tag": "Создать ключевое слово", + "Edit Keyword Tag": "Изменить ключевое слово", + "Create": "Создать", + "No photo has it; the keywords below it go too.": "Ни у одного фото его нет; вложенные ключевые слова тоже будут удалены." } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index e32c7f667..cbefa4c71 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -206,5 +206,8 @@ "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", "Running on {}.": "Running on {}.", "{file} · {size}": "{file} · {size}", - "• {assumption}": "• {assumption}" + "• {assumption}": "• {assumption}", + "Put inside “{parent}”": "放入“{parent}”中", + "Delete the keyword “{name}”?": "要删除关键字“{name}”吗?", + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "将从 {count} 张照片中移除,其下的关键字也一并移除。撤销可恢复。{:.0}" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 0cbba6d54..684506432 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1738,5 +1738,16 @@ "Keyword List": "关键字列表", "Filter Keywords": "过滤关键字", "No keywords yet": "还没有关键字", - "No keywords match": "没有匹配的关键字" + "No keywords match": "没有匹配的关键字", + "Keyword Name": "关键字名称", + "Synonyms": "同义词", + "Separated by commas": "用逗号分隔", + "Keyword Tag Options": "关键字标记选项", + "Include on Export": "导出时包含", + "Export Containing Keywords": "导出包含的关键字", + "Export Synonyms": "导出同义词", + "Create Keyword Tag": "创建关键字标记", + "Edit Keyword Tag": "编辑关键字标记", + "Create": "创建", + "No photo has it; the keywords below it go too.": "没有照片使用它;其下的关键字也会一并删除。" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 182a7dc6b..501c6f176 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -206,5 +206,8 @@ "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", "Running on {}.": "Running on {}.", "{file} · {size}": "{file} · {size}", - "• {assumption}": "• {assumption}" + "• {assumption}": "• {assumption}", + "Put inside “{parent}”": "放入「{parent}」中", + "Delete the keyword “{name}”?": "要刪除關鍵字「{name}」嗎?", + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "將從 {count} 張照片移除,其下的關鍵字也一併移除。還原可復原。{:.0}" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index d45c83718..5fb1f4a01 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1738,5 +1738,16 @@ "Keyword List": "關鍵字清單", "Filter Keywords": "篩選關鍵字", "No keywords yet": "尚無關鍵字", - "No keywords match": "沒有符合的關鍵字" + "No keywords match": "沒有符合的關鍵字", + "Keyword Name": "關鍵字名稱", + "Synonyms": "同義詞", + "Separated by commas": "以逗號分隔", + "Keyword Tag Options": "關鍵字標記選項", + "Include on Export": "轉存時包含", + "Export Containing Keywords": "轉存包含的關鍵字", + "Export Synonyms": "轉存同義詞", + "Create Keyword Tag": "建立關鍵字標記", + "Edit Keyword Tag": "編輯關鍵字標記", + "Create": "建立", + "No photo has it; the keywords below it go too.": "沒有照片使用它;其下的關鍵字也會一併刪除。" } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 2473996ac..766cd772a 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -82,6 +82,9 @@ 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::KeywordTag { editing: None, .. } => "Create Keyword Tag", + Dialog::KeywordTag { .. } => "Edit Keyword Tag", + Dialog::DeleteKeyword { .. } => "Delete Keyword", Dialog::MergeKeywords { .. } => "Merge Keywords", Dialog::NewSmartAlbum { .. } => "Create Smart Album", Dialog::AllMetadata { .. } => "All Metadata", @@ -711,6 +714,75 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Import { opts } => crate::import::body(app, ui, opts), Dialog::Settings { tab } => crate::panels::settings::body(app, ui, tab), Dialog::SamModel { error, .. } => sam_model_body(app, ui, error.as_deref()), + Dialog::KeywordTag { + editing, + name, + parent, + inside, + synonyms, + include_on_export, + export_containing, + export_synonyms, + person, + add_to_selected, + } => { + ui.label(egui::RichText::new(crate::i18n::tr("Keyword Name")).color(t.text_dim)); + // several fields: the name takes the focus when the dialog opens, not after + let r = crate::text_field::TextField::singleline("field:keywordTagName", name) + .hint(crate::i18n::tr("Keyword Name")) + .width(f32::INFINITY) + .select_on_focus(true) + .show(ui); + if just_opened(ui, "keyword-tag") { + r.response.request_focus(); + } + if r.ending == Some(crate::text_field::Ending::Return) { + confirm = true; + } + ui.add_space(4.0); + ui.label(egui::RichText::new(crate::i18n::tr("Synonyms")).color(t.text_dim)); + crate::text_field::TextField::singleline("field:keywordSynonyms", synonyms) + .hint(crate::i18n::tr("Separated by commas")) + .width(f32::INFINITY) + .show(ui); + ui.add_space(6.0); + if editing.is_none() { + if let Some(p) = parent { + let r = ui.checkbox(inside, crate::i18n::tr_format!("Put inside “{parent}”", parent = p.replace('|', " › "))); + crate::widgets::register(ui.ctx(), "check:keywordInside", r.rect); + } + let n = app.session.selection.ids.len(); + let r = ui.add_enabled(n > 0, egui::Checkbox::new(add_to_selected, crate::i18n::tr("Add to Selected Photos"))); + crate::widgets::register(ui.ctx(), "check:keywordAddToSelected", r.rect); + ui.add_space(4.0); + } + ui.label(egui::RichText::new(crate::i18n::tr("Keyword Tag Options")).color(t.text_dim)); + for (on, label, id) in [ + (include_on_export, "Include on Export", "check:keywordIncludeOnExport"), + (export_containing, "Export Containing Keywords", "check:keywordExportContaining"), + (export_synonyms, "Export Synonyms", "check:keywordExportSynonyms"), + (person, "Person", "check:keywordPerson"), + ] { + let r = ui.checkbox(on, crate::i18n::tr(label)); + crate::widgets::register(ui.ctx(), id, r.rect); + } + } + Dialog::DeleteKeyword { keyword, count } => { + let name = keyword.replace('|', " › "); + ui.label(crate::i18n::tr_format!("Delete the keyword “{name}”?", name = name)); + ui.label( + egui::RichText::new(if *count == 0 { + crate::i18n::tr("No photo has it; the keywords below it go too.").to_string() + } else { + crate::i18n::tr_format!( + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.", + if *count == 1 { "" } else { "s" }, + count = count + ) + }) + .color(t.text_dim), + ); + } Dialog::ConfirmDelete { count } => { let what = if *count == 1 { crate::i18n::tr("this photo").to_string() } else { crate::i18n::tr_format!("these {count} photos", count = count) }; ui.label(crate::i18n::tr_format!("Move {what} to Recently Deleted?", what = what)); @@ -814,7 +886,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::DenoiseModel { .. } => "Install", Dialog::FaceModel { info, .. } if !informational && info["download"].is_string() => "Download", Dialog::FaceModel { .. } if !informational => "Install", - Dialog::ConfirmDelete { .. } => "Delete", + Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } => "Delete", + Dialog::KeywordTag { editing: None, .. } => "Create", + Dialog::KeywordTag { .. } => "Save", Dialog::RemoveFolder { .. } => "Remove", Dialog::SamModel { then: Some(_), .. } if sam_installed => "Continue", Dialog::SamModel { .. } if sam_installed || sam_running || sam_nowhere => "", @@ -861,6 +935,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { | Dialog::NewAlbum { .. } | Dialog::NewSmartAlbum { .. } | Dialog::SmartRules { .. } + | Dialog::KeywordTag { .. } ) => { app.toast(ctx, e) @@ -1053,6 +1128,40 @@ 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::KeywordTag { + editing, + name, + parent, + inside, + synonyms, + include_on_export, + export_containing, + export_synonyms, + person, + add_to_selected, + } => { + let mut params = json!({ + "synonyms": synonyms.split(',').map(str::trim).filter(|s| !s.is_empty()).collect::>(), + "includeOnExport": include_on_export, + "exportContaining": export_containing, + "exportSynonyms": export_synonyms, + "person": person, + }); + match editing { + Some(keyword) => { + params["keyword"] = json!(keyword); + params["name"] = json!(name); + app.run("keyword.edit", params) + } + None => { + params["name"] = json!(name); + params["parent"] = if *inside { json!(parent) } else { serde_json::Value::Null }; + params["addToSelected"] = json!(add_to_selected); + app.run("keyword.create", params) + } + } + } + Dialog::DeleteKeyword { keyword, .. } => app.run("keyword.delete", json!({"keyword": keyword})), Dialog::MergeKeywords { from, into } => app.run("keyword.merge", json!({"from": from, "into": into})), Dialog::AutoStack { gap } => app.run("stack.auto", json!({"gap": gap})), Dialog::DenoiseModel { info, accepted } => crate::panels::denoise::install(app, info, *accepted), @@ -1295,3 +1404,13 @@ fn dialog_field(ui: &mut egui::Ui, widget: &str, text: &mut String, hint: &str) } r.ending == Some(crate::text_field::Ending::Return) } + +/// The dialog part `key` is drawn this frame but wasn't the frame before: the dialog has just +/// opened (to give its first field the focus once). +fn just_opened(ui: &egui::Ui, key: &str) -> bool { + let id = egui::Id::new(("dialog-shown", key)); + let pass = ui.ctx().cumulative_pass_nr(); + let last: Option = ui.data_mut(|d| d.get_temp(id)); + ui.data_mut(|d| d.insert_temp(id, pass)); + last.and_then(|l| l.checked_add(1)) != Some(pass) +} diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 4df3bb6be..ac12459f2 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -3,11 +3,12 @@ //! keyword gives it to the selected photos or takes it away; the arrow shows the photos with it. use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; -use lightcraft_catalog::keywords::{KeywordNode, same}; +use lightcraft_catalog::keywords::{KeywordInfo, KeywordNode, same}; use lightcraft_catalog::{Catalog, PhotoId}; use serde_json::json; use crate::LightcraftApp; +use crate::state::Dialog; use crate::theme::Tokens; use crate::widgets::register; @@ -26,8 +27,22 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.add_space(24.0); crate::text_field::TextField::singleline("field:keywordFilter", &mut filter) .hint(crate::i18n::tr("Filter Keywords")) - .width(ui.available_width() - 22.0) + .width(ui.available_width() - 82.0) .show(ui); + // + creates a keyword (inside the picked one), − deletes the picked one + let plus = ui.button("+").on_hover_text(crate::i18n::tr("Create Keyword Tag")); + register(ui.ctx(), "keywordList:create", plus.rect); + if plus.clicked() { + app.ui.dialog = Some(create_dialog(app)); + } + let picked = app.ui.keyword_list_selected.clone().filter(|k| app.session.catalog.has_keyword(k)); + let minus = ui.add_enabled(picked.is_some(), egui::Button::new("−")).on_hover_text(crate::i18n::tr("Delete Keyword")); + register(ui.ctx(), "keywordList:delete", minus.rect); + if minus.clicked() + && let Some(k) = picked + { + app.ui.dialog = Some(delete_dialog(app, &k)); + } }); ui.data_mut(|d| d.insert_temp(fid, filter.clone())); ui.add_space(6.0); @@ -139,6 +154,57 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId if resp.clicked() { app.ui.keyword_list_selected = Some(r.path.clone()); } + if resp.double_clicked() { + app.ui.dialog = Some(edit_dialog(app, &r.path)); + } +} + +/// Create Keyword Tag: inside the keyword picked in the list, else the default parent. +pub(crate) fn create_dialog(app: &LightcraftApp) -> Dialog { + let parent = app.ui.keyword_list_selected.clone().filter(|k| app.session.catalog.has_keyword(k)).or_else(|| app.session.keyword_parent.clone()); + let d = KeywordInfo::default(); + Dialog::KeywordTag { + editing: None, + name: String::new(), + inside: parent.is_some(), + parent, + synonyms: String::new(), + include_on_export: d.include_on_export, + export_containing: d.export_containing, + export_synonyms: d.export_synonyms, + person: d.person, + add_to_selected: false, + } +} + +/// Edit Keyword Tag for `path`, showing its name and attributes. +pub(crate) fn edit_dialog(app: &LightcraftApp, path: &str) -> Dialog { + let path = app.session.catalog.keyword_path(path).unwrap_or_else(|| path.to_string()); + let info = app.session.catalog.keyword_info(&path).cloned().unwrap_or_default(); + Dialog::KeywordTag { + name: path.rsplit('|').next().unwrap_or(&path).to_string(), + editing: Some(path), + parent: None, + inside: false, + synonyms: info.synonyms.join(", "), + include_on_export: info.include_on_export, + export_containing: info.export_containing, + export_synonyms: info.export_synonyms, + person: info.person, + add_to_selected: false, + } +} + +/// Delete Keyword, saying how many photos have it. +pub(crate) fn delete_dialog(app: &LightcraftApp, path: &str) -> Dialog { + let keyword = app.session.catalog.keyword_path(path).unwrap_or_else(|| path.to_string()); + let count = app + .session + .catalog + .photos() + .filter(|p| p.in_library() && p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::is_under(k, &keyword))) + .count(); + Dialog::DeleteKeyword { keyword, count } } /// One row of the Keyword List as drawn. diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 8974fc300..373ecfd3e 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -562,6 +562,30 @@ pub enum Dialog { from: String, to: String, }, + /// Create a keyword, or edit one (`editing`): Lightroom Classic's Create / Edit Keyword Tag. + KeywordTag { + /// The keyword being edited; `None`: a new one. + editing: Option, + name: String, + /// A new keyword goes inside this one when `inside` is on (the keyword picked in the + /// list, or the default parent). + parent: Option, + inside: bool, + /// Comma-separated. + synonyms: String, + include_on_export: bool, + export_containing: bool, + export_synonyms: bool, + person: bool, + /// A new keyword is given to the selected photos. + add_to_selected: bool, + }, + /// Delete a keyword from every photo and the keyword list, after asking. + DeleteKeyword { + keyword: String, + /// Photos that have it (or one below it). + count: usize, + }, /// Merge keywords into another one on every photo. MergeKeywords { from: Vec, diff --git a/crates/ui-egui/src/tests_keyword_list.rs b/crates/ui-egui/src/tests_keyword_list.rs index b0294dc15..9e08dcf36 100644 --- a/crates/ui-egui/src/tests_keyword_list.rs +++ b/crates/ui-egui/src/tests_keyword_list.rs @@ -98,3 +98,82 @@ fn the_arrow_shows_the_photos_with_the_keyword() { ask(&mut h, "ui.clickWidget", json!({"id": "keywordShow:mountains"})); assert_eq!(h.app.session.filter.keyword.as_deref(), Some("mountains")); } + +fn find(h: &mut Headless, text: &str) { + ask(h, "ui.clickWidget", json!({"id": "field:keywordFilter"})); + ask(h, "ui.key", json!({"key": "A", "cmd": true})); + ask(h, "ui.text", json!({"text": text})); +} + +fn info(h: &mut Headless, keyword: &str) -> serde_json::Value { + h.app.session.execute("keyword.info", &json!({"keyword": keyword})).unwrap_or(serde_json::Value::Null) +} + +/// The + button creates a keyword (Create Keyword Tag): inside the keyword picked in the list, with +/// synonyms, and given to the selected photos when asked; Return creates it. +#[test] +fn plus_creates_a_keyword_inside_the_picked_one() { + let (mut h, ids) = keywords_panel(); + find(&mut h, "mountains"); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordRow:mountains"})); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordList:create"})); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::KeywordTag { editing: None, .. })), "{:?}", h.app.ui.dialog); + ask(&mut h, "ui.text", json!({"text": "Alps"})); + ask(&mut h, "ui.clickWidget", json!({"id": "field:keywordSynonyms"})); + ask(&mut h, "ui.text", json!({"text": "peaks, summits"})); + ask(&mut h, "ui.clickWidget", json!({"id": "check:keywordAddToSelected"})); + ask(&mut h, "ui.clickWidget", json!({"id": "field:keywordTagName"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert_eq!(h.app.ui.dialog, None); + let i = info(&mut h, "mountains|Alps"); + assert_eq!((i["listed"].as_bool(), i["synonyms"].clone()), (Some(true), json!(["peaks", "summits"])), "{i}"); + assert!(ids.iter().all(|id| keywords_of(&h, *id).contains(&"mountains|Alps".to_string())), "given to the selection"); +} + +/// Double-clicking a keyword edits it (Edit Keyword Tag): the dialog shows what it is, and a new +/// name and an option change apply together, in one undo step. +#[test] +fn double_clicking_a_keyword_edits_it() { + let (mut h, _) = keywords_panel(); + run(&mut h, "keyword.create", json!({"name": "Weddings", "synonyms": ["marriage"]})); + find(&mut h, "wedd"); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordRow:Weddings", "count": 2})); + let Some(crate::state::Dialog::KeywordTag { editing, name, synonyms, .. }) = h.app.ui.dialog.clone() else { panic!("{:?}", h.app.ui.dialog) }; + assert_eq!((editing.as_deref(), name.as_str(), synonyms.as_str()), (Some("Weddings"), "Weddings", "marriage")); + let undo = h.app.session.undo.len(); + ask(&mut h, "ui.text", json!({"text": "Marriages"})); + ask(&mut h, "ui.clickWidget", json!({"id": "check:keywordIncludeOnExport"})); + ask(&mut h, "ui.dialog.confirm", json!({})); + let i = info(&mut h, "Marriages"); + assert_eq!((i["includeOnExport"].as_bool(), i["synonyms"].clone()), (Some(false), json!(["marriage"])), "{i}"); + assert_eq!(h.app.session.undo.len(), undo + 1, "one undo step"); +} + +/// The − button deletes the picked keyword after asking how many photos lose it; Cancel keeps it, and undo +/// brings a deleted one back. +#[test] +fn minus_deletes_a_keyword_after_asking() { + let (mut h, _) = keywords_panel(); + find(&mut h, "mountains"); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordRow:mountains"})); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordList:delete"})); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::DeleteKeyword { .. })), "{:?}", h.app.ui.dialog); + ask(&mut h, "ui.dialog.cancel", json!({})); + assert!(h.app.session.catalog.has_keyword("mountains"), "Cancel kept it"); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordList:delete"})); + ask(&mut h, "ui.clickWidget", json!({"id": "button:dialogOk"})); + assert!(!h.app.session.catalog.has_keyword("mountains")); + run(&mut h, "edit.undo", json!({})); + assert!(h.app.session.catalog.has_keyword("mountains")); +} + +/// Creating a keyword that exists says so, and the dialog stays open with what was typed. +#[test] +fn creating_a_keyword_that_exists_keeps_the_dialog() { + let (mut h, _) = keywords_panel(); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordList:create"})); + ask(&mut h, "ui.text", json!({"text": "mountains"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::KeywordTag { .. })), "still open"); + assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.0.contains("already")), "{:?}", h.app.ui.toast); +} From c01eaadbf36a993b0b8c1a64af0608eacd598b89 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:27:22 -0400 Subject: [PATCH 12/36] LRC-LIB-KEYWORDS: the parent of a listed keyword is a keyword too "Events" of a listed "Events|Weddings" is in the tree, but `has_keyword` and `keyword_path` only looked at photos and the listing itself: it couldn't be renamed, moved or made the default parent. They now find a keyword through the listed keywords below it as well. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/keywords.rs | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index d297d34b3..bf2aee088 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -311,10 +311,9 @@ impl Catalog { out } - /// The keyword is in the tree: listed, or on a photo (itself or one below it). + /// The keyword is in the tree: listed or on a photo, itself or one below it. pub fn has_keyword(&self, path: &str) -> bool { - let k = clean(path); - !k.is_empty() && (self.keyword_info(&k).is_some() || self.photos().any(|p| p.meta.keywords.iter().any(|x| is_under(x, &k)))) + self.keyword_path(path).is_some() } /// The keyword as written in the library (whatever the case of `path`): its listing, or the @@ -327,9 +326,11 @@ impl Catalog { if let Some(l) = self.keyword_list.get(&k.to_lowercase()) { return Some(l.path.clone()); } + // the levels of a listed keyword below it, or of a photo's let levels = k.split(SEP).count(); - self.photos() - .flat_map(|p| p.meta.keywords.iter()) + let listed = self.keyword_list.values().map(|l| &l.path); + listed + .chain(self.photos().flat_map(|p| p.meta.keywords.iter())) .find(|x| is_under(x, &k)) .map(|x| x.split(SEP).map(str::trim).filter(|s| !s.is_empty()).take(levels).collect::>().join("|")) } @@ -799,6 +800,19 @@ mod tests { assert!(c.create_keyword_ops("ärzte", KeywordInfo::default(), &[]).is_err(), "exists"); } + /// A keyword that is only the parent of a listed one ("Events" of "Events|Weddings") is a + /// keyword like any other: it is in the tree, so it can be found, moved and renamed. + #[test] + fn the_parent_of_a_listed_keyword_is_a_keyword() { + let mut c = Catalog::new(); + c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); + assert!(c.has_keyword("events")); + assert_eq!(c.keyword_path("EVENTS").as_deref(), Some("Events")); + let op = c.edit_keyword_ops("events", "Occasions", KeywordInfo::default()).unwrap(); + c.apply(op).unwrap(); + assert_eq!(listed(&c).iter().map(|(p, _)| p.as_str()).collect::>(), ["Occasions", "Occasions|Weddings"]); + } + #[test] fn suggestions_rank_co_occurrence_and_prefixes() { let (c, _) = lib(&[&["dog", "park"], &["dog", "park", "ball"], &["dog", "beach"], &["cat"], &["cat"], &["cat"], &["parade"], &["eagle"]]); From f1c513c20ce9da2701726a25f97e2e7e2c1a75ee Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:27:22 -0400 Subject: [PATCH 13/36] LRC-LIB-KEYWORDS: a keyword's context menu in the Keyword List Right-clicking a keyword offers Create Keyword Tag Inside it, Edit Keyword Tag, Put New Keywords Inside This Keyword (the list marks that keyword with a dot), Add to / Remove from Selected Photos, Show Photos with Keyword, Purge Unused Keywords and Delete Keyword. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de-formats.json | 3 +- crates/ui-egui/locales/de.json | 6 ++- crates/ui-egui/locales/es-formats.json | 3 +- crates/ui-egui/locales/es.json | 6 ++- crates/ui-egui/locales/ja-formats.json | 3 +- crates/ui-egui/locales/ja.json | 6 ++- crates/ui-egui/locales/pt-br-formats.json | 3 +- crates/ui-egui/locales/pt-br.json | 6 ++- crates/ui-egui/locales/ru-formats.json | 3 +- crates/ui-egui/locales/ru.json | 6 ++- crates/ui-egui/locales/zh-hans-formats.json | 3 +- crates/ui-egui/locales/zh-hans.json | 6 ++- crates/ui-egui/locales/zh-hant-formats.json | 3 +- crates/ui-egui/locales/zh-hant.json | 6 ++- crates/ui-egui/src/panels/keyword_list.rs | 54 ++++++++++++++++++++- crates/ui-egui/src/tests_keyword_list.rs | 27 +++++++++++ 16 files changed, 129 insertions(+), 15 deletions(-) diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 6babc6e9e..9b42ac141 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -209,5 +209,6 @@ "• {assumption}": "• {assumption}", "Put inside “{parent}”": "In „{parent}“ einfügen", "Delete the keyword “{name}”?": "Das Stichwort „{name}“ löschen?", - "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Es wird von {count} Foto{} entfernt, mit den Stichwörtern darunter. Rückgängig stellt es wieder her." + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Es wird von {count} Foto{} entfernt, mit den Stichwörtern darunter. Rückgängig stellt es wieder her.", + "Create Keyword Tag Inside “{name}”…": "Stichwort in „{name}“ erstellen…" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 1cfc9bcac..aa456e1e4 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1904,5 +1904,9 @@ "Create Keyword Tag": "Stichwort erstellen", "Edit Keyword Tag": "Stichwort bearbeiten", "Create": "Erstellen", - "No photo has it; the keywords below it go too.": "Kein Foto hat es; die Stichwörter darunter werden ebenfalls gelöscht." + "No photo has it; the keywords below it go too.": "Kein Foto hat es; die Stichwörter darunter werden ebenfalls gelöscht.", + "Edit Keyword Tag…": "Stichwort bearbeiten…", + "Put New Keywords Inside This Keyword": "Neue Stichwörter in dieses Stichwort einfügen", + "Purge Unused Keywords": "Nicht verwendete Stichwörter löschen", + "Delete Keyword…": "Stichwort löschen…" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index da9c85435..5be99a4af 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -209,5 +209,6 @@ "• {assumption}": "• {assumption}", "Put inside “{parent}”": "Poner dentro de “{parent}”", "Delete the keyword “{name}”?": "¿Eliminar la palabra clave “{name}”?", - "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Se quita de {count} foto{}, con las palabras clave que contiene. Deshacer la recupera." + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Se quita de {count} foto{}, con las palabras clave que contiene. Deshacer la recupera.", + "Create Keyword Tag Inside “{name}”…": "Crear palabra clave dentro de “{name}”…" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index d368e72eb..476fc8e16 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1749,5 +1749,9 @@ "Create Keyword Tag": "Crear palabra clave", "Edit Keyword Tag": "Editar palabra clave", "Create": "Crear", - "No photo has it; the keywords below it go too.": "Ninguna foto la tiene; las palabras clave que contiene también se eliminan." + "No photo has it; the keywords below it go too.": "Ninguna foto la tiene; las palabras clave que contiene también se eliminan.", + "Edit Keyword Tag…": "Editar palabra clave…", + "Put New Keywords Inside This Keyword": "Poner las nuevas palabras clave dentro de esta", + "Purge Unused Keywords": "Eliminar palabras clave no usadas", + "Delete Keyword…": "Eliminar palabra clave…" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index fb5c8bbf3..587a8f421 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -209,5 +209,6 @@ "• {assumption}": "• {assumption}", "Put inside “{parent}”": "「{parent}」の中に入れる", "Delete the keyword “{name}”?": "キーワード「{name}」を削除しますか?", - "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "写真{count}枚から下位のキーワードとともに削除されます。取り消すと元に戻ります。{:.0}" + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "写真{count}枚から下位のキーワードとともに削除されます。取り消すと元に戻ります。{:.0}", + "Create Keyword Tag Inside “{name}”…": "「{name}」の中にキーワードタグを作成…" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index c13be07fd..f165ecf23 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1749,5 +1749,9 @@ "Create Keyword Tag": "キーワードタグを作成", "Edit Keyword Tag": "キーワードタグを編集", "Create": "作成", - "No photo has it; the keywords below it go too.": "このキーワードを持つ写真はありません。下位のキーワードも削除されます。" + "No photo has it; the keywords below it go too.": "このキーワードを持つ写真はありません。下位のキーワードも削除されます。", + "Edit Keyword Tag…": "キーワードタグを編集…", + "Put New Keywords Inside This Keyword": "新しいキーワードをこのキーワードの中に入れる", + "Purge Unused Keywords": "使用されていないキーワードを削除", + "Delete Keyword…": "キーワードを削除…" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 23c3c759f..4181dc253 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -209,5 +209,6 @@ "• {assumption}": "• {assumption}", "Put inside “{parent}”": "Colocar dentro de “{parent}”", "Delete the keyword “{name}”?": "Excluir a palavra-chave “{name}”?", - "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Ela é removida de {count} foto{}, com as palavras-chave abaixo dela. Desfazer a traz de volta." + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Ela é removida de {count} foto{}, com as palavras-chave abaixo dela. Desfazer a traz de volta.", + "Create Keyword Tag Inside “{name}”…": "Criar palavra-chave dentro de “{name}”…" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 728433726..5da1ec6bd 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1749,5 +1749,9 @@ "Create Keyword Tag": "Criar palavra-chave", "Edit Keyword Tag": "Editar palavra-chave", "Create": "Criar", - "No photo has it; the keywords below it go too.": "Nenhuma foto a tem; as palavras-chave abaixo dela também são excluídas." + "No photo has it; the keywords below it go too.": "Nenhuma foto a tem; as palavras-chave abaixo dela também são excluídas.", + "Edit Keyword Tag…": "Editar palavra-chave…", + "Put New Keywords Inside This Keyword": "Colocar novas palavras-chave dentro desta", + "Purge Unused Keywords": "Excluir palavras-chave não usadas", + "Delete Keyword…": "Excluir palavra-chave…" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 12ab6b544..6a64e0714 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -209,5 +209,6 @@ "• {assumption}": "• {assumption}", "Put inside “{parent}”": "Поместить в «{parent}»", "Delete the keyword “{name}”?": "Удалить ключевое слово «{name}»?", - "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Оно будет снято с фото ({count}){:.0} вместе с вложенными ключевыми словами. Отмена вернёт его." + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Оно будет снято с фото ({count}){:.0} вместе с вложенными ключевыми словами. Отмена вернёт его.", + "Create Keyword Tag Inside “{name}”…": "Создать ключевое слово в «{name}»…" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c8aa367f8..3ec2d67f5 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1422,5 +1422,9 @@ "Create Keyword Tag": "Создать ключевое слово", "Edit Keyword Tag": "Изменить ключевое слово", "Create": "Создать", - "No photo has it; the keywords below it go too.": "Ни у одного фото его нет; вложенные ключевые слова тоже будут удалены." + "No photo has it; the keywords below it go too.": "Ни у одного фото его нет; вложенные ключевые слова тоже будут удалены.", + "Edit Keyword Tag…": "Изменить ключевое слово…", + "Put New Keywords Inside This Keyword": "Помещать новые ключевые слова в это", + "Purge Unused Keywords": "Удалить неиспользуемые ключевые слова", + "Delete Keyword…": "Удалить ключевое слово…" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index cbefa4c71..8034165b2 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -209,5 +209,6 @@ "• {assumption}": "• {assumption}", "Put inside “{parent}”": "放入“{parent}”中", "Delete the keyword “{name}”?": "要删除关键字“{name}”吗?", - "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "将从 {count} 张照片中移除,其下的关键字也一并移除。撤销可恢复。{:.0}" + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "将从 {count} 张照片中移除,其下的关键字也一并移除。撤销可恢复。{:.0}", + "Create Keyword Tag Inside “{name}”…": "在“{name}”中创建关键字标记…" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 684506432..b556d5fca 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1749,5 +1749,9 @@ "Create Keyword Tag": "创建关键字标记", "Edit Keyword Tag": "编辑关键字标记", "Create": "创建", - "No photo has it; the keywords below it go too.": "没有照片使用它;其下的关键字也会一并删除。" + "No photo has it; the keywords below it go too.": "没有照片使用它;其下的关键字也会一并删除。", + "Edit Keyword Tag…": "编辑关键字标记…", + "Put New Keywords Inside This Keyword": "将新关键字放入此关键字中", + "Purge Unused Keywords": "清除未使用的关键字", + "Delete Keyword…": "删除关键字…" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 501c6f176..368fe1a90 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -209,5 +209,6 @@ "• {assumption}": "• {assumption}", "Put inside “{parent}”": "放入「{parent}」中", "Delete the keyword “{name}”?": "要刪除關鍵字「{name}」嗎?", - "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "將從 {count} 張照片移除,其下的關鍵字也一併移除。還原可復原。{:.0}" + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "將從 {count} 張照片移除,其下的關鍵字也一併移除。還原可復原。{:.0}", + "Create Keyword Tag Inside “{name}”…": "在「{name}」中建立關鍵字標記…" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 5fb1f4a01..8e76acca0 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1749,5 +1749,9 @@ "Create Keyword Tag": "建立關鍵字標記", "Edit Keyword Tag": "編輯關鍵字標記", "Create": "建立", - "No photo has it; the keywords below it go too.": "沒有照片使用它;其下的關鍵字也會一併刪除。" + "No photo has it; the keywords below it go too.": "沒有照片使用它;其下的關鍵字也會一併刪除。", + "Edit Keyword Tag…": "編輯關鍵字標記…", + "Put New Keywords Inside This Keyword": "將新關鍵字放入此關鍵字中", + "Purge Unused Keywords": "清除未使用的關鍵字", + "Delete Keyword…": "刪除關鍵字…" } diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index ac12459f2..e31d8e99e 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -149,7 +149,13 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId let font = t.font(13.0); let measure = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), t.text_label).size().x; let name = if measure(&r.name) <= room { r.name.clone() } else { crate::widgets::elide_head(&r.name, room, measure) }; - ui.painter().text(pos2(x + 34.0, cy), Align2::LEFT_CENTER, name, font.clone(), if picked { t.text } else { t.text_label }); + let label = ui.painter().text(pos2(x + 34.0, cy), Align2::LEFT_CENTER, name, font.clone(), if picked { t.text } else { t.text_label }); + // where new keywords go (Put New Keywords Inside This Keyword): a dot after the name + if app.session.keyword_parent.as_deref().is_some_and(|k| same(k, &r.path)) { + let dot = Rect::from_center_size(pos2(label.right() + 7.0, cy), vec2(6.0, 6.0)); + ui.painter().circle_filled(dot.center(), 3.0, t.accent); + register(ui.ctx(), format!("keywordDefault:{}", r.path), dot); + } let resp = resp.on_hover_text(r.path.replace('|', " › ")); if resp.clicked() { app.ui.keyword_list_selected = Some(r.path.clone()); @@ -157,6 +163,52 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId if resp.double_clicked() { app.ui.dialog = Some(edit_dialog(app, &r.path)); } + resp.context_menu(|ui| menu(app, ui, &r.path, selection)); +} + +/// A keyword's context menu. +fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str, selection: &[PhotoId]) { + let name = path.replace('|', " › "); + let item = |ui: &mut egui::Ui, id: &str, label: &str, enabled: bool| { + let r = ui.add_enabled(enabled, egui::Button::new(label)); + register(ui.ctx(), format!("keywordMenu:{id}"), r.rect); + r.clicked() + }; + if item(ui, "create", &crate::i18n::tr_format!("Create Keyword Tag Inside “{name}”…", name = name), true) { + let mut d = create_dialog(app); + if let Dialog::KeywordTag { parent, inside, .. } = &mut d { + *parent = Some(path.to_string()); + *inside = true; + } + app.ui.dialog = Some(d); + } + if item(ui, "edit", crate::i18n::tr("Edit Keyword Tag…"), true) { + app.ui.dialog = Some(edit_dialog(app, path)); + } + let is_default = app.session.keyword_parent.as_deref().is_some_and(|k| same(k, path)); + let label = format!("{}{}", if is_default { "✓ " } else { "" }, crate::i18n::tr("Put New Keywords Inside This Keyword")); + if item(ui, "defaultParent", &label, true) { + let _ = app.run("keyword.setDefaultParent", json!({"keyword": if is_default { serde_json::Value::Null } else { json!(path) }})); + } + ui.separator(); + let some = !selection.is_empty(); + if item(ui, "add", crate::i18n::tr("Add to Selected Photos"), some) { + let _ = app.run("photo.setMeta", json!({"addKeywords": [path]})); + } + if item(ui, "remove", crate::i18n::tr("Remove from Selected Photos"), some) { + let _ = app.run("photo.setMeta", json!({"removeKeywords": [path]})); + } + if item(ui, "show", crate::i18n::tr("Show Photos with Keyword"), true) { + super::left::browse_all_photos(app, true); + let _ = app.run("library.filter", json!({"keyword": path})); + } + ui.separator(); + if item(ui, "purge", crate::i18n::tr("Purge Unused Keywords"), true) { + let _ = app.run("keyword.purgeUnused", json!({})); + } + if item(ui, "delete", crate::i18n::tr("Delete Keyword…"), true) { + app.ui.dialog = Some(delete_dialog(app, path)); + } } /// Create Keyword Tag: inside the keyword picked in the list, else the default parent. diff --git a/crates/ui-egui/src/tests_keyword_list.rs b/crates/ui-egui/src/tests_keyword_list.rs index 9e08dcf36..3b4b5668f 100644 --- a/crates/ui-egui/src/tests_keyword_list.rs +++ b/crates/ui-egui/src/tests_keyword_list.rs @@ -177,3 +177,30 @@ fn creating_a_keyword_that_exists_keeps_the_dialog() { assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::KeywordTag { .. })), "still open"); assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.0.contains("already")), "{:?}", h.app.ui.toast); } + +/// A keyword's context menu: create a keyword inside it, edit it, make it where new keywords go +/// (marked in the list), and purge the keywords no photo has. +#[test] +fn a_keywords_menu_offers_its_actions() { + let (mut h, _) = keywords_panel(); + run(&mut h, "keyword.create", json!({"name": "Weddings", "parent": "Events"})); + find(&mut h, "events"); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordRow:Events", "button": "right"})); + for item in ["create", "edit", "defaultParent", "purge", "delete"] { + assert!(has(&h, &format!("keywordMenu:{item}")), "{item}"); + } + ask(&mut h, "ui.clickWidget", json!({"id": "keywordMenu:defaultParent"})); + assert_eq!(h.app.session.keyword_parent.as_deref(), Some("Events")); + assert!(has(&h, "keywordDefault:Events"), "the list marks where new keywords go"); + // Create inside it + ask(&mut h, "ui.clickWidget", json!({"id": "keywordRow:Events", "button": "right"})); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordMenu:create"})); + let Some(crate::state::Dialog::KeywordTag { parent, inside, .. }) = h.app.ui.dialog.clone() else { panic!("{:?}", h.app.ui.dialog) }; + assert_eq!((parent.as_deref(), inside), (Some("Events"), true)); + ask(&mut h, "ui.dialog.cancel", json!({})); + // Purge: Events|Weddings and Events have no photos + ask(&mut h, "ui.clickWidget", json!({"id": "keywordRow:Events", "button": "right"})); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordMenu:purge"})); + assert!(!h.app.session.catalog.has_keyword("Events")); + assert_eq!(h.app.session.keyword_parent, None, "gone with it"); +} From debd2ee2ecff4a1db6a0d0c4909d77045bbc9c2a Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:29:57 -0400 Subject: [PATCH 14/36] LRC-LIB-KEYWORDS: reorganize keywords by drag and drop In the Keyword List: - dragging a keyword onto another nests it there, with the keywords below it and its photos; dropping it on the list's title (which says "Drop here for the top level" during the drag) takes it to the top level; - a keyword is never outlined, nor dropped, inside itself or one of its children; - where a keyword of its name is already, a dialog asks before merging the two; - photos dragged from the grid onto a keyword get it. The dragged keyword's name follows the pointer; the drag ends with the button's release, wherever it is. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de-formats.json | 5 +- crates/ui-egui/locales/de.json | 4 +- crates/ui-egui/locales/es-formats.json | 5 +- crates/ui-egui/locales/es.json | 4 +- crates/ui-egui/locales/ja-formats.json | 5 +- crates/ui-egui/locales/ja.json | 4 +- crates/ui-egui/locales/pt-br-formats.json | 5 +- crates/ui-egui/locales/pt-br.json | 4 +- crates/ui-egui/locales/ru-formats.json | 5 +- crates/ui-egui/locales/ru.json | 4 +- crates/ui-egui/locales/zh-hans-formats.json | 5 +- crates/ui-egui/locales/zh-hans.json | 4 +- crates/ui-egui/locales/zh-hant-formats.json | 5 +- crates/ui-egui/locales/zh-hant.json | 4 +- crates/ui-egui/src/panels/dialogs.rs | 15 +++ crates/ui-egui/src/panels/keyword_list.rs | 107 +++++++++++++++++++- crates/ui-egui/src/state.rs | 10 ++ crates/ui-egui/src/tests_keyword_list.rs | 68 ++++++++++++- 18 files changed, 246 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 9b42ac141..d4ae39838 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -210,5 +210,8 @@ "Put inside “{parent}”": "In „{parent}“ einfügen", "Delete the keyword “{name}”?": "Das Stichwort „{name}“ löschen?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Es wird von {count} Foto{} entfernt, mit den Stichwörtern darunter. Rückgängig stellt es wieder her.", - "Create Keyword Tag Inside “{name}”…": "Stichwort in „{name}“ erstellen…" + "Create Keyword Tag Inside “{name}”…": "Stichwort in „{name}“ erstellen…", + "“{parent}” has a “{name}” already. Merge the two?": "„{parent}“ enthält bereits „{name}“. Beide zusammenführen?", + "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" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index aa456e1e4..cdd96f9dc 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1908,5 +1908,7 @@ "Edit Keyword Tag…": "Stichwort bearbeiten…", "Put New Keywords Inside This Keyword": "Neue Stichwörter in dieses Stichwort einfügen", "Purge Unused Keywords": "Nicht verwendete Stichwörter löschen", - "Delete Keyword…": "Stichwort löschen…" + "Delete Keyword…": "Stichwort löschen…", + "Drop here for the top level": "Hier ablegen für die oberste Ebene", + "Their photos and the keywords below them come together under one keyword.": "Ihre Fotos und die Stichwörter darunter kommen unter einem Stichwort zusammen." } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 5be99a4af..72bec0202 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -210,5 +210,8 @@ "Put inside “{parent}”": "Poner dentro de “{parent}”", "Delete the keyword “{name}”?": "¿Eliminar la palabra clave “{name}”?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Se quita de {count} foto{}, con las palabras clave que contiene. Deshacer la recupera.", - "Create Keyword Tag Inside “{name}”…": "Crear palabra clave dentro de “{name}”…" + "Create Keyword Tag Inside “{name}”…": "Crear palabra clave dentro de “{name}”…", + "“{parent}” has a “{name}” already. Merge the two?": "“{parent}” ya tiene “{name}”. ¿Combinar las dos?", + "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{}" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 476fc8e16..814e0c796 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1753,5 +1753,7 @@ "Edit Keyword Tag…": "Editar palabra clave…", "Put New Keywords Inside This Keyword": "Poner las nuevas palabras clave dentro de esta", "Purge Unused Keywords": "Eliminar palabras clave no usadas", - "Delete Keyword…": "Eliminar palabra clave…" + "Delete Keyword…": "Eliminar palabra clave…", + "Drop here for the top level": "Soltar aquí para el nivel superior", + "Their photos and the keywords below them come together under one keyword.": "Sus fotos y las palabras clave que contienen quedan bajo una sola palabra clave." } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 587a8f421..dd6060b6f 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -210,5 +210,8 @@ "Put inside “{parent}”": "「{parent}」の中に入れる", "Delete the keyword “{name}”?": "キーワード「{name}」を削除しますか?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "写真{count}枚から下位のキーワードとともに削除されます。取り消すと元に戻ります。{:.0}", - "Create Keyword Tag Inside “{name}”…": "「{name}」の中にキーワードタグを作成…" + "Create Keyword Tag Inside “{name}”…": "「{name}」の中にキーワードタグを作成…", + "“{parent}” has a “{name}” already. Merge the two?": "「{parent}」にはすでに「{name}」があります。統合しますか?", + "There is a “{name}” at the top level already. Merge the two?": "最上位にはすでに「{name}」があります。統合しますか?", + "Added “{keyword}” to {n} photo{}": "写真{n}枚に「{keyword}」を追加しました{:.0}" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index f165ecf23..b073b3440 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1753,5 +1753,7 @@ "Edit Keyword Tag…": "キーワードタグを編集…", "Put New Keywords Inside This Keyword": "新しいキーワードをこのキーワードの中に入れる", "Purge Unused Keywords": "使用されていないキーワードを削除", - "Delete Keyword…": "キーワードを削除…" + "Delete Keyword…": "キーワードを削除…", + "Drop here for the top level": "ここにドロップすると最上位に移動", + "Their photos and the keywords below them come together under one keyword.": "写真と下位のキーワードが1つのキーワードにまとめられます。" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 4181dc253..ef31845fc 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -210,5 +210,8 @@ "Put inside “{parent}”": "Colocar dentro de “{parent}”", "Delete the keyword “{name}”?": "Excluir a palavra-chave “{name}”?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Ela é removida de {count} foto{}, com as palavras-chave abaixo dela. Desfazer a traz de volta.", - "Create Keyword Tag Inside “{name}”…": "Criar palavra-chave dentro de “{name}”…" + "Create Keyword Tag Inside “{name}”…": "Criar palavra-chave dentro de “{name}”…", + "“{parent}” has a “{name}” already. Merge the two?": "“{parent}” já tem “{name}”. Mesclar as duas?", + "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{}" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 5da1ec6bd..3e366b9ef 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1753,5 +1753,7 @@ "Edit Keyword Tag…": "Editar palavra-chave…", "Put New Keywords Inside This Keyword": "Colocar novas palavras-chave dentro desta", "Purge Unused Keywords": "Excluir palavras-chave não usadas", - "Delete Keyword…": "Excluir palavra-chave…" + "Delete Keyword…": "Excluir palavra-chave…", + "Drop here for the top level": "Solte aqui para o nível superior", + "Their photos and the keywords below them come together under one keyword.": "Suas fotos e as palavras-chave abaixo delas ficam sob uma só palavra-chave." } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 6a64e0714..f1d4ca2f7 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -210,5 +210,8 @@ "Put inside “{parent}”": "Поместить в «{parent}»", "Delete the keyword “{name}”?": "Удалить ключевое слово «{name}»?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Оно будет снято с фото ({count}){:.0} вместе с вложенными ключевыми словами. Отмена вернёт его.", - "Create Keyword Tag Inside “{name}”…": "Создать ключевое слово в «{name}»…" + "Create Keyword Tag Inside “{name}”…": "Создать ключевое слово в «{name}»…", + "“{parent}” has a “{name}” already. Merge the two?": "В «{parent}» уже есть «{name}». Объединить их?", + "There is a “{name}” at the top level already. Merge the two?": "На верхнем уровне уже есть «{name}». Объединить их?", + "Added “{keyword}” to {n} photo{}": "«{keyword}» добавлено к фото ({n}){:.0}" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 3ec2d67f5..cd3bd4913 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1426,5 +1426,7 @@ "Edit Keyword Tag…": "Изменить ключевое слово…", "Put New Keywords Inside This Keyword": "Помещать новые ключевые слова в это", "Purge Unused Keywords": "Удалить неиспользуемые ключевые слова", - "Delete Keyword…": "Удалить ключевое слово…" + "Delete Keyword…": "Удалить ключевое слово…", + "Drop here for the top level": "Отпустите здесь для верхнего уровня", + "Their photos and the keywords below them come together under one keyword.": "Их фото и вложенные ключевые слова объединятся под одним ключевым словом." } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index 8034165b2..f01ccdf73 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -210,5 +210,8 @@ "Put inside “{parent}”": "放入“{parent}”中", "Delete the keyword “{name}”?": "要删除关键字“{name}”吗?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "将从 {count} 张照片中移除,其下的关键字也一并移除。撤销可恢复。{:.0}", - "Create Keyword Tag Inside “{name}”…": "在“{name}”中创建关键字标记…" + "Create Keyword Tag Inside “{name}”…": "在“{name}”中创建关键字标记…", + "“{parent}” has a “{name}” already. Merge the two?": "“{parent}”中已有“{name}”。要合并两者吗?", + "There is a “{name}” at the top level already. Merge the two?": "顶层已有“{name}”。要合并两者吗?", + "Added “{keyword}” to {n} photo{}": "已将“{keyword}”添加到 {n} 张照片{:.0}" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index b556d5fca..b6e09076c 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1753,5 +1753,7 @@ "Edit Keyword Tag…": "编辑关键字标记…", "Put New Keywords Inside This Keyword": "将新关键字放入此关键字中", "Purge Unused Keywords": "清除未使用的关键字", - "Delete Keyword…": "删除关键字…" + "Delete Keyword…": "删除关键字…", + "Drop here for the top level": "拖放到此处以移至顶层", + "Their photos and the keywords below them come together under one keyword.": "它们的照片和下级关键字将合并到同一个关键字下。" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 368fe1a90..8a4b64558 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -210,5 +210,8 @@ "Put inside “{parent}”": "放入「{parent}」中", "Delete the keyword “{name}”?": "要刪除關鍵字「{name}」嗎?", "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "將從 {count} 張照片移除,其下的關鍵字也一併移除。還原可復原。{:.0}", - "Create Keyword Tag Inside “{name}”…": "在「{name}」中建立關鍵字標記…" + "Create Keyword Tag Inside “{name}”…": "在「{name}」中建立關鍵字標記…", + "“{parent}” has a “{name}” already. Merge the two?": "「{parent}」中已有「{name}」。要合併兩者嗎?", + "There is a “{name}” at the top level already. Merge the two?": "最上層已有「{name}」。要合併兩者嗎?", + "Added “{keyword}” to {n} photo{}": "已將「{keyword}」加入 {n} 張照片{:.0}" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 8e76acca0..af97aac45 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1753,5 +1753,7 @@ "Edit Keyword Tag…": "編輯關鍵字標記…", "Put New Keywords Inside This Keyword": "將新關鍵字放入此關鍵字中", "Purge Unused Keywords": "清除未使用的關鍵字", - "Delete Keyword…": "刪除關鍵字…" + "Delete Keyword…": "刪除關鍵字…", + "Drop here for the top level": "拖放到此處以移至最上層", + "Their photos and the keywords below them come together under one keyword.": "它們的照片與下層關鍵字將合併到同一個關鍵字下。" } diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 766cd772a..199a98583 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -85,6 +85,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::KeywordTag { editing: None, .. } => "Create Keyword Tag", Dialog::KeywordTag { .. } => "Edit Keyword Tag", Dialog::DeleteKeyword { .. } => "Delete Keyword", + Dialog::MoveKeyword { .. } => "Merge Keywords", Dialog::MergeKeywords { .. } => "Merge Keywords", Dialog::NewSmartAlbum { .. } => "Create Smart Album", Dialog::AllMetadata { .. } => "All Metadata", @@ -767,6 +768,18 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { crate::widgets::register(ui.ctx(), id, r.rect); } } + Dialog::MoveKeyword { keyword, parent } => { + let name = keyword.rsplit('|').next().unwrap_or(keyword); + match parent { + Some(p) => ui.label(crate::i18n::tr_format!( + "“{parent}” has a “{name}” already. Merge the two?", + parent = p.replace('|', " › "), + name = name + )), + None => ui.label(crate::i18n::tr_format!("There is a “{name}” at the top level already. Merge the two?", name = name)), + }; + ui.label(egui::RichText::new(crate::i18n::tr("Their photos and the keywords below them come together under one keyword.")).color(t.text_dim)); + } Dialog::DeleteKeyword { keyword, count } => { let name = keyword.replace('|', " › "); ui.label(crate::i18n::tr_format!("Delete the keyword “{name}”?", name = name)); @@ -887,6 +900,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::FaceModel { info, .. } if !informational && info["download"].is_string() => "Download", Dialog::FaceModel { .. } if !informational => "Install", Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } => "Delete", + Dialog::MoveKeyword { .. } => "Merge", Dialog::KeywordTag { editing: None, .. } => "Create", Dialog::KeywordTag { .. } => "Save", Dialog::RemoveFolder { .. } => "Remove", @@ -1162,6 +1176,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("keyword.delete", json!({"keyword": keyword})), + Dialog::MoveKeyword { keyword, parent } => app.run("keyword.move", json!({"keyword": keyword, "parent": parent, "merge": true})), Dialog::MergeKeywords { from, into } => app.run("keyword.merge", json!({"from": from, "into": into})), Dialog::AutoStack { gap } => app.run("stack.auto", json!({"gap": gap})), Dialog::DenoiseModel { info, accepted } => crate::panels::denoise::install(app, info, *accepted), diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index e31d8e99e..04ad6a7f0 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -20,7 +20,23 @@ const INDENT: f32 = 14.0; pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); crate::widgets::divider(ui); - super::right::header(ui, "Keyword List"); + // the title, where a dragged keyword goes back to the top level + let (title, _) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::hover()); + register(ui.ctx(), "keywordList:topLevel", title); + let dragging = app.ui.dragging_keyword.clone(); + let (pointer, released) = ui.input(|i| (i.pointer.latest_pos(), i.pointer.any_released())); + let over_title = pointer.is_some_and(|p| title.contains(p)); + if dragging.is_some() && over_title { + ui.painter().rect_stroke(title.shrink2(vec2(16.0, 6.0)), 4.0, Stroke::new(1.5, t.accent), StrokeKind::Inside); + } + let heading = if dragging.is_some() { crate::i18n::tr("Drop here for the top level") } else { crate::i18n::tr("Keyword List") }; + ui.painter().text(pos2(title.left() + 24.0, title.center().y + 2.0), Align2::LEFT_CENTER, heading, t.semibold(15.0), t.text); + if released + && over_title + && let Some(k) = dragging.clone() + { + drop_keyword(app, ui.ctx(), &k, None); + } let fid = egui::Id::new("keyword-list-filter"); let mut filter: String = ui.data(|d| d.get_temp(fid)).unwrap_or_default(); ui.horizontal(|ui| { @@ -61,14 +77,62 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { row(app, ui, r, &selection, filter.trim().is_empty()); } ui.add_space(12.0); + // the drag ends with the button's release, wherever it is + if app.ui.dragging_keyword.is_some() { + let (released, down) = ui.input(|i| (i.pointer.any_released(), i.pointer.any_down())); + if released || !down { + app.ui.dragging_keyword = None; + } else if let (Some(k), Some(p)) = (app.ui.dragging_keyword.clone(), pointer) { + drag_label(ui.ctx(), &k, p); + } + } +} + +/// The dragged keyword's name, following the pointer. +fn drag_label(ctx: &egui::Context, keyword: &str, at: egui::Pos2) { + let t = Tokens::get(ctx); + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + let name = keyword.rsplit('|').next().unwrap_or(keyword).to_string(); + egui::Area::new(egui::Id::new("drag-keyword")).order(egui::Order::Tooltip).interactable(false).fixed_pos(at + vec2(14.0, 10.0)).show(ctx, |ui| { + egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| { + ui.label(egui::RichText::new(name).color(Color32::WHITE).font(t.semibold(12.0))); + }); + }); +} + +/// Drop the dragged `keyword` inside `parent` (`None`: the top level). Where one of its name is +/// already, ask before merging the two; where it is already, nothing happens. +fn drop_keyword(app: &mut LightcraftApp, ctx: &egui::Context, keyword: &str, parent: Option<&str>) { + app.ui.dragging_keyword = None; + let cat = &app.session.catalog; + let leaf = keyword.rsplit('|').next().unwrap_or(keyword); + let parent = parent.map(|p| cat.keyword_path(p).unwrap_or_else(|| p.to_string())); + if parent.as_deref().is_some_and(|p| lightcraft_catalog::keywords::is_under(p, keyword)) { + return; + } + let to = parent.as_deref().map_or_else(|| leaf.to_string(), |p| format!("{p}|{leaf}")); + if same(&to, keyword) { + return; + } + if cat.has_keyword(&to) { + app.ui.dialog = Some(Dialog::MoveKeyword { keyword: keyword.to_string(), parent }); + return; + } + if let Err(e) = app.run("keyword.move", json!({"keyword": keyword, "parent": parent})) { + app.toast(ctx, e); + } } /// One keyword's row: the triangle, the tick box, the name, the count, and on hover the arrow. /// `can_fold`: the triangle opens and closes the level (not while a filter opens it). fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId], can_fold: bool) { let t = Tokens::get(ui.ctx()); - let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click()); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click_and_drag()); register(ui.ctx(), format!("keywordRow:{}", r.path), rect); + if resp.drag_started() { + app.ui.dragging_keyword = Some(r.path.clone()); + } + drop_target(app, ui, rect, &r.path); let picked = app.ui.keyword_list_selected.as_deref().is_some_and(|k| same(k, &r.path)); let inner = Rect::from_min_max(rect.min + vec2(16.0, 0.0), rect.max - vec2(16.0, 0.0)); if picked { @@ -166,6 +230,45 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId resp.context_menu(|ui| menu(app, ui, &r.path, selection)); } +/// A row while a keyword or photos are dragged: outlined under the pointer when the drop would +/// do something (a keyword never goes inside itself), and a release there does it: nests the +/// keyword inside this one, or gives this keyword to the photos. +fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, rect: Rect, path: &str) { + let (pointer, released) = ui.input(|i| (i.pointer.latest_pos(), i.pointer.any_released())); + if !pointer.is_some_and(|p| rect.contains(p)) { + return; + } + let t = Tokens::get(ui.ctx()); + let outline = |ui: &egui::Ui| ui.painter().rect_stroke(rect.shrink2(vec2(16.0, 1.0)), 4.0, Stroke::new(1.5, t.accent), StrokeKind::Inside); + if let Some(k) = app.ui.dragging_keyword.clone() { + if lightcraft_catalog::keywords::is_under(path, &k) { + return; + } + outline(ui); + if released { + drop_keyword(app, ui.ctx(), &k, Some(path)); + } + } else if let Some(ids) = app.ui.dragging_photos.clone() { + outline(ui); + if released { + let n = ids.len(); + app.ui.dragging_photos = None; + match app.run("photo.setMeta", json!({"ids": ids, "addKeywords": [path]})) { + Ok(_) => app.toast( + ui.ctx(), + crate::i18n::tr_format!( + "Added “{keyword}” to {n} photo{}", + if n == 1 { "" } else { "s" }, + keyword = path.replace('|', " › "), + n = n + ), + ), + Err(e) => app.toast(ui.ctx(), e), + } + } + } +} + /// A keyword's context menu. fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str, selection: &[PhotoId]) { let name = path.replace('|', " › "); diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 373ecfd3e..20fddc3b7 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -454,6 +454,9 @@ pub struct UiState { /// The keyword picked in the Keyword List (− deletes it, Edit edits it). #[serde(skip)] pub keyword_list_selected: Option, + /// A keyword being dragged in the Keyword List (onto another to nest it). + #[serde(skip)] + pub dragging_keyword: Option, /// A running slideshow (full screen): seconds per photo, when the next one is due (egui /// time), paused. #[serde(skip)] @@ -580,6 +583,12 @@ pub enum Dialog { /// A new keyword is given to the selected photos. add_to_selected: bool, }, + /// Move a keyword inside `parent` (`None`: the top level) where one of its name is already: + /// asks before merging the two. + MoveKeyword { + keyword: String, + parent: Option, + }, /// Delete a keyword from every photo and the keyword list, after asking. DeleteKeyword { keyword: String, @@ -788,6 +797,7 @@ impl Default for UiState { keyword_painter: None, keyword_list_open: Vec::new(), keyword_list_selected: None, + dragging_keyword: None, info_overlay: InfoOverlay::Off, navigator: true, settings: AppSettings::default(), diff --git a/crates/ui-egui/src/tests_keyword_list.rs b/crates/ui-egui/src/tests_keyword_list.rs index 3b4b5668f..4d91290e8 100644 --- a/crates/ui-egui/src/tests_keyword_list.rs +++ b/crates/ui-egui/src/tests_keyword_list.rs @@ -13,8 +13,13 @@ const SETTLE: Duration = Duration::from_secs(120); /// The demo library in the Library grid with the Keywords panel open, two photos selected. fn keywords_panel() -> (Headless, Vec) { + keywords_panel_at([1400.0, 1000.0]) +} + +/// [`keywords_panel`] in a window of `size` (tall enough, every keyword row is on screen). +fn keywords_panel_at(size: [f32; 2]) -> (Headless, Vec) { let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); - let mut h = Headless::new(app, [1400.0, 1000.0], 1.0); + let mut h = Headless::new(app, size, 1.0); let ids: Vec = h.app.session.visible_cloned().iter().take(2).map(|p| p.0).collect(); for (method, params) in [ ("ui.set", json!({"view": "photoGrid", "right": "keywords"})), @@ -204,3 +209,64 @@ fn a_keywords_menu_offers_its_actions() { assert!(!h.app.session.catalog.has_keyword("Events")); assert_eq!(h.app.session.keyword_parent, None, "gone with it"); } + +fn center(h: &Headless, id: &str) -> egui::Pos2 { + h.app.widgets.iter().rev().find(|(w, _)| w == id).map(|(_, r)| r.center()).unwrap_or_else(|| panic!("no {id} on screen")) +} + +/// Drag the widget `from` and drop it on the widget `onto`. +fn drag(h: &mut Headless, from: &str, onto: &str) { + let to = center(h, onto); + ask(h, "ui.dragWidget", json!({"id": from, "toX": to.x, "toY": to.y, "steps": 12})); +} + +/// Dragging a keyword onto another nests it there, with its photos; dropping it on the Keyword +/// List's title takes it back to the top level. +#[test] +fn dragging_a_keyword_nests_it_and_back() { + let (mut h, ids) = keywords_panel_at([1400.0, 2400.0]); + run(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "keywords": ["Lisbon"]})); + run(&mut h, "keyword.create", json!({"name": "Portugal"})); + drag(&mut h, "keywordRow:Lisbon", "keywordRow:Portugal"); + assert_eq!(keywords_of(&h, ids[0]), ["Portugal|Lisbon"]); + assert!(h.app.ui.dragging_keyword.is_none(), "the drag ended"); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordRowToggle:Portugal"})); + drag(&mut h, "keywordRow:Portugal|Lisbon", "keywordList:topLevel"); + assert_eq!(keywords_of(&h, ids[0]), ["Lisbon"]); +} + +/// A keyword can't be dropped inside itself or one of its own children: nothing changes. +#[test] +fn a_keyword_cant_be_dropped_inside_itself() { + let (mut h, ids) = keywords_panel_at([1400.0, 2400.0]); + run(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "keywords": ["Portugal|Lisbon"]})); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordRowToggle:Portugal"})); + let undo = h.app.session.undo.len(); + drag(&mut h, "keywordRow:Portugal", "keywordRow:Portugal|Lisbon"); + assert_eq!((keywords_of(&h, ids[0]), h.app.session.undo.len()), (vec!["Portugal|Lisbon".to_string()], undo)); + assert_eq!(h.app.ui.dialog, None); +} + +/// Dropping a keyword where one of that name is already (Italy has a Rome) asks before merging +/// the two; Merge does it. +#[test] +fn dropping_onto_a_taken_name_asks_before_merging() { + let (mut h, ids) = keywords_panel_at([1400.0, 2400.0]); + run(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "keywords": ["Rome"]})); + run(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "keywords": ["Italy|Rome"]})); + drag(&mut h, "keywordRow:Rome", "keywordRow:Italy"); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::MoveKeyword { .. })), "{:?}", h.app.ui.dialog); + assert_eq!(keywords_of(&h, ids[0]), ["Rome"], "nothing yet"); + ask(&mut h, "ui.clickWidget", json!({"id": "button:dialogOk"})); + assert_eq!((keywords_of(&h, ids[0]), keywords_of(&h, ids[1])), (vec!["Italy|Rome".to_string()], vec!["Italy|Rome".to_string()])); +} + +/// Photos dragged from the grid onto a keyword get it. +#[test] +fn dropping_photos_on_a_keyword_tags_them() { + let (mut h, ids) = keywords_panel_at([1400.0, 2400.0]); + run(&mut h, "keyword.create", json!({"name": "Portugal"})); + drag(&mut h, &format!("thumb:{}", ids[0]), "keywordRow:Portugal"); + assert!(ids.iter().all(|id| keywords_of(&h, *id).contains(&"Portugal".to_string())), "the selection got it"); + assert!(h.app.ui.dragging_photos.is_none()); +} From c74323970559c0bafe1a5bd2ed1fa46f1e09a2be Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:31:35 -0400 Subject: [PATCH 15/36] LRC-LIB-KEYWORDS: document the Keyword List The LRC-LIB-KEYWORDS row lists the Keyword List, its commands and what isn't there yet; LR-LIB-KEYWORD no longer says keywords can't be dragged. What's New describes the list, its dialogs, drag and drop and the export options. Co-Authored-By: Claude Opus 5.5 --- docs/parity.md | 4 ++-- docs/whats-new.md | 12 ++++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/parity.md b/docs/parity.md index 8b879922e..2b29858eb 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -165,7 +165,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-RATING | Star ratings | P0 | ✅ | `cmd:photo.rate` (`advance`), `crates/ui-egui/src/shortcuts.rs` | 0 clears; 1–5 set; Shift+0–5 applies and advances once, including with Auto Advance. Batch grid and undo/redo regressions; macOS native ownership corrected (PR #261) | | 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; no keyword drag-and-drop | +| 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-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 | @@ -752,7 +752,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | | LRC-LIB-VC | Virtual copies | P1 | ✅ | `cmd:photo.virtualCopy` | ⌘' | | LRC-LIB-LABELS | Colour-label sets | P1 | ✅ | `cmd:label.sets`, `cmd:label.applySet`, `cmd:label.saveSet`, `cmd:label.deleteSet`, `cmd:label.setNames`, `crates/engine/src/cmd/manage.rs` | built-in Colors / Review sets + user sets (Photo ▸ Set Color Label, Edit Label Names… dialog); names written to and read from `xmp:Label` | -| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter | P1 | ✅ | `crates/catalog/src/keywords.rs`, `cmd:keyword.list`, `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) | +| 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. Keyword tag options (include on export, export containing keywords, export synonyms, person) shape exported files: names in `dc:subject`, paths in `lr:hierarchicalSubject`. Not yet: synonyms in search, person keywords tied to faces, import/export of keyword lists, Keyword Shortcut, Edit Set, a selection-aware Keywording box | | 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 diff --git a/docs/whats-new.md b/docs/whats-new.md index 38a48e9cb..55db7c592 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,18 @@ ## October 2026 +### Keyword List +- The Keywords panel ends with a Keyword List, as in Lightroom Classic: every keyword with its photo count, those no + photo has yet too. Filter it, tick a keyword to give it to the selected photos (a dash: only some have it), or click + the arrow to see its photos. +- Create keywords with + (inside the one you picked, with synonyms, and given to the selection if you like), edit one + by double-clicking it, delete one with − after a confirmation. Right-click for more: 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 to bring it back to the top level; photos dragged + from the grid onto a keyword get it. +- Keyword tag options decide what exported files carry: Include on Export, Export Containing Keywords, Export + Synonyms. Exports now write keyword names to `dc:subject` and their paths to `lr:hierarchicalSubject`. + ### Text fields - Right-click Search Photos, a slider's typed value, the Info panel's fields, the name field of New Album, Rename Album, New Smart Album, Rename Keyword, Merge Keywords and similar dialogs, or the name field under unnamed faces for From 3c9a6d7459596eb0364130d9b2b70033dbecdc2f Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:39:53 -0400 Subject: [PATCH 16/36] LRC-LIB-KEYWORDS: photos in Recently Deleted don't hold keywords The tree and the counts leave out photos in Recently Deleted (and those only browsed), but `keyword_path`, `has_keyword` and purging read every photo: a keyword only a deleted photo had couldn't be created again, wasn't purged, and could lend its spelling. They now read library photos only, as the tree does. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/keywords.rs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index bf2aee088..ef3ae9fb8 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -311,7 +311,8 @@ impl Catalog { out } - /// The keyword is in the tree: listed or on a photo, itself or one below it. + /// The keyword is in the tree: listed or on a library photo (not one in Recently Deleted or + /// only browsed), itself or one below it. pub fn has_keyword(&self, path: &str) -> bool { self.keyword_path(path).is_some() } @@ -330,7 +331,7 @@ impl Catalog { let levels = k.split(SEP).count(); let listed = self.keyword_list.values().map(|l| &l.path); listed - .chain(self.photos().flat_map(|p| p.meta.keywords.iter())) + .chain(self.photos().filter(|p| p.in_library()).flat_map(|p| p.meta.keywords.iter())) .find(|x| is_under(x, &k)) .map(|x| x.split(SEP).map(str::trim).filter(|s| !s.is_empty()).take(levels).collect::>().join("|")) } @@ -406,9 +407,10 @@ impl Catalog { Ok(Op::Batch { ops }) } - /// Take off the keyword list the keywords no photo has, nor any keyword below them — one batch. + /// Take off the keyword list the keywords no library photo has, nor any keyword below them — one + /// batch. pub fn purge_unused_keywords_ops(&self) -> Op { - let used: Vec<&String> = self.photos().flat_map(|p| p.meta.keywords.iter()).collect(); + let used: Vec<&String> = self.photos().filter(|p| p.in_library()).flat_map(|p| p.meta.keywords.iter()).collect(); Op::Batch { ops: self.list_ops(|list| list.retain(|_, l| used.iter().any(|k| is_under(k, &l.path)))) } } @@ -813,6 +815,20 @@ mod tests { assert_eq!(listed(&c).iter().map(|(p, _)| p.as_str()).collect::>(), ["Occasions", "Occasions|Weddings"]); } + /// Photos in Recently Deleted don't count: a keyword only they have isn't in the tree, so it + /// can be created again and is purged as unused, and its spelling there isn't the library's. + #[test] + fn deleted_photos_dont_hold_keywords() { + let (mut c, ids) = lib(&[&["Weddings"], &["travel"]]); + c.apply(Op::SetDeleted { id: ids[0], deleted: true }).unwrap(); + assert!(!c.has_keyword("weddings")); + assert!(c.create_keyword_ops("weddings", KeywordInfo::default(), &[]).is_ok()); + c.apply(Op::SetKeyword { path: "Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); + let op = c.purge_unused_keywords_ops(); + c.apply(op).unwrap(); + assert!(listed(&c).is_empty(), "only a deleted photo has it: unused"); + } + #[test] fn suggestions_rank_co_occurrence_and_prefixes() { let (c, _) = lib(&[&["dog", "park"], &["dog", "park", "ball"], &["dog", "beach"], &["cat"], &["cat"], &["cat"], &["parade"], &["eagle"]]); From f0689d32710b674f01aa74b24cd5493457b478e6 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:41:20 -0400 Subject: [PATCH 17/36] LRC-LIB-KEYWORDS: merging overlapping keywords keeps every listing Merging `a|b` and `a|c` into `a` moved the listings one at a time: the `a|b|c` listing landed on `a|c` before that one left for `a`, merged into it, and its attributes were lost. Every moving listing now leaves first, then each lands. Each keyword also moves by the closest keyword merged (`a|b` before `a`), so the order they are given in doesn't change the result; the keyword filter follows the same way. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/keywords.rs | 44 ++++++++++++++++++++++++++----- crates/engine/src/cmd/keywords.rs | 4 +-- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index ef3ae9fb8..1a0493b81 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -43,6 +43,11 @@ pub fn reparent(k: &str, from: &str, to: &str) -> String { std::iter::once(to).filter(|t| !t.is_empty()).chain(rest).collect::>().join("|") } +/// The keyword of `from` that `k` is under, the closest one when several are (`a|b` before `a`). +pub fn closest<'a>(k: &str, from: &'a [String]) -> Option<&'a String> { + from.iter().filter(|f| is_under(k, f)).max_by_key(|f| f.split(SEP).count()) +} + /// Keep the first of case-insensitively equal keywords. fn dedupe(v: &mut Vec) { let mut seen = std::collections::HashSet::new(); @@ -201,10 +206,12 @@ impl Catalog { /// one already there merges into it (that one's attributes stay, the synonyms gather). fn list_move_ops(&self, from: &[String], to: &str) -> Vec { self.list_ops(|list| { - let moving: Vec = list.iter().filter(|(_, l)| from.iter().any(|f| is_under(&l.path, f))).map(|(k, _)| k.clone()).collect(); - for key in moving { - let Some(l) = list.remove(&key) else { continue }; - let Some(f) = from.iter().find(|f| is_under(&l.path, f)) else { continue }; + // every moving listing leaves first, then each lands: one landing where another is + // about to leave isn't merged into it + let moving: Vec = list.iter().filter(|(_, l)| closest(&l.path, from).is_some()).map(|(k, _)| k.clone()).collect(); + let left: Vec = moving.iter().filter_map(|key| list.remove(key)).collect(); + for l in left { + let Some(f) = closest(&l.path, from) else { continue }; let path = reparent(&l.path, f, to); match list.get_mut(&path.to_lowercase()) { Some(there) => { @@ -258,9 +265,8 @@ impl Catalog { if from.iter().any(|f| is_under(&into, f)) { return Err(CatalogError::Invalid("can't merge a keyword into one below it".into())); } - let mut ops = self.keyword_ops(|kws| { - kws.iter().map(|k| from.iter().find(|f| is_under(k, f)).map(|f| reparent(k, f, &into)).unwrap_or_else(|| k.clone())).collect() - }); + let mut ops = + self.keyword_ops(|kws| kws.iter().map(|k| closest(k, &from).map(|f| reparent(k, f, &into)).unwrap_or_else(|| k.clone())).collect()); ops.extend(self.list_move_ops(&from, &into)); Ok(Op::Batch { ops }) } @@ -829,6 +835,30 @@ mod tests { assert!(listed(&c).is_empty(), "only a deleted photo has it: unused"); } + /// Merging several keywords moves each keyword under them by the closest one, whatever the + /// order they are given in, and every listing lands where its keyword does: one landing where + /// another is about to leave isn't merged into it. + #[test] + fn merging_overlapping_keywords_keeps_every_listing() { + let (mut c, ids) = lib(&[&["a|b|c"], &["a|c"]]); + let person = KeywordInfo { person: true, ..KeywordInfo::default() }; + c.apply(Op::SetKeyword { path: "a|b|c".into(), info: Some(person.clone()) }).unwrap(); + c.apply(Op::SetKeyword { path: "a|c".into(), info: Some(with_synonyms(&["sea"])) }).unwrap(); + let op = c.merge_keywords_ops(&["a|b".into(), "a|c".into()], "a").unwrap(); + c.apply(op).unwrap(); + assert_eq!((kws(&c, ids[0]), kws(&c, ids[1])), (vec!["a|c".to_string()], vec!["a".to_string()])); + assert_eq!(listed(&c), [("a".to_string(), with_synonyms(&["sea"])), ("a|c".to_string(), person)]); + // the order of the keywords merged doesn't matter + let merged = |from: [&str; 2]| { + let (mut c, ids) = lib(&[&["a|b|c", "a|d"]]); + let op = c.merge_keywords_ops(&from.map(String::from), "x").unwrap(); + c.apply(op).unwrap(); + kws(&c, ids[0]) + }; + assert_eq!(merged(["a", "a|b"]), merged(["a|b", "a"])); + assert_eq!(merged(["a", "a|b"]), ["x|c", "x|d"]); + } + #[test] fn suggestions_rank_co_occurrence_and_prefixes() { let (c, _) = lib(&[&["dog", "park"], &["dog", "park", "ball"], &["dog", "beach"], &["cat"], &["cat"], &["cat"], &["parade"], &["eagle"]]); diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index bf5f95fbd..460278a27 100644 --- a/crates/engine/src/cmd/keywords.rs +++ b/crates/engine/src/cmd/keywords.rs @@ -1,7 +1,7 @@ //! Library-wide keyword commands: list (tree with counts), suggestions, rename, delete, merge; //! keyword sets (nine keywords a keystroke away: ⌥1–⌥9) and Recent Keywords. -use lightcraft_catalog::keywords::{KeywordInfo, clean, is_under, reparent}; +use lightcraft_catalog::keywords::{KeywordInfo, clean, closest, is_under, reparent}; use serde_json::{Value, json}; use super::{CommandSpec, always, bad, bool_or, cmd, str_param}; @@ -390,7 +390,7 @@ pub fn specs() -> Vec { let op = s.catalog.merge_keywords_ops(&from, &into).map_err(|e| bad("keyword.merge", e.to_string()))?; let (from, into) = (from.iter().map(|f| clean(f)).collect::>(), clean(&into)); commit_keywords(s, "Merge Keywords", op, |k| { - Some(match from.iter().find(|f| is_under(k, f)) { + Some(match closest(k, &from) { Some(f) => reparent(k, f, &into), None => k.to_string(), }) From c01bdbbe9bbf811cb18323bec4577f57f056a457 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:43:58 -0400 Subject: [PATCH 18/36] LRC-LIB-KEYWORDS: the default parent belongs to its keyword in the library "Put New Keywords Inside This Keyword" was a session preference that followed renames by hand: undo and redo never moved it back, so after undoing a rename it named a keyword that no longer existed and the next new keyword silently made that parent again. It is now a mark on the keyword in the catalog's keyword list (`new_keywords_inside`, one keyword at most): it moves with its keyword (into a merge too), goes with it, comes back with undo and redo, and is kept with the library. Setting it is one undo step. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/keywords.rs | 67 ++++++++++++++++++++++- crates/engine/src/cmd/keywords.rs | 21 +++---- crates/engine/src/lib.rs | 3 - crates/engine/src/library.rs | 4 -- crates/engine/src/tests_keyword_list.rs | 21 +++++-- crates/ui-egui/src/panels/keyword_list.rs | 7 ++- crates/ui-egui/src/tests_keyword_list.rs | 4 +- 7 files changed, 95 insertions(+), 32 deletions(-) diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index 1a0493b81..b25728de7 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -74,6 +74,10 @@ pub struct KeywordInfo { /// The keyword names a person. #[serde(default)] pub person: bool, + /// New keywords go inside this one (Put New Keywords Inside This Keyword): one keyword at + /// most, see [`Catalog::default_keyword_parent`]. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub new_keywords_inside: bool, } fn yes() -> bool { @@ -82,7 +86,14 @@ fn yes() -> bool { impl Default for KeywordInfo { fn default() -> Self { - KeywordInfo { synonyms: Vec::new(), include_on_export: true, export_containing: true, export_synonyms: true, person: false } + KeywordInfo { + synonyms: Vec::new(), + include_on_export: true, + export_containing: true, + export_synonyms: true, + person: false, + new_keywords_inside: false, + } } } @@ -215,6 +226,7 @@ impl Catalog { let path = reparent(&l.path, f, to); match list.get_mut(&path.to_lowercase()) { Some(there) => { + there.info.new_keywords_inside |= l.info.new_keywords_inside; for s in l.info.synonyms { if !there.info.synonyms.iter().any(|x| same(x, &s)) { there.info.synonyms.push(s); @@ -323,6 +335,31 @@ impl Catalog { self.keyword_path(path).is_some() } + /// The keyword new keywords go inside (Put New Keywords Inside This Keyword), if any. + pub fn default_keyword_parent(&self) -> Option { + self.keyword_list.values().find(|l| l.info.new_keywords_inside).map(|l| l.path.clone()) + } + + /// Make `keyword` the one new keywords go inside (`None`: none) — one batch. It is listed if it + /// wasn't, keeping the attributes it has. + pub fn set_default_parent_ops(&self, keyword: Option<&str>) -> Result { + let path = match keyword { + Some(k) => Some(self.keyword_path(k).ok_or_else(|| CatalogError::Invalid(format!("no keyword “{}”", clean(k))))?), + None => None, + }; + Ok(Op::Batch { + ops: self.list_ops(|list| { + for l in list.values_mut() { + l.info.new_keywords_inside = false; + } + if let Some(path) = path { + let l = list.entry(path.to_lowercase()).or_insert_with(|| ListedKeyword { path, info: KeywordInfo::default() }); + l.info.new_keywords_inside = true; + } + }), + }) + } + /// The keyword as written in the library (whatever the case of `path`): its listing, or the /// levels a photo's keyword spells it with. `None` when there is no such keyword. pub fn keyword_path(&self, path: &str) -> Option { @@ -859,6 +896,34 @@ mod tests { assert_eq!(merged(["a", "a|b"]), ["x|c", "x|d"]); } + /// One keyword at most is where new keywords go (Put New Keywords Inside This Keyword). Making + /// another one so is one undo step; the mark moves with its keyword (also into a merge) and + /// goes with it. + #[test] + fn one_keyword_is_where_new_keywords_go() { + let (mut c, _) = lib(&[&["Events|Weddings"], &["Travel"]]); + let op = c.set_default_parent_ops(Some("events")).unwrap(); + let undo = c.apply(op).unwrap(); + assert_eq!(c.default_keyword_parent().as_deref(), Some("Events")); + let op = c.set_default_parent_ops(Some("travel")).unwrap(); + c.apply(op).unwrap(); + assert_eq!(c.default_keyword_parent().as_deref(), Some("Travel"), "only one"); + assert!(c.set_default_parent_ops(Some("lisbon")).is_err()); + let op = c.rename_keyword_ops("travel", "Trips").unwrap(); + c.apply(op).unwrap(); + assert_eq!(c.default_keyword_parent().as_deref(), Some("Trips")); + let op = c.merge_keywords_ops(&["trips".into()], "Events").unwrap(); + c.apply(op).unwrap(); + assert_eq!(c.default_keyword_parent().as_deref(), Some("Events"), "into the merge"); + let op = c.delete_keyword_ops("events").unwrap(); + c.apply(op).unwrap(); + assert_eq!(c.default_keyword_parent(), None); + let op = c.set_default_parent_ops(None).unwrap(); + assert_eq!(op, Op::Batch { ops: vec![] }, "none to clear"); + c.apply(undo).unwrap(); + assert_eq!(c.default_keyword_parent(), None, "undo of the first: none was"); + } + #[test] fn suggestions_rank_co_occurrence_and_prefixes() { let (c, _) = lib(&[&["dog", "park"], &["dog", "park", "ball"], &["dog", "beach"], &["cat"], &["cat"], &["cat"], &["parade"], &["eagle"]]); diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index 460278a27..00f6d24d2 100644 --- a/crates/engine/src/cmd/keywords.rs +++ b/crates/engine/src/cmd/keywords.rs @@ -15,8 +15,8 @@ fn strs(p: &Value, key: &str) -> Vec { } } -/// Commit a keyword batch; returns how many changes it made. The keyword filter and the default -/// parent follow a renamed keyword and are cleared when their keyword is deleted. +/// Commit a keyword batch; returns how many changes it made. The keyword filter follows a renamed +/// keyword and is cleared when its keyword is deleted. fn commit_keywords(s: &mut Session, label: &str, op: lightcraft_catalog::Op, follow: impl Fn(&str) -> Option) -> Result { let n = match &op { lightcraft_catalog::Op::Batch { ops } => ops.len(), @@ -28,13 +28,6 @@ fn commit_keywords(s: &mut Session, label: &str, op: lightcraft_catalog::Op, fol if let Some(k) = s.filter.keyword.clone() { s.filter.keyword = follow(&k); } - if let Some(k) = s.keyword_parent.clone() { - let followed = follow(&k).filter(|k| s.catalog.has_keyword(k)); - if followed.as_deref() != Some(k.as_str()) { - s.keyword_parent = followed; - let _ = s.save_prefs(); - } - } Ok(json!({"changed": n})) } @@ -50,6 +43,7 @@ fn info_from(p: &Value, base: KeywordInfo) -> KeywordInfo { export_containing: bool_or(p, "exportContaining", base.export_containing), export_synonyms: bool_or(p, "exportSynonyms", base.export_synonyms), person: bool_or(p, "person", base.person), + new_keywords_inside: base.new_keywords_inside, } } @@ -254,7 +248,7 @@ pub fn specs() -> Vec { let parent = match p.get("parent") { Some(Value::String(k)) => Some(s.catalog.keyword_path(k).unwrap_or_else(|| clean(k))), Some(_) => None, - None => s.keyword_parent.clone(), + None => s.catalog.default_keyword_parent(), }; let path = clean(&match parent { Some(parent) => format!("{parent}|{name}"), @@ -340,12 +334,13 @@ pub fn specs() -> Vec { "{keyword: keyword | null} — the parent keyword.create puts new keywords inside (null: the top level)", always, |s, p| { - s.keyword_parent = match p.get("keyword") { + let keyword = match p.get("keyword") { Some(Value::Null) | None => None, Some(_) => Some(named_keyword(s, p, "keyword.setDefaultParent")?), }; - let _ = s.save_prefs(); - Ok(json!({"keyword": s.keyword_parent})) + let op = s.catalog.set_default_parent_ops(keyword.as_deref()).map_err(|e| bad("keyword.setDefaultParent", e.to_string()))?; + commit_keywords(s, "Put New Keywords Inside", op, |k| Some(k.to_string()))?; + Ok(json!({"keyword": s.catalog.default_keyword_parent()})) } ), cmd!( diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b676c8fba..aab6c0277 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -247,8 +247,6 @@ pub struct Session { pub keyword_sets: Vec, pub keyword_set: Option, pub recent_keywords: Vec, - /// "Put new keywords inside this keyword": the parent `keyword.create` uses unless told. - pub keyword_parent: Option, /// Before/After: the "before" settings chosen per photo (this session; default: the photo's /// import state). See `cmd/before.rs`. pub before: std::collections::HashMap>, @@ -334,7 +332,6 @@ impl Session { keyword_sets: Vec::new(), keyword_set: None, recent_keywords: Vec::new(), - keyword_parent: None, before: Default::default(), import_probes: Default::default(), preview_build: None, diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 1524e7032..a83dc4a82 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -149,8 +149,6 @@ struct PrefsFile { keyword_sets: Vec, keyword_set: Option, recent_keywords: Vec, - /// The parent new keywords go inside. - keyword_parent: Option, /// Develop defaults for imported photos. import: crate::import::ImportDefaults, /// Thumbnail disk cache budget (MB, 0 = default). @@ -340,7 +338,6 @@ impl Session { self.keyword_sets = prefs.keyword_sets; self.keyword_set = prefs.keyword_set; self.recent_keywords = prefs.recent_keywords; - self.keyword_parent = prefs.keyword_parent; self.import_defaults = prefs.import; self.cache_mb = prefs.cache_mb; self.forget_local_days = prefs.forget_local_days.unwrap_or(lightcraft_catalog::DEFAULT_FORGET_DAYS); @@ -570,7 +567,6 @@ impl Session { keyword_sets: self.keyword_sets.clone(), keyword_set: self.keyword_set.clone(), recent_keywords: self.recent_keywords.clone(), - keyword_parent: self.keyword_parent.clone(), import: self.import_defaults.clone(), cache_mb: self.cache_mb, smart_previews_dir: self.smart_previews_dir.as_ref().map(|d| d.to_string_lossy().to_string()), diff --git a/crates/engine/src/tests_keyword_list.rs b/crates/engine/src/tests_keyword_list.rs index 261ad99a8..18932dd3d 100644 --- a/crates/engine/src/tests_keyword_list.rs +++ b/crates/engine/src/tests_keyword_list.rs @@ -55,8 +55,8 @@ fn creating_a_keyword_with_attributes_and_the_selection() { } /// "Put new keywords inside this keyword": new keywords go inside the default parent unless the -/// command says otherwise (`parent: null` = the top level). It follows the parent's rename, goes -/// when the parent is deleted, and is kept with the library. +/// command says otherwise (`parent: null` = the top level). It belongs to its keyword in the +/// library: it follows a rename, goes with a delete, and undo and redo bring it back with them. #[test] fn new_keywords_go_inside_the_default_parent() { let dir = temp_dir("default-parent"); @@ -64,17 +64,26 @@ fn new_keywords_go_inside_the_default_parent() { s.open_library(&dir, true).unwrap(); s.execute("keyword.create", &json!({"name": "Events"})).unwrap(); s.execute("keyword.setDefaultParent", &json!({"keyword": "events"})).unwrap(); - assert_eq!(s.keyword_parent.as_deref(), Some("Events")); + assert_eq!(s.catalog.default_keyword_parent().as_deref(), Some("Events")); assert_eq!(s.execute("keyword.create", &json!({"name": "Birthdays"})).unwrap()["keyword"], "Events|Birthdays"); assert_eq!(s.execute("keyword.create", &json!({"name": "Travel", "parent": null})).unwrap()["keyword"], "Travel"); s.execute("keyword.rename", &json!({"from": "Events", "to": "Occasions"})).unwrap(); - assert_eq!(s.keyword_parent.as_deref(), Some("Occasions"), "follows the rename"); + assert_eq!(s.catalog.default_keyword_parent().as_deref(), Some("Occasions"), "follows the rename"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.default_keyword_parent().as_deref(), Some("Events"), "undo takes it back"); + s.execute("edit.redo", &json!({})).unwrap(); drop(s); let mut s = Session::new(); s.open_library(&dir, false).unwrap(); - assert_eq!(s.keyword_parent.as_deref(), Some("Occasions"), "kept with the library"); + assert_eq!(s.catalog.default_keyword_parent().as_deref(), Some("Occasions"), "kept with the library"); s.execute("keyword.delete", &json!({"keyword": "occasions"})).unwrap(); - assert_eq!(s.keyword_parent, None, "gone with its keyword"); + assert_eq!(s.catalog.default_keyword_parent(), None, "gone with its keyword"); + assert_eq!(s.execute("keyword.create", &json!({"name": "Graduations"})).unwrap()["keyword"], "Graduations", "at the top level"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.default_keyword_parent().as_deref(), Some("Occasions"), "undoing the delete brings it back"); + s.execute("keyword.setDefaultParent", &json!({"keyword": null})).unwrap(); + assert_eq!(s.catalog.default_keyword_parent(), None); assert!(s.execute("keyword.setDefaultParent", &json!({"keyword": "lisbon"})).is_err(), "no such keyword"); let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 04ad6a7f0..8d215d63f 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -215,7 +215,7 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId let name = if measure(&r.name) <= room { r.name.clone() } else { crate::widgets::elide_head(&r.name, room, measure) }; let label = ui.painter().text(pos2(x + 34.0, cy), Align2::LEFT_CENTER, name, font.clone(), if picked { t.text } else { t.text_label }); // where new keywords go (Put New Keywords Inside This Keyword): a dot after the name - if app.session.keyword_parent.as_deref().is_some_and(|k| same(k, &r.path)) { + if app.session.catalog.default_keyword_parent().as_deref().is_some_and(|k| same(k, &r.path)) { let dot = Rect::from_center_size(pos2(label.right() + 7.0, cy), vec2(6.0, 6.0)); ui.painter().circle_filled(dot.center(), 3.0, t.accent); register(ui.ctx(), format!("keywordDefault:{}", r.path), dot); @@ -288,7 +288,7 @@ fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str, selection: &[Pho if item(ui, "edit", crate::i18n::tr("Edit Keyword Tag…"), true) { app.ui.dialog = Some(edit_dialog(app, path)); } - let is_default = app.session.keyword_parent.as_deref().is_some_and(|k| same(k, path)); + let is_default = app.session.catalog.default_keyword_parent().as_deref().is_some_and(|k| same(k, path)); let label = format!("{}{}", if is_default { "✓ " } else { "" }, crate::i18n::tr("Put New Keywords Inside This Keyword")); if item(ui, "defaultParent", &label, true) { let _ = app.run("keyword.setDefaultParent", json!({"keyword": if is_default { serde_json::Value::Null } else { json!(path) }})); @@ -316,7 +316,8 @@ fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str, selection: &[Pho /// Create Keyword Tag: inside the keyword picked in the list, else the default parent. pub(crate) fn create_dialog(app: &LightcraftApp) -> Dialog { - let parent = app.ui.keyword_list_selected.clone().filter(|k| app.session.catalog.has_keyword(k)).or_else(|| app.session.keyword_parent.clone()); + let parent = + app.ui.keyword_list_selected.clone().filter(|k| app.session.catalog.has_keyword(k)).or_else(|| app.session.catalog.default_keyword_parent()); let d = KeywordInfo::default(); Dialog::KeywordTag { editing: None, diff --git a/crates/ui-egui/src/tests_keyword_list.rs b/crates/ui-egui/src/tests_keyword_list.rs index 4d91290e8..d63cc5768 100644 --- a/crates/ui-egui/src/tests_keyword_list.rs +++ b/crates/ui-egui/src/tests_keyword_list.rs @@ -195,7 +195,7 @@ fn a_keywords_menu_offers_its_actions() { assert!(has(&h, &format!("keywordMenu:{item}")), "{item}"); } ask(&mut h, "ui.clickWidget", json!({"id": "keywordMenu:defaultParent"})); - assert_eq!(h.app.session.keyword_parent.as_deref(), Some("Events")); + assert_eq!(h.app.session.catalog.default_keyword_parent().as_deref(), Some("Events")); assert!(has(&h, "keywordDefault:Events"), "the list marks where new keywords go"); // Create inside it ask(&mut h, "ui.clickWidget", json!({"id": "keywordRow:Events", "button": "right"})); @@ -207,7 +207,7 @@ fn a_keywords_menu_offers_its_actions() { ask(&mut h, "ui.clickWidget", json!({"id": "keywordRow:Events", "button": "right"})); ask(&mut h, "ui.clickWidget", json!({"id": "keywordMenu:purge"})); assert!(!h.app.session.catalog.has_keyword("Events")); - assert_eq!(h.app.session.keyword_parent, None, "gone with it"); + assert_eq!(h.app.session.catalog.default_keyword_parent(), None, "gone with it"); } fn center(h: &Headless, id: &str) -> egui::Pos2 { From d62a5637d848a537e69a35c79c9d59ebdb7de155 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:44:41 -0400 Subject: [PATCH 19/36] LRC-LIB-KEYWORDS: the filter follows a keyword moved into a new parent `keyword.move` worked out where the keyword landed from the parent's spelling in the library, and fell back to the top level when the parent didn't exist yet, while the catalog made that parent: the keyword filter stayed on the old path and the grid showed nothing. It now lands the filter where the catalog puts the keyword. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/keywords.rs | 11 ++++++----- crates/engine/src/tests_keyword_list.rs | 13 +++++++++++++ 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index 00f6d24d2..1e9dacbae 100644 --- a/crates/engine/src/cmd/keywords.rs +++ b/crates/engine/src/cmd/keywords.rs @@ -301,15 +301,16 @@ pub fn specs() -> Vec { Some(Value::Null) => None, _ => return Err(bad("keyword.move", "`parent` is a keyword, or null for the top level")), }; + // where it lands, as the catalog spells it: a parent that doesn't exist yet is made + let leaf = from.rsplit('|').next().unwrap_or(&from).to_string(); + let to = match parent.as_deref().map(|k| s.catalog.keyword_path(k).unwrap_or_else(|| clean(k))).filter(|k| !k.is_empty()) { + Some(parent) => format!("{parent}|{leaf}"), + None => leaf, + }; let op = s .catalog .move_keyword_ops(&from, parent.as_deref(), bool_or(p, "merge", false)) .map_err(|e| refused("keyword.move", e, ": moving would merge the two (merge: true)"))?; - let leaf = from.rsplit('|').next().unwrap_or(&from).to_string(); - let to = match parent.as_deref().and_then(|k| s.catalog.keyword_path(k)) { - Some(parent) => format!("{parent}|{leaf}"), - None => leaf, - }; commit_keywords(s, "Move Keyword", op, |k| Some(if is_under(k, &from) { reparent(k, &from, &to) } else { k.to_string() })) } ), diff --git a/crates/engine/src/tests_keyword_list.rs b/crates/engine/src/tests_keyword_list.rs index 18932dd3d..eba8e34b0 100644 --- a/crates/engine/src/tests_keyword_list.rs +++ b/crates/engine/src/tests_keyword_list.rs @@ -129,3 +129,16 @@ fn purging_unused_keywords() { s.execute("edit.undo", &json!({})).unwrap(); assert!(s.execute("keyword.info", &json!({"keyword": "Weddings"})).is_ok()); } + +/// Moving a keyword into a parent that doesn't exist yet makes that parent, and the keyword filter +/// follows the keyword there (the grid kept filtering by the old path, and showed nothing). +#[test] +fn the_filter_follows_a_keyword_moved_into_a_new_parent() { + let mut s = Session::with_demo(); + let id = s.visible_cloned()[0].0; + s.execute("photo.setMeta", &json!({"ids": [id], "keywords": ["Rome"]})).unwrap(); + s.execute("library.filter", &json!({"keyword": "Rome"})).unwrap(); + s.execute("keyword.move", &json!({"keyword": "rome", "parent": "Italy"})).unwrap(); + assert_eq!(keywords_of(&s, id), ["Italy|Rome"]); + assert_eq!(s.filter.keyword.as_deref(), Some("Italy|Rome")); +} From 3a145eef4e6f3ade630369e95f950dbe7b43236f Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:46:39 -0400 Subject: [PATCH 20/36] LRC-LIB-KEYWORDS: keywords from files keep their hierarchy Exports now write names to `dc:subject` and paths to `lr:hierarchicalSubject`, as Lightroom Classic does, but import and sidecars read only `dc:subject`: an exported `travel|Italy|Rome` came back as three keywords, Rome, Italy and travel. They now take the paths of `lr:hierarchicalSubject` and the names of `dc:subject` that aren't levels of those paths (`keywords::from_file`); without paths, the names as they are, so LightCraft's own sidecars read as before. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/keywords.rs | 22 ++++++++++++++++++++++ crates/engine/src/files.rs | 16 +++++++++++++++- crates/engine/src/sidecar.rs | 16 +++++++++++++++- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index b25728de7..1fe9369f1 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -43,6 +43,16 @@ pub fn reparent(k: &str, from: &str, to: &str) -> String { std::iter::once(to).filter(|t| !t.is_empty()).chain(rest).collect::>().join("|") } +/// A file's keywords as a photo carries them: the paths of `lr:hierarchicalSubject`, then the +/// names of `dc:subject` that aren't levels of those paths (Lightroom Classic writes each keyword's +/// name, its parents' and synonyms flat besides its path). Each once, cleaned. +pub fn from_file(flat: &[String], hierarchical: &[String]) -> Vec { + let mut out: Vec = hierarchical.iter().map(|k| clean(k)).collect(); + out.extend(flat.iter().map(|k| clean(k)).filter(|k| !hierarchical.iter().any(|h| h.split(SEP).any(|level| same(level.trim(), k))))); + dedupe(&mut out); + out +} + /// The keyword of `from` that `k` is under, the closest one when several are (`a|b` before `a`). pub fn closest<'a>(k: &str, from: &'a [String]) -> Option<&'a String> { from.iter().filter(|f| is_under(k, f)).max_by_key(|f| f.split(SEP).count()) @@ -924,6 +934,18 @@ mod tests { assert_eq!(c.default_keyword_parent(), None, "undo of the first: none was"); } + /// A file's keywords as a photo carries them: the paths of `lr:hierarchicalSubject`, and the + /// names of `dc:subject` that aren't levels of those paths (an exported `travel|Italy` comes + /// back as one keyword, not three). Without paths, the names as they are. + #[test] + fn a_files_keywords_keep_their_hierarchy() { + let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(from_file(&v(&["Rome", "Italy", "travel", "beach"]), &v(&["travel|Italy|Rome"])), ["travel|Italy|Rome", "beach"]); + assert_eq!(from_file(&v(&["ROME"]), &v(&["travel|Rome", " travel | rome "])), ["travel|Rome"], "each once, cleaned"); + assert_eq!(from_file(&v(&["beach", " sea "]), &[]), ["beach", "sea"]); + assert_eq!(from_file(&v(&["a|b"]), &[]), ["a|b"], "LightCraft's own sidecars write paths flat"); + } + #[test] fn suggestions_rank_co_occurrence_and_prefixes() { let (c, _) = lib(&[&["dog", "park"], &["dog", "park", "ball"], &["dog", "beach"], &["cat"], &["cat"], &["cat"], &["parade"], &["eagle"]]); diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index db762f009..53afc1c20 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -37,7 +37,8 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option) { usage_terms: m.usage_terms.clone().unwrap_or_default(), copyright_url: m.copyright_url.clone().unwrap_or_default(), creator: m.artist.clone().unwrap_or_default(), - keywords: m.keywords.clone(), + // paths from `lr:hierarchicalSubject`, names from `dc:subject` that aren't their levels + keywords: lightcraft_catalog::keywords::from_file(&m.keywords, &m.hierarchical_keywords), regions: m.regions.clone(), }; (meta, m.capture_time.as_ref().map(|d| d.to_iso())) @@ -587,6 +588,19 @@ impl crate::Session { #[cfg(test)] mod tests { + /// Keywords from a file keep their hierarchy: the paths of `lr:hierarchicalSubject`, not the + /// flat names of `dc:subject` that LightCraft's exports (and Lightroom Classic) write besides + /// them; an exported `travel|Italy|Rome` comes back as itself. + #[test] + fn imported_keywords_keep_their_hierarchy() { + let m = lightcraft_meta::Metadata { + keywords: vec!["Rome".into(), "Italy".into(), "travel".into(), "beach".into()], + hierarchical_keywords: vec!["travel|Italy|Rome".into()], + ..Default::default() + }; + assert_eq!(super::meta_of(&m).0.keywords, ["travel|Italy|Rome", "beach"]); + } + use super::*; use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index 7e9249489..e040658c8 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -133,7 +133,8 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result Date: Fri, 9 Oct 2026 14:47:38 -0400 Subject: [PATCH 21/36] LRC-LIB-KEYWORDS: keywords given to photos are stored cleaned `photo.setMeta` stored keywords as given: " beach " showed in the tree as "beach" but couldn't be found there (Edit, Move, Delete said there was no such keyword), and duplicates were caught by ASCII case only. Keywords are now stored cleaned (`a | b ` is `a|b`, empty ones are dropped), each once whatever the case of any letter, and removing one matches it the same way. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/library.rs | 22 ++++++++++++++++------ crates/engine/src/tests_keyword_list.rs | 15 +++++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 09d19f0dd..8a4d6497b 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -766,16 +766,26 @@ pub fn specs() -> Vec { *field = v.to_string(); } } - if let Some(k) = strs("keywords") { - m.keywords = k; - } - for k in strs("addKeywords").unwrap_or_default() { - if !m.keywords.iter().any(|x| x.eq_ignore_ascii_case(&k)) { + // keywords are stored cleaned (`a | b ` is `a|b`), each once whatever its case + use lightcraft_catalog::keywords::{clean, same}; + let add = |m: &mut lightcraft_catalog::Meta, k: &str| { + let k = clean(k); + if !k.is_empty() && !m.keywords.iter().any(|x| same(x, &k)) { m.keywords.push(k); } + }; + if let Some(k) = strs("keywords") { + m.keywords.clear(); + for k in k { + add(&mut m, &k); + } + } + for k in strs("addKeywords").unwrap_or_default() { + add(&mut m, &k); } for k in strs("removeKeywords").unwrap_or_default() { - m.keywords.retain(|x| !x.eq_ignore_ascii_case(&k)); + let k = clean(&k); + m.keywords.retain(|x| !same(&clean(x), &k)); } ops.push(Op::SetMeta { id, meta: Box::new(m) }); } diff --git a/crates/engine/src/tests_keyword_list.rs b/crates/engine/src/tests_keyword_list.rs index eba8e34b0..4c0e186cf 100644 --- a/crates/engine/src/tests_keyword_list.rs +++ b/crates/engine/src/tests_keyword_list.rs @@ -142,3 +142,18 @@ fn the_filter_follows_a_keyword_moved_into_a_new_parent() { assert_eq!(keywords_of(&s, id), ["Italy|Rome"]); assert_eq!(s.filter.keyword.as_deref(), Some("Italy|Rome")); } + +/// Keywords given to photos are stored cleaned (" beach " is "beach", "Travel | Italy" is +/// "Travel|Italy"), so the list's actions find them; one a photo has already, whatever the case of +/// any letter, isn't added twice. +#[test] +fn keywords_given_to_photos_are_stored_cleaned() { + let mut s = Session::with_demo(); + let id = s.visible_cloned()[0].0; + s.execute("photo.setMeta", &json!({"ids": [id], "keywords": [" beach ", "Travel | Italy", "Ärzte"]})).unwrap(); + s.execute("photo.setMeta", &json!({"ids": [id], "addKeywords": [" sea ", "ÄRZTE", " "]})).unwrap(); + assert_eq!(keywords_of(&s, id), ["beach", "Travel|Italy", "Ärzte", "sea"]); + s.execute("photo.setMeta", &json!({"ids": [id], "removeKeywords": ["travel | italy"]})).unwrap(); + assert_eq!(keywords_of(&s, id), ["beach", "Ärzte", "sea"]); + assert!(s.execute("keyword.info", &json!({"keyword": "beach"})).is_ok()); +} From fad169fa47f02a1b11b89a679b2619d0193bc94d Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:49:54 -0400 Subject: [PATCH 22/36] LRC-LIB-KEYWORDS: a new keyword keeps the spelling of its parents Creating "TRAVEL|Rome" next to "travel|italy" listed it as typed, and the tree, which takes a level's spelling from the list first, showed "TRAVEL". A new keyword now takes the library's spelling of each level it shares with a keyword there. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/keywords.rs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index 1fe9369f1..1ac42ab5e 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -345,6 +345,22 @@ impl Catalog { self.keyword_path(path).is_some() } + /// `path` with each level the library has spelled as the library spells it (`TRAVEL|Rome` next + /// to `travel|italy` is `travel|Rome`). + fn spelled(&self, path: &str) -> String { + let levels: Vec<&str> = path.split(SEP).collect(); + let mut out = String::new(); + for (i, level) in levels.iter().enumerate() { + let prefix = levels.get(..=i).map(|l| l.join("|")).unwrap_or_default(); + let name = self.keyword_path(&prefix).and_then(|p| p.rsplit(SEP).next().map(str::to_string)).unwrap_or_else(|| level.to_string()); + if !out.is_empty() { + out.push(SEP); + } + out.push_str(&name); + } + out + } + /// The keyword new keywords go inside (Put New Keywords Inside This Keyword), if any. pub fn default_keyword_parent(&self) -> Option { self.keyword_list.values().find(|l| l.info.new_keywords_inside).map(|l| l.path.clone()) @@ -399,6 +415,7 @@ impl Catalog { if self.has_keyword(&path) { return Err(CatalogError::KeywordExists(path)); } + let path = self.spelled(&path); let mut ops = vec![Op::SetKeyword { path: path.clone(), info: Some(info) }]; for id in photos { let p = self.photo(*id).ok_or(CatalogError::NoPhoto(*id))?; @@ -946,6 +963,17 @@ mod tests { assert_eq!(from_file(&v(&["a|b"]), &[]), ["a|b"], "LightCraft's own sidecars write paths flat"); } + /// A new keyword inside one the library has takes the library's spelling of it: creating + /// "TRAVEL|rome" next to "travel|italy" doesn't respell "travel". + #[test] + fn a_new_keyword_keeps_the_spelling_of_its_parents() { + let (mut c, _) = lib(&[&["travel|italy"]]); + let op = c.create_keyword_ops("TRAVEL|Rome", KeywordInfo::default(), &[]).unwrap(); + c.apply(op).unwrap(); + assert_eq!(listed(&c).iter().map(|(p, _)| p.as_str()).collect::>(), ["travel|Rome"]); + assert_eq!(tree_paths(&c.keyword_tree())[0].0, "travel"); + } + #[test] fn suggestions_rank_co_occurrence_and_prefixes() { let (c, _) = lib(&[&["dog", "park"], &["dog", "park", "ball"], &["dog", "beach"], &["cat"], &["cat"], &["cat"], &["parade"], &["eagle"]]); From f0ac4900ba6139437071872647ca67a482541337 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:50:03 -0400 Subject: [PATCH 23/36] LRC-LIB-KEYWORDS: an edit that changes nothing is nothing Editing a keyword without changing it listed it (when it wasn't) and made an undo step. The attributes are now set only when they change something: a keyword only photos have, renamed with the default attributes, stays unlisted. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/keywords.rs | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index 1ac42ab5e..a266e6c64 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -473,7 +473,16 @@ impl Catalog { ops = renamed; } } - ops.push(Op::SetKeyword { path: to, info: Some(info) }); + // set the attributes only when they change something: an unlisted keyword with the default + // ones stays unlisted + let now = self.keyword_info(&from).cloned(); + let changed = match &now { + Some(now) => *now != info || to != from, + None => info != KeywordInfo::default(), + }; + if changed { + ops.push(Op::SetKeyword { path: to, info: Some(info) }); + } Ok(Op::Batch { ops }) } @@ -882,7 +891,7 @@ mod tests { assert_eq!(c.keyword_path("EVENTS").as_deref(), Some("Events")); let op = c.edit_keyword_ops("events", "Occasions", KeywordInfo::default()).unwrap(); c.apply(op).unwrap(); - assert_eq!(listed(&c).iter().map(|(p, _)| p.as_str()).collect::>(), ["Occasions", "Occasions|Weddings"]); + assert_eq!(tree_paths(&c.keyword_tree()), [("Occasions".to_string(), 0), ("Occasions|Weddings".to_string(), 0)]); } /// Photos in Recently Deleted don't count: a keyword only they have isn't in the tree, so it @@ -974,6 +983,20 @@ mod tests { assert_eq!(tree_paths(&c.keyword_tree())[0].0, "travel"); } + /// An edit that changes nothing is nothing: no listing, no undo step. + #[test] + fn an_edit_that_changes_nothing_is_nothing() { + let (mut c, _) = lib(&[&["beach"]]); + assert_eq!(c.edit_keyword_ops("beach", "beach", KeywordInfo::default()).unwrap(), Op::Batch { ops: vec![] }); + c.apply(Op::SetKeyword { path: "beach".into(), info: Some(with_synonyms(&["shore"])) }).unwrap(); + assert_eq!(c.edit_keyword_ops("BEACH", "beach", with_synonyms(&["shore"])).unwrap(), Op::Batch { ops: vec![] }); + // renamed with the default attributes: the photos change, nothing is listed + let (mut c, _) = lib(&[&["beach"]]); + let op = c.edit_keyword_ops("beach", "Shore", KeywordInfo::default()).unwrap(); + c.apply(op).unwrap(); + assert!(listed(&c).is_empty()); + } + #[test] fn suggestions_rank_co_occurrence_and_prefixes() { let (c, _) = lib(&[&["dog", "park"], &["dog", "park", "ball"], &["dog", "beach"], &["cat"], &["cat"], &["cat"], &["parade"], &["eagle"]]); From 9e1abe5a973eef5d598231505dbbd4237a93b1a1 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:50:13 -0400 Subject: [PATCH 24/36] LRC-LIB-KEYWORDS: typing suggests keywords no photo has yet Suggestions came from the photos' keywords only, so a keyword created in the Keyword List was never offered while typing. Those only the list has are suggested too. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/keywords.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index a266e6c64..b79d245ac 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -498,7 +498,13 @@ impl Catalog { /// appear together with `current` on other photos, then the most used ones. Most frequent /// first, at most `n`, never one of `current`. pub fn keyword_suggestions(&self, current: &[String], prefix: &str, n: usize) -> Vec { - let all = self.keywords(); + // the photos' keywords with their counts, and those only the list has (none) + let mut all = self.keywords(); + for l in self.keyword_list.values() { + if !all.iter().any(|(k, _)| same(k, &l.path)) { + all.push((l.path.clone(), 0)); + } + } let has = |k: &str| current.iter().any(|c| same(c, k)); let q = prefix.trim().to_lowercase(); let mut scored: Vec<(i64, String)> = if !q.is_empty() { @@ -997,6 +1003,15 @@ mod tests { assert!(listed(&c).is_empty()); } + /// Typing suggests keywords no photo has yet too (created in the Keyword List). + #[test] + fn suggestions_include_keywords_without_photos() { + let (mut c, _) = lib(&[&["beach"]]); + c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(KeywordInfo::default()) }).unwrap(); + assert_eq!(c.keyword_suggestions(&[], "wed", 5), ["Events|Weddings"]); + assert!(!c.keyword_suggestions(&["events|weddings".into()], "wed", 5).contains(&"Events|Weddings".to_string()), "not one it has"); + } + #[test] fn suggestions_rank_co_occurrence_and_prefixes() { let (c, _) = lib(&[&["dog", "park"], &["dog", "park", "ball"], &["dog", "beach"], &["cat"], &["cat"], &["cat"], &["parade"], &["eagle"]]); From c80318667dd4ac66e6d166a02520839df133c7f2 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:51:26 -0400 Subject: [PATCH 25/36] LRC-LIB-KEYWORDS: keyword actions count the photos they change Rename, merge and delete answered `changed` with every change they made, so with the keyword list it no longer meant photos (renaming a keyword no photo has said 2). `changed` is photos again, and `listed` counts the keyword list's changes (what Purge reports). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/keywords.rs | 20 ++++++++++++-------- crates/engine/src/tests_keyword_list.rs | 13 +++++++++++++ 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index 1e9dacbae..3d9492499 100644 --- a/crates/engine/src/cmd/keywords.rs +++ b/crates/engine/src/cmd/keywords.rs @@ -15,20 +15,24 @@ fn strs(p: &Value, key: &str) -> Vec { } } -/// Commit a keyword batch; returns how many changes it made. The keyword filter follows a renamed -/// keyword and is cleared when its keyword is deleted. +/// Commit a keyword batch; returns `{changed: photos changed, listed: keyword list changes}`. The +/// keyword filter follows a renamed keyword and is cleared when its keyword is deleted. fn commit_keywords(s: &mut Session, label: &str, op: lightcraft_catalog::Op, follow: impl Fn(&str) -> Option) -> Result { - let n = match &op { - lightcraft_catalog::Op::Batch { ops } => ops.len(), - _ => 1, + use lightcraft_catalog::Op; + let (photos, listed) = match &op { + Op::Batch { ops } => { + (ops.iter().filter(|o| matches!(o, Op::SetMeta { .. })).count(), ops.iter().filter(|o| matches!(o, Op::SetKeyword { .. })).count()) + } + Op::SetMeta { .. } => (1, 0), + _ => (0, 1), }; - if n > 0 { + if photos + listed > 0 { s.commit(label, op)?; } if let Some(k) = s.filter.keyword.clone() { s.filter.keyword = follow(&k); } - Ok(json!({"changed": n})) + Ok(json!({"changed": photos, "listed": listed})) } /// A keyword's attributes from command params over `base`: those not given keep their value. @@ -324,7 +328,7 @@ pub fn specs() -> Vec { |s, _| { let op = s.catalog.purge_unused_keywords_ops(); let r = commit_keywords(s, "Purge Unused Keywords", op, |k| Some(k.to_string()))?; - Ok(json!({"purged": r["changed"]})) + Ok(json!({"purged": r["listed"]})) } ), cmd!( diff --git a/crates/engine/src/tests_keyword_list.rs b/crates/engine/src/tests_keyword_list.rs index 4c0e186cf..3d5e21666 100644 --- a/crates/engine/src/tests_keyword_list.rs +++ b/crates/engine/src/tests_keyword_list.rs @@ -157,3 +157,16 @@ fn keywords_given_to_photos_are_stored_cleaned() { assert_eq!(keywords_of(&s, id), ["beach", "Ärzte", "sea"]); assert!(s.execute("keyword.info", &json!({"keyword": "beach"})).is_ok()); } + +/// Rename, merge and delete say how many photos they changed, as before the keyword list: the +/// list's own changes aren't photos. +#[test] +fn keyword_actions_count_the_photos_they_change() { + let mut s = Session::with_demo(); + let id = s.visible_cloned()[0].0; + s.execute("keyword.create", &json!({"name": "Weddings", "synonyms": ["marriage"]})).unwrap(); + assert_eq!(s.execute("keyword.rename", &json!({"from": "Weddings", "to": "Marriages"})).unwrap()["changed"], 0); + s.execute("photo.setMeta", &json!({"ids": [id], "addKeywords": ["Marriages"]})).unwrap(); + assert_eq!(s.execute("keyword.rename", &json!({"from": "Marriages", "to": "Weddings"})).unwrap()["changed"], 1); + assert_eq!(s.execute("keyword.delete", &json!({"keyword": "weddings"})).unwrap()["changed"], 1); +} From 199e33043fc613d6ca290d0751d194c9b9d14dd4 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:54:15 -0400 Subject: [PATCH 26/36] LRC-LIB-KEYWORDS: the Keyword List stays fast with a big selection Every row worked out its tick box from every selected photo's keywords, every frame: with 20,000 photos selected a frame took seconds. `Ticks` now counts each keyword over the selection once, cached by library revision and selection, and each row looks its count up: 2.8 ms a frame for 20,024 selected photos. The check that the picked keyword still exists reads the cached tree instead of scanning every photo. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 18 +++++ crates/ui-egui/src/panels/keyword_list.rs | 84 +++++++++++++++-------- 2 files changed, 73 insertions(+), 29 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e0487c497..eebffbc76 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1077,6 +1077,8 @@ mod drop_tests { #[derive(Default)] pub struct Caches { keyword_tree: Option<(u64, std::sync::Arc>)>, + /// The Keyword List's tick boxes, by library revision and selection. + keyword_ticks: Option<(u64, u64, std::sync::Arc)>, folder_tree: Option<(u64, std::sync::Arc>)>, people: Option<(u64, lightcraft_catalog::Filter, std::sync::Arc>)>, suggestions: Option<(u64, std::sync::Arc>)>, @@ -1163,6 +1165,22 @@ impl Caches { } } } + /// How many selected photos have each keyword (the Keyword List's tick boxes). + pub(crate) fn keyword_ticks( + &mut self, + cat: &lightcraft_catalog::Catalog, + selection: &[lightcraft_catalog::PhotoId], + ) -> std::sync::Arc { + let key = key_of(selection); + match &self.keyword_ticks { + Some((r, k, t)) if *r == cat.revision && *k == key => t.clone(), + _ => { + let t = std::sync::Arc::new(panels::keyword_list::Ticks::of(cat, selection)); + self.keyword_ticks = Some((cat.revision, key, t.clone())); + t + } + } + } /// The people named on faces among the photos the filter lets through (its own `person` aside), /// with photo counts. pub fn people( diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 8d215d63f..f37e8ff64 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -51,7 +51,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if plus.clicked() { app.ui.dialog = Some(create_dialog(app)); } - let picked = app.ui.keyword_list_selected.clone().filter(|k| app.session.catalog.has_keyword(k)); + let picked = app.ui.keyword_list_selected.clone().filter(|k| in_tree(&app.caches.keyword_tree(&app.session.catalog), k)); let minus = ui.add_enabled(picked.is_some(), egui::Button::new("−")).on_hover_text(crate::i18n::tr("Delete Keyword")); register(ui.ctx(), "keywordList:delete", minus.rect); if minus.clicked() @@ -73,8 +73,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); } let selection = app.session.selection.ids.clone(); + let ticks = app.caches.keyword_ticks(&app.session.catalog, &selection); for r in &rows { - row(app, ui, r, &selection, filter.trim().is_empty()); + row(app, ui, r, &selection, &ticks, filter.trim().is_empty()); } ui.add_space(12.0); // the drag ends with the button's release, wherever it is @@ -125,7 +126,7 @@ fn drop_keyword(app: &mut LightcraftApp, ctx: &egui::Context, keyword: &str, par /// One keyword's row: the triangle, the tick box, the name, the count, and on hover the arrow. /// `can_fold`: the triangle opens and closes the level (not while a filter opens it). -fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId], can_fold: bool) { +fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId], ticks: &Ticks, can_fold: bool) { let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click_and_drag()); register(ui.ctx(), format!("keywordRow:{}", r.path), rect); @@ -164,7 +165,7 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId } } // the tick box: does the selection have it? - let state = tick(&app.session.catalog, selection, &r.path); + let state = ticks.tick(&r.path); let boxr = Rect::from_center_size(pos2(x + 22.0, cy), vec2(13.0, 13.0)); let on = !selection.is_empty(); let tb = @@ -417,23 +418,47 @@ pub(crate) enum Tick { All, } -/// The tick box of `path` for `photos`: a photo counts when it has the keyword itself (any case); -/// one with only a keyword below it doesn't. -pub(crate) fn tick(catalog: &Catalog, photos: &[PhotoId], path: &str) -> Tick { - let path = lightcraft_catalog::keywords::clean(path); - let has = photos - .iter() - .filter(|id| { - catalog - .photo(**id) - .is_some_and(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::same(&lightcraft_catalog::keywords::clean(k), &path))) - }) - .count(); - match has { - 0 => Tick::No, - n if n == photos.len() => Tick::All, - _ => Tick::Some, +/// How many of a selection's photos have each keyword itself, counted once per selection (and +/// library change) rather than per row and frame: a long list with thousands of photos selected +/// stays fast. +#[derive(Debug, Default)] +pub(crate) struct Ticks { + photos: usize, + /// Photos with it, by cleaned lower-case keyword. + counts: std::collections::HashMap, +} + +impl Ticks { + pub(crate) fn of(catalog: &Catalog, photos: &[PhotoId]) -> Ticks { + let mut counts: std::collections::HashMap = Default::default(); + let mut seen = std::collections::HashSet::new(); + for id in photos { + let Some(p) = catalog.photo(*id) else { continue }; + seen.clear(); + for k in &p.meta.keywords { + let key = lightcraft_catalog::keywords::clean(k).to_lowercase(); + if seen.insert(key.clone()) { + *counts.entry(key).or_default() += 1; + } + } + } + Ticks { photos: photos.len(), counts } } + + /// The tick box of `path`: a photo counts when it has the keyword itself (any case); one with + /// only a keyword below it doesn't. + pub(crate) fn tick(&self, path: &str) -> Tick { + match self.counts.get(&lightcraft_catalog::keywords::clean(path).to_lowercase()).copied().unwrap_or(0) { + 0 => Tick::No, + n if n >= self.photos => Tick::All, + _ => Tick::Some, + } + } +} + +/// The keyword is in the tree (any case). +fn in_tree(tree: &[KeywordNode], path: &str) -> bool { + tree.iter().any(|n| same(&n.path, path) || (lightcraft_catalog::keywords::is_under(path, &n.path) && in_tree(&n.children, path))) } #[cfg(test)] @@ -491,17 +516,18 @@ mod tests { } /// The tick box: ticked when every selected photo has the keyword itself, a dash when some do, - /// empty when none do or nothing is selected. + /// empty when none do or nothing is selected. Counted once per selection. #[test] fn the_tick_box_says_how_many_selected_photos_have_the_keyword() { let (c, ids) = library(&[&["Travel|Italy"], &["travel|italy", "beach"], &["travel|spain"]]); - assert_eq!(tick(&c, &ids[..2], "travel|Italy"), Tick::All, "whatever the case"); - assert_eq!(tick(&c, &ids, "travel|Italy"), Tick::Some); - assert_eq!(tick(&c, &ids, "travel"), Tick::No, "a keyword below doesn't tick its parent"); - assert_eq!(tick(&c, &[], "beach"), Tick::No); - assert_eq!(tick(&c, &ids[2..], "beach"), Tick::No); - // any letter's case, not only ASCII - let (c, ids) = library(&[&["Ärzte"]]); - assert_eq!(tick(&c, &ids, "ärzte"), Tick::All); + assert_eq!(Ticks::of(&c, &ids[..2]).tick("travel|Italy"), Tick::All, "whatever the case"); + let all = Ticks::of(&c, &ids); + assert_eq!(all.tick("travel|Italy"), Tick::Some); + assert_eq!(all.tick("travel"), Tick::No, "a keyword below doesn't tick its parent"); + assert_eq!(Ticks::of(&c, &[]).tick("beach"), Tick::No); + assert_eq!(Ticks::of(&c, &ids[2..]).tick("beach"), Tick::No); + // any letter's case, not only ASCII; a photo with it twice counts once + let (c, ids) = library(&[&["Ärzte", "ÄRZTE"], &[]]); + assert_eq!(Ticks::of(&c, &ids).tick("ärzte"), Tick::Some); } } From 5d4ebe5132d49132d232ad22c226e198a8879f1a Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:55:15 -0400 Subject: [PATCH 27/36] LRC-LIB-KEYWORDS: drops land only on what is shown The Keyword List took a drop wherever the pointer was inside a row's or the title's rectangle, even where the panel had scrolled it out of sight: a keyword released over the app's top bar, where the list's title was hidden, went to the top level. Drops now need the pointer on the visible part of the panel too; photos dropped from the grid alike. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/keyword_list.rs | 10 ++++++++-- crates/ui-egui/src/tests_keyword_list.rs | 23 +++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index f37e8ff64..5b92c4b50 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -25,7 +25,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { register(ui.ctx(), "keywordList:topLevel", title); let dragging = app.ui.dragging_keyword.clone(); let (pointer, released) = ui.input(|i| (i.pointer.latest_pos(), i.pointer.any_released())); - let over_title = pointer.is_some_and(|p| title.contains(p)); + let over_title = shown_under(ui, title, pointer); if dragging.is_some() && over_title { ui.painter().rect_stroke(title.shrink2(vec2(16.0, 6.0)), 4.0, Stroke::new(1.5, t.accent), StrokeKind::Inside); } @@ -89,6 +89,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } +/// The pointer is on the part of `rect` that is shown: inside it and inside the panel's visible +/// (scrolled) area, so a drop never lands on a row or title hidden under the top bar. +fn shown_under(ui: &egui::Ui, rect: Rect, pointer: Option) -> bool { + pointer.is_some_and(|p| rect.contains(p) && ui.clip_rect().contains(p)) +} + /// The dragged keyword's name, following the pointer. fn drag_label(ctx: &egui::Context, keyword: &str, at: egui::Pos2) { let t = Tokens::get(ctx); @@ -236,7 +242,7 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId /// keyword inside this one, or gives this keyword to the photos. fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, rect: Rect, path: &str) { let (pointer, released) = ui.input(|i| (i.pointer.latest_pos(), i.pointer.any_released())); - if !pointer.is_some_and(|p| rect.contains(p)) { + if !shown_under(ui, rect, pointer) { return; } let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/tests_keyword_list.rs b/crates/ui-egui/src/tests_keyword_list.rs index d63cc5768..9c840811d 100644 --- a/crates/ui-egui/src/tests_keyword_list.rs +++ b/crates/ui-egui/src/tests_keyword_list.rs @@ -270,3 +270,26 @@ fn dropping_photos_on_a_keyword_tags_them() { assert!(ids.iter().all(|id| keywords_of(&h, *id).contains(&"Portugal".to_string())), "the selection got it"); assert!(h.app.ui.dragging_photos.is_none()); } + +fn rect(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().rev().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no {id}")) +} + +/// A drop lands only on what is shown: with the list scrolled so that its title is hidden under +/// the top bar, a keyword released over the top bar stays where it is. +#[test] +fn drops_land_only_on_what_is_shown() { + let (mut h, ids) = keywords_panel_at([1400.0, 640.0]); + run(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "keywords": ["aaa|zz"]})); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordRowToggle:aaa"})); + let title = rect(&h, "keywordList:topLevel"); + ask(&mut h, "ui.hoverWidget", json!({"id": "keywordRow:aaa"})); + ask(&mut h, "ui.scroll", json!({"dx": 0, "dy": -(title.top() - 20.0)})); + let title = rect(&h, "keywordList:topLevel"); + let hidden = egui::pos2(title.center().x, title.top() + 8.0); + assert!(hidden.y < 44.0 && title.contains(hidden), "the title's top is under the top bar: {title:?}"); + let row = rect(&h, "keywordRow:aaa|zz"); + assert!(row.top() > 44.0, "the row is shown: {row:?}"); + ask(&mut h, "ui.dragWidget", json!({"id": "keywordRow:aaa|zz", "toX": hidden.x, "toY": hidden.y, "steps": 12})); + assert_eq!(keywords_of(&h, ids[0]), ["aaa|zz"], "not moved to the top level"); +} From 344db72c850caa6864d43589ebf3784905b1b381 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:56:01 -0400 Subject: [PATCH 28/36] LRC-LIB-KEYWORDS: the Keyword List needs no selection With no photo active the whole right panel said "No photo selected", so the library's Keyword List disappeared: keywords couldn't be made or organized before choosing a photo. The Keywords panel now shows the list without a selection (its tick boxes wait for one), under a note that selecting photos gives them keywords. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de.json | 3 ++- crates/ui-egui/locales/es.json | 3 ++- crates/ui-egui/locales/ja.json | 3 ++- crates/ui-egui/locales/pt-br.json | 3 ++- crates/ui-egui/locales/ru.json | 3 ++- crates/ui-egui/locales/zh-hans.json | 3 ++- crates/ui-egui/locales/zh-hant.json | 3 ++- crates/ui-egui/src/panels/right.rs | 12 ++++++++++++ crates/ui-egui/src/tests_keyword_list.rs | 13 +++++++++++++ 9 files changed, 39 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index cdd96f9dc..23a5879ed 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1910,5 +1910,6 @@ "Purge Unused Keywords": "Nicht verwendete Stichwörter löschen", "Delete Keyword…": "Stichwort löschen…", "Drop here for the top level": "Hier ablegen für die oberste Ebene", - "Their photos and the keywords below them come together under one keyword.": "Ihre Fotos und die Stichwörter darunter kommen unter einem Stichwort zusammen." + "Their photos and the keywords below them come together under one keyword.": "Ihre Fotos und die Stichwörter darunter kommen unter einem Stichwort zusammen.", + "Select photos to give them keywords.": "Wählen Sie Fotos aus, um ihnen Stichwörter zu geben." } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 814e0c796..c8cd8f87d 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1755,5 +1755,6 @@ "Purge Unused Keywords": "Eliminar palabras clave no usadas", "Delete Keyword…": "Eliminar palabra clave…", "Drop here for the top level": "Soltar aquí para el nivel superior", - "Their photos and the keywords below them come together under one keyword.": "Sus fotos y las palabras clave que contienen quedan bajo una sola palabra clave." + "Their photos and the keywords below them come together under one keyword.": "Sus fotos y las palabras clave que contienen quedan bajo una sola palabra clave.", + "Select photos to give them keywords.": "Selecciona fotos para darles palabras clave." } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index b073b3440..e8610ea0b 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1755,5 +1755,6 @@ "Purge Unused Keywords": "使用されていないキーワードを削除", "Delete Keyword…": "キーワードを削除…", "Drop here for the top level": "ここにドロップすると最上位に移動", - "Their photos and the keywords below them come together under one keyword.": "写真と下位のキーワードが1つのキーワードにまとめられます。" + "Their photos and the keywords below them come together under one keyword.": "写真と下位のキーワードが1つのキーワードにまとめられます。", + "Select photos to give them keywords.": "写真を選択するとキーワードを付けられます。" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 3e366b9ef..7dddca874 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1755,5 +1755,6 @@ "Purge Unused Keywords": "Excluir palavras-chave não usadas", "Delete Keyword…": "Excluir palavra-chave…", "Drop here for the top level": "Solte aqui para o nível superior", - "Their photos and the keywords below them come together under one keyword.": "Suas fotos e as palavras-chave abaixo delas ficam sob uma só palavra-chave." + "Their photos and the keywords below them come together under one keyword.": "Suas fotos e as palavras-chave abaixo delas ficam sob uma só palavra-chave.", + "Select photos to give them keywords.": "Selecione fotos para dar palavras-chave a elas." } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index cd3bd4913..22ce2e160 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1428,5 +1428,6 @@ "Purge Unused Keywords": "Удалить неиспользуемые ключевые слова", "Delete Keyword…": "Удалить ключевое слово…", "Drop here for the top level": "Отпустите здесь для верхнего уровня", - "Their photos and the keywords below them come together under one keyword.": "Их фото и вложенные ключевые слова объединятся под одним ключевым словом." + "Their photos and the keywords below them come together under one keyword.": "Их фото и вложенные ключевые слова объединятся под одним ключевым словом.", + "Select photos to give them keywords.": "Выберите фото, чтобы назначить им ключевые слова." } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index b6e09076c..e73f6593c 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1755,5 +1755,6 @@ "Purge Unused Keywords": "清除未使用的关键字", "Delete Keyword…": "删除关键字…", "Drop here for the top level": "拖放到此处以移至顶层", - "Their photos and the keywords below them come together under one keyword.": "它们的照片和下级关键字将合并到同一个关键字下。" + "Their photos and the keywords below them come together under one keyword.": "它们的照片和下级关键字将合并到同一个关键字下。", + "Select photos to give them keywords.": "选择照片以为其添加关键字。" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index af97aac45..886336abb 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1755,5 +1755,6 @@ "Purge Unused Keywords": "清除未使用的關鍵字", "Delete Keyword…": "刪除關鍵字…", "Drop here for the top level": "拖放到此處以移至最上層", - "Their photos and the keywords below them come together under one keyword.": "它們的照片與下層關鍵字將合併到同一個關鍵字下。" + "Their photos and the keywords below them come together under one keyword.": "它們的照片與下層關鍵字將合併到同一個關鍵字下。", + "Select photos to give them keywords.": "選取照片以加入關鍵字。" } diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 9f2788a16..f8a817955 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -19,6 +19,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let width = app.ui.right_width; let resized = super::resizable_side(ui, false, "right_panel", frame, width, crate::state::RIGHT_WIDTH, reserve, |ui| { let Some(id) = app.session.active() else { + // the Keyword List is the library's, not a photo's: it is there without a selection + if app.ui.right == RightPanel::Keywords { + egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]).show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + header(ui, "Keywords"); + padded(ui, |ui| { + ui.label(egui::RichText::new(crate::i18n::tr("Select photos to give them keywords.")).color(t.text_dim)); + }); + super::keyword_list::show(app, ui); + }); + return; + } let r = ui.max_rect(); super::empty_message(ui, r, "No photo selected", "Select a photo to edit"); return; diff --git a/crates/ui-egui/src/tests_keyword_list.rs b/crates/ui-egui/src/tests_keyword_list.rs index 9c840811d..17a62f37c 100644 --- a/crates/ui-egui/src/tests_keyword_list.rs +++ b/crates/ui-egui/src/tests_keyword_list.rs @@ -293,3 +293,16 @@ fn drops_land_only_on_what_is_shown() { ask(&mut h, "ui.dragWidget", json!({"id": "keywordRow:aaa|zz", "toX": hidden.x, "toY": hidden.y, "steps": 12})); assert_eq!(keywords_of(&h, ids[0]), ["aaa|zz"], "not moved to the top level"); } + +/// With no photo selected the Keyword List is still there, to create and organize keywords; its +/// tick boxes wait for a selection. +#[test] +fn the_keyword_list_needs_no_selection() { + let (mut h, _) = keywords_panel(); + run(&mut h, "library.select", json!({"ids": []})); + assert!(has(&h, "keywordList:topLevel") && has(&h, "keywordList:create"), "the list is shown"); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordList:create"})); + ask(&mut h, "ui.text", json!({"text": "Weddings"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(h.app.session.catalog.has_keyword("Weddings"), "and creates keywords"); +} From 92730c9246fe687a79dac771ab619c320923ae03 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:56:40 -0400 Subject: [PATCH 29/36] LRC-LIB-KEYWORDS: Return in Synonyms creates the keyword In Create / Edit Keyword Tag only the name field answered Return: typed synonyms followed by Return did nothing. Return in Synonyms now creates (or saves) the keyword too, as a dialog's default button does. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/dialogs.rs | 6 +++++- crates/ui-egui/src/tests_keyword_list.rs | 13 +++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 199a98583..3db3766b2 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -742,10 +742,14 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } ui.add_space(4.0); ui.label(egui::RichText::new(crate::i18n::tr("Synonyms")).color(t.text_dim)); - crate::text_field::TextField::singleline("field:keywordSynonyms", synonyms) + // Return in either field creates (or saves) the keyword + let r = crate::text_field::TextField::singleline("field:keywordSynonyms", synonyms) .hint(crate::i18n::tr("Separated by commas")) .width(f32::INFINITY) .show(ui); + if r.ending == Some(crate::text_field::Ending::Return) { + confirm = true; + } ui.add_space(6.0); if editing.is_none() { if let Some(p) = parent { diff --git a/crates/ui-egui/src/tests_keyword_list.rs b/crates/ui-egui/src/tests_keyword_list.rs index 17a62f37c..1602b4c17 100644 --- a/crates/ui-egui/src/tests_keyword_list.rs +++ b/crates/ui-egui/src/tests_keyword_list.rs @@ -306,3 +306,16 @@ fn the_keyword_list_needs_no_selection() { ask(&mut h, "ui.key", json!({"key": "Enter"})); assert!(h.app.session.catalog.has_keyword("Weddings"), "and creates keywords"); } + +/// Return in the Synonyms field creates the keyword too, as from the name. +#[test] +fn return_in_synonyms_creates_the_keyword() { + let (mut h, _) = keywords_panel(); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordList:create"})); + ask(&mut h, "ui.text", json!({"text": "Weddings"})); + ask(&mut h, "ui.clickWidget", json!({"id": "field:keywordSynonyms"})); + ask(&mut h, "ui.text", json!({"text": "marriage"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert_eq!(h.app.ui.dialog, None); + assert_eq!(info(&mut h, "Weddings")["synonyms"], json!(["marriage"])); +} From 23ce79009da16e5743d6aaba87c7e9bb088366e4 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:58:45 -0400 Subject: [PATCH 30/36] LRC-LIB-KEYWORDS: the Keyword List follows its keywords MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a rename (Edit Keyword Tag), a drag or a merge made in the list, the picked keyword still named the old path (the highlight went and − was greyed out for a keyword that was there) and the renamed levels closed. The pick and the open levels now follow the keyword, and the levels containing it open, so a keyword dropped into another stays in sight. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/dialogs.rs | 20 ++++++++++++++-- crates/ui-egui/src/panels/keyword_list.rs | 29 +++++++++++++++++++++-- crates/ui-egui/src/tests_keyword_list.rs | 26 +++++++++++++++++++- 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 3db3766b2..058735f02 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -1169,7 +1169,15 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result { params["keyword"] = json!(keyword); params["name"] = json!(name); - app.run("keyword.edit", params) + let r = app.run("keyword.edit", params); + if r.is_ok() { + let to = match keyword.rsplit_once('|') { + Some((parent, _)) => format!("{parent}|{}", name.trim()), + None => name.trim().to_string(), + }; + crate::panels::keyword_list::follow(app, keyword, &to); + } + r } None => { params["name"] = json!(name); @@ -1180,7 +1188,15 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result app.run("keyword.delete", json!({"keyword": keyword})), - Dialog::MoveKeyword { keyword, parent } => app.run("keyword.move", json!({"keyword": keyword, "parent": parent, "merge": true})), + Dialog::MoveKeyword { keyword, parent } => { + let r = app.run("keyword.move", json!({"keyword": keyword, "parent": parent, "merge": true})); + if r.is_ok() { + let leaf = keyword.rsplit('|').next().unwrap_or(keyword); + let to = parent.as_deref().map_or_else(|| leaf.to_string(), |p| format!("{p}|{leaf}")); + crate::panels::keyword_list::follow(app, keyword, &to); + } + r + } Dialog::MergeKeywords { from, into } => app.run("keyword.merge", json!({"from": from, "into": into})), Dialog::AutoStack { gap } => app.run("stack.auto", json!({"gap": gap})), Dialog::DenoiseModel { info, accepted } => crate::panels::denoise::install(app, info, *accepted), diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 5b92c4b50..2e5274dc5 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -89,6 +89,30 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } +/// After the keyword `from` became `to` (renamed, moved or merged), the list's pick and open +/// levels follow it, and the levels containing it open so that it stays in sight. +pub(crate) fn follow(app: &mut LightcraftApp, from: &str, to: &str) { + use lightcraft_catalog::keywords::{is_under, reparent}; + let to = app.session.catalog.keyword_path(to).unwrap_or_else(|| to.to_string()); + if let Some(k) = app.ui.keyword_list_selected.clone() + && is_under(&k, from) + { + app.ui.keyword_list_selected = Some(reparent(&k, from, &to)); + } + for o in &mut app.ui.keyword_list_open { + if is_under(o, from) { + *o = reparent(o, from, &to).to_lowercase(); + } + } + let levels: Vec<&str> = to.split('|').collect(); + for n in 1..levels.len() { + let parent = levels.get(..n).map(|l| l.join("|").to_lowercase()).unwrap_or_default(); + if !app.ui.keyword_list_open.iter().any(|o| same(o, &parent)) { + app.ui.keyword_list_open.push(parent); + } + } +} + /// The pointer is on the part of `rect` that is shown: inside it and inside the panel's visible /// (scrolled) area, so a drop never lands on a row or title hidden under the top bar. fn shown_under(ui: &egui::Ui, rect: Rect, pointer: Option) -> bool { @@ -125,8 +149,9 @@ fn drop_keyword(app: &mut LightcraftApp, ctx: &egui::Context, keyword: &str, par app.ui.dialog = Some(Dialog::MoveKeyword { keyword: keyword.to_string(), parent }); return; } - if let Err(e) = app.run("keyword.move", json!({"keyword": keyword, "parent": parent})) { - app.toast(ctx, e); + match app.run("keyword.move", json!({"keyword": keyword, "parent": parent})) { + Ok(_) => follow(app, keyword, &to), + Err(e) => app.toast(ctx, e), } } diff --git a/crates/ui-egui/src/tests_keyword_list.rs b/crates/ui-egui/src/tests_keyword_list.rs index 1602b4c17..6790ccff1 100644 --- a/crates/ui-egui/src/tests_keyword_list.rs +++ b/crates/ui-egui/src/tests_keyword_list.rs @@ -230,7 +230,7 @@ fn dragging_a_keyword_nests_it_and_back() { drag(&mut h, "keywordRow:Lisbon", "keywordRow:Portugal"); assert_eq!(keywords_of(&h, ids[0]), ["Portugal|Lisbon"]); assert!(h.app.ui.dragging_keyword.is_none(), "the drag ended"); - ask(&mut h, "ui.clickWidget", json!({"id": "keywordRowToggle:Portugal"})); + assert!(has(&h, "keywordRow:Portugal|Lisbon"), "Portugal opened to show it"); drag(&mut h, "keywordRow:Portugal|Lisbon", "keywordList:topLevel"); assert_eq!(keywords_of(&h, ids[0]), ["Lisbon"]); } @@ -319,3 +319,27 @@ fn return_in_synonyms_creates_the_keyword() { assert_eq!(h.app.ui.dialog, None); assert_eq!(info(&mut h, "Weddings")["synonyms"], json!(["marriage"])); } + +/// The picked keyword and the open levels follow a rename or a move made in the list: renamed, +/// Events stays open and picked; dropped into Portugal, Lisbon stays in sight and picked. +#[test] +fn the_list_follows_its_keywords() { + let (mut h, ids) = keywords_panel_at([1400.0, 2400.0]); + run(&mut h, "keyword.create", json!({"name": "Weddings", "parent": "Events"})); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordRowToggle:Events"})); + // a moment later: the triangle's click and the row's two aren't a triple click + for _ in 0..40 { + h.step(); + } + ask(&mut h, "ui.clickWidget", json!({"id": "keywordRow:Events", "count": 2})); + ask(&mut h, "ui.text", json!({"text": "Occasions"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(has(&h, "keywordRow:Occasions|Weddings"), "still open"); + assert_eq!(h.app.ui.keyword_list_selected.as_deref(), Some("Occasions"), "still picked"); + run(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "keywords": ["Lisbon"]})); + run(&mut h, "keyword.create", json!({"name": "Portugal", "parent": null})); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordRow:Lisbon"})); + drag(&mut h, "keywordRow:Lisbon", "keywordRow:Portugal"); + assert!(has(&h, "keywordRow:Portugal|Lisbon"), "its new parent opened"); + assert_eq!(h.app.ui.keyword_list_selected.as_deref(), Some("Portugal|Lisbon")); +} From 4010f4a466ef16957c548c2bcadf4fa6d0f3f090 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:00:40 -0400 Subject: [PATCH 31/36] LRC-LIB-KEYWORDS: a keyword drag ends with Esc, or wherever it is released The Keyword List ended a keyword drag only while it was drawn: with another panel opened mid-drag, the drag lingered until the list came back. `keyword_list::drag_feedback` now runs every frame, like the album drag's: the drag ends with the button's release wherever it is, or with Esc. A press on a tick box that moves no longer picks the keyword up. The test that a keyword can't be dropped inside itself now checks that no move was even tried. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panels/keyword_list.rs | 30 ++++++------ crates/ui-egui/src/tests_keyword_list.rs | 57 +++++++++++++++++++++++ 3 files changed, 74 insertions(+), 14 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index eebffbc76..4169af149 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -969,6 +969,7 @@ impl LightcraftApp { pick::poll(self, &ctx); panels::grid::drag_feedback(self, &ctx); panels::left::album_drag_feedback(self, &ctx); + panels::keyword_list::drag_feedback(self, &ctx); panels::toast(self, &ctx); self.widgets = widgets::take_registry(&ctx); self.end_frame(&ctx, t0); diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 2e5274dc5..8eab45029 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -78,15 +78,6 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { row(app, ui, r, &selection, &ticks, filter.trim().is_empty()); } ui.add_space(12.0); - // the drag ends with the button's release, wherever it is - if app.ui.dragging_keyword.is_some() { - let (released, down) = ui.input(|i| (i.pointer.any_released(), i.pointer.any_down())); - if released || !down { - app.ui.dragging_keyword = None; - } else if let (Some(k), Some(p)) = (app.ui.dragging_keyword.clone(), pointer) { - drag_label(ui.ctx(), &k, p); - } - } } /// After the keyword `from` became `to` (renamed, moved or merged), the list's pick and open @@ -119,11 +110,21 @@ fn shown_under(ui: &egui::Ui, rect: Rect, pointer: Option) -> bool { pointer.is_some_and(|p| rect.contains(p) && ui.clip_rect().contains(p)) } -/// The dragged keyword's name, following the pointer. -fn drag_label(ctx: &egui::Context, keyword: &str, at: egui::Pos2) { +/// A keyword being dragged, each frame (after the panels, which take the drop): its name follows +/// the pointer, and the drag ends with the button's release wherever it is (the list may be gone +/// by then) or with Esc. +pub fn drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { + let Some(keyword) = app.ui.dragging_keyword.clone() else { return }; + let (released, down, at, esc) = + ctx.input(|i| (i.pointer.any_released(), i.pointer.any_down(), i.pointer.latest_pos(), i.key_pressed(egui::Key::Escape))); + if released || !down || esc { + app.ui.dragging_keyword = None; + return; + } + let Some(at) = at else { return }; let t = Tokens::get(ctx); ctx.set_cursor_icon(egui::CursorIcon::Grabbing); - let name = keyword.rsplit('|').next().unwrap_or(keyword).to_string(); + let name = keyword.rsplit('|').next().unwrap_or(&keyword).to_string(); egui::Area::new(egui::Id::new("drag-keyword")).order(egui::Order::Tooltip).interactable(false).fixed_pos(at + vec2(14.0, 10.0)).show(ctx, |ui| { egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| { ui.label(egui::RichText::new(name).color(Color32::WHITE).font(t.semibold(12.0))); @@ -199,8 +200,9 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId let state = ticks.tick(&r.path); let boxr = Rect::from_center_size(pos2(x + 22.0, cy), vec2(13.0, 13.0)); let on = !selection.is_empty(); - let tb = - ui.interact(boxr.expand(3.0), egui::Id::new(("keyword-list-tick", r.path.to_lowercase())), if on { Sense::click() } else { Sense::hover() }); + // (it senses drags too, so a press there that moves doesn't pick the keyword up) + let sense = if on { Sense::click_and_drag() } else { Sense::hover() }; + let tb = ui.interact(boxr.expand(3.0), egui::Id::new(("keyword-list-tick", r.path.to_lowercase())), sense); register(ui.ctx(), format!("keywordCheck:{}", r.path), boxr); let edge = if on { t.text_label } else { t.text_dim.gamma_multiply(0.5) }; match state { diff --git a/crates/ui-egui/src/tests_keyword_list.rs b/crates/ui-egui/src/tests_keyword_list.rs index 6790ccff1..6c638562a 100644 --- a/crates/ui-egui/src/tests_keyword_list.rs +++ b/crates/ui-egui/src/tests_keyword_list.rs @@ -245,6 +245,7 @@ fn a_keyword_cant_be_dropped_inside_itself() { drag(&mut h, "keywordRow:Portugal", "keywordRow:Portugal|Lisbon"); assert_eq!((keywords_of(&h, ids[0]), h.app.session.undo.len()), (vec!["Portugal|Lisbon".to_string()], undo)); assert_eq!(h.app.ui.dialog, None); + assert_eq!(h.app.ui.toast, None, "not even tried: no error to show"); } /// Dropping a keyword where one of that name is already (Italy has a Rome) asks before merging @@ -343,3 +344,59 @@ fn the_list_follows_its_keywords() { assert!(has(&h, "keywordRow:Portugal|Lisbon"), "its new parent opened"); assert_eq!(h.app.ui.keyword_list_selected.as_deref(), Some("Portugal|Lisbon")); } + +/// Press at `from`, move in steps to `to` (each a frame), without releasing. +fn press_and_move(h: &mut Headless, from: egui::Pos2, to: egui::Pos2) { + let press = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + h.events.extend([egui::Event::PointerMoved(from), press(from, true)]); + h.step(); + for i in 1..=8 { + h.events.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 8.0))); + h.step(); + } +} + +fn release(h: &mut Headless, at: egui::Pos2) { + h.events.push(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }); + h.settle(SETTLE); +} + +/// Esc gives up a keyword drag: released on another keyword afterwards, it stays where it is. +#[test] +fn escape_gives_up_a_keyword_drag() { + let (mut h, ids) = keywords_panel_at([1400.0, 2400.0]); + run(&mut h, "photo.setMeta", json!({"ids": [ids[0]], "keywords": ["Lisbon"]})); + run(&mut h, "keyword.create", json!({"name": "Portugal", "parent": null})); + let (from, to) = (center(&h, "keywordRow:Lisbon"), center(&h, "keywordRow:Portugal")); + press_and_move(&mut h, from, to); + assert_eq!(h.app.ui.dragging_keyword.as_deref(), Some("Lisbon"), "dragging"); + h.events.push(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }); + h.step(); + assert_eq!(h.app.ui.dragging_keyword, None, "given up"); + release(&mut h, to); + assert_eq!(keywords_of(&h, ids[0]), ["Lisbon"]); +} + +/// A drag ends with the button's release even when the Keyword List went away meanwhile (another +/// panel opened): it doesn't linger to the next time the list is shown. +#[test] +fn a_keyword_drag_ends_without_the_list() { + let (mut h, _) = keywords_panel_at([1400.0, 2400.0]); + let from = center(&h, "keywordRow:mountains"); + press_and_move(&mut h, from, from + egui::vec2(-300.0, 0.0)); + assert_eq!(h.app.ui.dragging_keyword.as_deref(), Some("mountains")); + h.app.ui.right = crate::state::RightPanel::Info; + h.step(); + release(&mut h, from + egui::vec2(-300.0, 0.0)); + assert_eq!(h.app.ui.dragging_keyword, None); +} + +/// Pressing on a tick box and moving doesn't pick the keyword up. +#[test] +fn a_tick_box_doesnt_start_a_drag() { + let (mut h, _) = keywords_panel_at([1400.0, 2400.0]); + let from = center(&h, "keywordCheck:mountains"); + press_and_move(&mut h, from, from + egui::vec2(0.0, 60.0)); + assert_eq!(h.app.ui.dragging_keyword, None); + release(&mut h, from + egui::vec2(0.0, 60.0)); +} From 8fcc4897c7a89e7de37d290df562623781b60b43 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:02:02 -0400 Subject: [PATCH 32/36] LRC-LIB-KEYWORDS: keyword translations: Person, merging, export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Russian had no "Person": the option showed in English (it reaches `tr` through a variable). A test now checks that every label the Keyword List passes that way is in every language's file. - The merge confirmation's button said "Merge", which some languages translate as merging photos (HDR, panorama): it says "Merge Keywords". - Traditional Chinese uses 匯出 for Export, as everywhere else in the file, not 轉存; "Export Containing Keywords" means the parent keywords in both Chinese files (上層 / 上级), not "included" ones. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/ru.json | 3 ++- crates/ui-egui/locales/zh-hans.json | 2 +- crates/ui-egui/locales/zh-hant.json | 6 ++--- crates/ui-egui/src/panels/dialogs.rs | 3 ++- crates/ui-egui/src/panels/keyword_list.rs | 27 +++++++++++++++++++++++ 5 files changed, 35 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 22ce2e160..57440fe76 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1429,5 +1429,6 @@ "Delete Keyword…": "Удалить ключевое слово…", "Drop here for the top level": "Отпустите здесь для верхнего уровня", "Their photos and the keywords below them come together under one keyword.": "Их фото и вложенные ключевые слова объединятся под одним ключевым словом.", - "Select photos to give them keywords.": "Выберите фото, чтобы назначить им ключевые слова." + "Select photos to give them keywords.": "Выберите фото, чтобы назначить им ключевые слова.", + "Person": "Человек" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index e73f6593c..c652318ad 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1744,7 +1744,7 @@ "Separated by commas": "用逗号分隔", "Keyword Tag Options": "关键字标记选项", "Include on Export": "导出时包含", - "Export Containing Keywords": "导出包含的关键字", + "Export Containing Keywords": "导出上级关键字", "Export Synonyms": "导出同义词", "Create Keyword Tag": "创建关键字标记", "Edit Keyword Tag": "编辑关键字标记", diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 886336abb..d29103389 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1743,9 +1743,9 @@ "Synonyms": "同義詞", "Separated by commas": "以逗號分隔", "Keyword Tag Options": "關鍵字標記選項", - "Include on Export": "轉存時包含", - "Export Containing Keywords": "轉存包含的關鍵字", - "Export Synonyms": "轉存同義詞", + "Include on Export": "匯出時包含", + "Export Containing Keywords": "匯出上層關鍵字", + "Export Synonyms": "匯出同義詞", "Create Keyword Tag": "建立關鍵字標記", "Edit Keyword Tag": "編輯關鍵字標記", "Create": "建立", diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index 058735f02..62cd8ca04 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -904,7 +904,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::FaceModel { info, .. } if !informational && info["download"].is_string() => "Download", Dialog::FaceModel { .. } if !informational => "Install", Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } => "Delete", - Dialog::MoveKeyword { .. } => "Merge", + // (not "Merge": that is photo merging, HDR / panorama, in some languages) + Dialog::MoveKeyword { .. } => "Merge Keywords", Dialog::KeywordTag { editing: None, .. } => "Create", Dialog::KeywordTag { .. } => "Save", Dialog::RemoveFolder { .. } => "Remove", diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 8eab45029..482db5648 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -563,4 +563,31 @@ mod tests { let (c, ids) = library(&[&["Ärzte", "ÄRZTE"], &[]]); assert_eq!(Ticks::of(&c, &ids).tick("ärzte"), Tick::Some); } + + /// The labels the Keyword List and its dialogs pass to `tr` through variables (out of reach of + /// a literal search) are translated in every language. + #[test] + fn labels_passed_through_variables_are_translated() { + use crate::i18n::Locale; + let labels = [ + "Include on Export", + "Export Containing Keywords", + "Export Synonyms", + "Person", + "Merge Keywords", + "No keywords yet", + "No keywords match", + "Add to Selected Photos", + "Remove from Selected Photos", + ]; + 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"] { + let text = std::fs::read_to_string(dir.join(format!("{code}.json"))).unwrap(); + let catalog: std::collections::HashMap = serde_json::from_str(&text).unwrap(); + for label in labels { + assert!(catalog.contains_key(label), "{code}: “{label}”"); + } + } + assert_eq!(Locale::ALL.len(), 8, "a language added: list its file above"); + } } From 334868b73a56da34cab78a0d354cab271477f8dc Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:03:16 -0400 Subject: [PATCH 33/36] LRC-LIB-KEYWORDS: typed new keywords go inside the default parent Lightroom Classic puts new keywords typed for photos inside the keyword marked Put New Keywords Inside This Keyword; only + did here. The Keywords panel's box now does too (`Catalog::typed_keyword`): a new name goes inside the default parent, while a keyword the library has, or a path typed whole, stays as typed. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/keywords.rs | 24 ++++++++++++++++++++++++ crates/ui-egui/src/panels/right.rs | 3 ++- crates/ui-egui/src/tests_keyword_list.rs | 17 +++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index b79d245ac..d01c06741 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -345,6 +345,17 @@ impl Catalog { self.keyword_path(path).is_some() } + /// Where a keyword typed for a photo goes: inside the default parent when it is a new name (Put + /// New Keywords Inside This Keyword); a keyword the library has, or a path typed whole, as + /// typed (cleaned). + pub fn typed_keyword(&self, typed: &str) -> String { + let k = clean(typed); + match self.default_keyword_parent() { + Some(parent) if !k.is_empty() && !k.contains(SEP) && !self.has_keyword(&k) => format!("{parent}{SEP}{k}"), + _ => k, + } + } + /// `path` with each level the library has spelled as the library spells it (`TRAVEL|Rome` next /// to `travel|italy` is `travel|Rome`). fn spelled(&self, path: &str) -> String { @@ -1012,6 +1023,19 @@ mod tests { assert!(!c.keyword_suggestions(&["events|weddings".into()], "wed", 5).contains(&"Events|Weddings".to_string()), "not one it has"); } + /// A new name typed for a photo goes inside the default parent; a keyword the library has, and + /// a path typed whole, don't. + #[test] + fn typed_new_keywords_go_inside_the_default_parent() { + let (mut c, _) = lib(&[&["beach"], &["Events"]]); + assert_eq!(c.typed_keyword(" Weddings "), "Weddings", "no default parent"); + let op = c.set_default_parent_ops(Some("events")).unwrap(); + c.apply(op).unwrap(); + assert_eq!(c.typed_keyword("Weddings"), "Events|Weddings"); + assert_eq!(c.typed_keyword("BEACH"), "BEACH", "the library has it"); + assert_eq!(c.typed_keyword("Places|Lisbon"), "Places|Lisbon"); + } + #[test] fn suggestions_rank_co_occurrence_and_prefixes() { let (c, _) = lib(&[&["dog", "park"], &["dog", "park", "ball"], &["dog", "beach"], &["cat"], &["cat"], &["cat"], &["parade"], &["eagle"]]); diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index f8a817955..23915c84a 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -776,7 +776,8 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let r = ui.add(egui::TextEdit::singleline(&mut text).hint_text(crate::i18n::tr("Add keyword")).desired_width(f32::INFINITY)); register(ui.ctx(), "field:keyword", r.rect); if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) && !text.trim().is_empty() { - let kws: Vec = text.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(); + // a new name goes inside the default parent (Put New Keywords Inside This Keyword) + let kws: Vec = text.split(',').map(|s| app.session.catalog.typed_keyword(s)).filter(|s| !s.is_empty()).collect(); let _ = app.run("photo.setMeta", json!({"addKeywords": kws})); text.clear(); } diff --git a/crates/ui-egui/src/tests_keyword_list.rs b/crates/ui-egui/src/tests_keyword_list.rs index 6c638562a..f736efcb8 100644 --- a/crates/ui-egui/src/tests_keyword_list.rs +++ b/crates/ui-egui/src/tests_keyword_list.rs @@ -400,3 +400,20 @@ fn a_tick_box_doesnt_start_a_drag() { assert_eq!(h.app.ui.dragging_keyword, None); release(&mut h, from + egui::vec2(0.0, 60.0)); } + +/// Keywords typed in the Keywords panel's box go inside the default parent too when they are +/// new (Lightroom Classic's Put New Keywords Inside This Keyword); keywords the library has, and +/// paths typed whole, stay as typed. +#[test] +fn typed_keywords_go_inside_the_default_parent() { + let (mut h, ids) = keywords_panel(); + run(&mut h, "keyword.create", json!({"name": "Events"})); + run(&mut h, "keyword.setDefaultParent", json!({"keyword": "Events"})); + ask(&mut h, "ui.clickWidget", json!({"id": "field:keyword"})); + ask(&mut h, "ui.text", json!({"text": "Birthdays, mountains, Places|Lisbon"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + let k = keywords_of(&h, ids[0]); + for want in ["Events|Birthdays", "mountains", "Places|Lisbon"] { + assert!(k.contains(&want.to_string()), "{want}: {k:?}"); + } +} From decebccb8923b56c2a06a728add9c276b3e92ecb Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:04:02 -0400 Subject: [PATCH 34/36] LRC-LIB-KEYWORDS: dropping photos on a keyword counts those that get it The toast said every dropped photo got the keyword, those that had it already included. It now counts the photos that didn't have it. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/keyword_list.rs | 9 ++++++++- crates/ui-egui/src/tests_keyword_list.rs | 11 +++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 482db5648..46da5b028 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -285,7 +285,14 @@ fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, rect: Rect, path: &st } else if let Some(ids) = app.ui.dragging_photos.clone() { outline(ui); if released { - let n = ids.len(); + // only the photos that didn't have it get it + let lacking = |id: &u64| { + app.session + .catalog + .photo(PhotoId(*id)) + .is_some_and(|p| !p.meta.keywords.iter().any(|k| same(&lightcraft_catalog::keywords::clean(k), path))) + }; + let n = ids.iter().filter(|id| lacking(id)).count(); app.ui.dragging_photos = None; match app.run("photo.setMeta", json!({"ids": ids, "addKeywords": [path]})) { Ok(_) => app.toast( diff --git a/crates/ui-egui/src/tests_keyword_list.rs b/crates/ui-egui/src/tests_keyword_list.rs index f736efcb8..657e5f337 100644 --- a/crates/ui-egui/src/tests_keyword_list.rs +++ b/crates/ui-egui/src/tests_keyword_list.rs @@ -417,3 +417,14 @@ fn typed_keywords_go_inside_the_default_parent() { assert!(k.contains(&want.to_string()), "{want}: {k:?}"); } } + +/// Dropping photos on a keyword says how many got it: one that had it already isn't counted. +#[test] +fn dropping_photos_counts_those_that_get_the_keyword() { + let (mut h, ids) = keywords_panel_at([1400.0, 2400.0]); + run(&mut h, "keyword.create", json!({"name": "Portugal"})); + run(&mut h, "photo.setMeta", json!({"ids": [ids[1]], "addKeywords": ["Portugal"]})); + drag(&mut h, &format!("thumb:{}", ids[0]), "keywordRow:Portugal"); + let toast = h.app.ui.toast.clone().map(|t| t.0).unwrap_or_default(); + assert!(toast.contains("1 photo") && !toast.contains("2 photos"), "{toast}"); +} From 228561d4c93ee68a80314221ad0b72aa8a60edab Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:04:29 -0400 Subject: [PATCH 35/36] LRC-LIB-KEYWORDS: idle controls in the Keyword List say nothing With nothing selected a tick box still offered "Add to Selected Photos", and while a filter holds the levels open the triangle lit up on hover though a click does nothing. Neither does now. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/keyword_list.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 46da5b028..432f75c98 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -186,7 +186,9 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId } else { vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)] }; - ui.painter().add(egui::Shape::convex_polygon(pts, if tr.hovered() { t.text } else { t.text_dim }, Stroke::NONE)); + // while a filter opens the levels the triangle does nothing: it doesn't light up either + let lit = tr.hovered() && can_fold; + ui.painter().add(egui::Shape::convex_polygon(pts, if lit { t.text } else { t.text_dim }, Stroke::NONE)); if tr.clicked() && can_fold { let key = r.path.to_lowercase(); if r.open { @@ -219,10 +221,15 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId ui.painter().rect_stroke(boxr, 2.0, Stroke::new(1.0, edge), StrokeKind::Inside); } } - let tb = tb.on_hover_text(crate::i18n::tr(match state { - Tick::All => "Remove from Selected Photos", - _ => "Add to Selected Photos", - })); + // (with nothing selected it does nothing, and says nothing) + let tb = if on { + tb.on_hover_text(crate::i18n::tr(match state { + Tick::All => "Remove from Selected Photos", + _ => "Add to Selected Photos", + })) + } else { + tb + }; if tb.clicked() { let key = if state == Tick::All { "removeKeywords" } else { "addKeywords" }; let _ = app.run("photo.setMeta", json!({key: [r.path]})); From 2e016f73a614bd7004bb67867d3518228aca714e Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 15:05:12 -0400 Subject: [PATCH 36/36] LRC-LIB-KEYWORDS: the docs say what the keyword options do The parity row said Person shapes exported files: it is stored only. It now also covers the list without a selection, the default parent's reach (typed keywords too) and keywords' hierarchy on import. The synonyms' doc no longer says search finds them, which it doesn't yet. What's New follows. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/keywords.rs | 2 +- docs/parity.md | 2 +- docs/whats-new.md | 5 ++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index d01c06741..2de752ec4 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -70,7 +70,7 @@ fn dedupe(v: &mut Vec) { #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct KeywordInfo { - /// Other words for it, exported with it (when `export_synonyms`) and found by search. + /// Other words for it, exported with it (when `export_synonyms`). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub synonyms: Vec, /// Written into exported files at all. diff --git a/docs/parity.md b/docs/parity.md index 2b29858eb..e639467f7 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -752,7 +752,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | | LRC-LIB-VC | Virtual copies | P1 | ✅ | `cmd:photo.virtualCopy` | ⌘' | | LRC-LIB-LABELS | Colour-label sets | P1 | ✅ | `cmd:label.sets`, `cmd:label.applySet`, `cmd:label.saveSet`, `cmd:label.deleteSet`, `cmd:label.setNames`, `crates/engine/src/cmd/manage.rs` | built-in Colors / Review sets + user sets (Photo ▸ Set Color Label, Edit Label Names… dialog); names written to and read from `xmp:Label` | -| 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. Keyword tag options (include on export, export containing keywords, export synonyms, person) shape exported files: names in `dc:subject`, paths in `lr:hierarchicalSubject`. Not yet: synonyms in search, person keywords tied to faces, import/export of keyword lists, Keyword Shortcut, Edit Set, a selection-aware Keywording box | +| 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. Not yet: synonyms in search, person keywords tied to faces, import/export of keyword lists, Keyword Shortcut, Edit Set, a selection-aware Keywording box | | 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 diff --git a/docs/whats-new.md b/docs/whats-new.md index 55db7c592..2b8190242 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -11,8 +11,11 @@ Inside This Keyword, Purge Unused Keywords. - Drag a keyword onto another to nest it, or onto the list's title to bring it back to the top level; photos dragged from the grid onto a keyword get it. +- Right-click ▸ Put New Keywords Inside This Keyword: new keywords, made with + or typed in the box above, go inside + it. The list is there with no photo selected too, to set up keywords first. - Keyword tag options decide what exported files carry: Include on Export, Export Containing Keywords, Export - Synonyms. Exports now write keyword names to `dc:subject` and their paths to `lr:hierarchicalSubject`. + Synonyms. Exports now write keyword names to `dc:subject` and their paths to `lr:hierarchicalSubject`, and keywords + imported from files (or read from sidecars) keep the hierarchy `lr:hierarchicalSubject` gives them. ### Text fields - Right-click Search Photos, a slider's typed value, the Info panel's fields, the name field of New Album, Rename