diff --git a/crates/catalog/src/journal.rs b/crates/catalog/src/journal.rs index 3b97c9623..dd30b57b0 100644 --- a/crates/catalog/src/journal.rs +++ b/crates/catalog/src/journal.rs @@ -47,6 +47,7 @@ //! | 2 | after v0.2.0 | `Op::SetBrowsed`, `Catalog.browsed`, `Photo.local_baseline` | //! | 3 | albums by hand | `Op::SetAlbumOrder`, `Album.order` | //! | 4 | Reload reads lens data | `Op::SetEmbeddedLens` | +//! | 5 | 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 @@ -71,7 +72,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 = 4; +pub const VERSION: u32 = 5; /// 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..2de752ec4 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -5,8 +5,14 @@ //! 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 std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; use crate::{Catalog, CatalogError, Meta, Op, Result}; @@ -18,16 +24,38 @@ 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()); 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`. -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}") } +/// 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 (ẞ / ß). +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("|") +} + +/// 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()) } /// Keep the first of case-insensitively equal keywords. @@ -36,6 +64,66 @@ 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`). + #[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, + /// 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 { + true +} + +impl Default for KeywordInfo { + fn default() -> Self { + KeywordInfo { + synonyms: Vec::new(), + include_on_export: true, + export_containing: true, + export_synonyms: true, + person: false, + new_keywords_inside: 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, +} + +/// 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 { @@ -63,9 +151,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 +173,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()); @@ -118,52 +209,314 @@ 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| { + // 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) => { + 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); + } + } + } + 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() { 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 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`. 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())); } 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| { - 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 }) } + /// 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| same(x, 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 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() + } + + /// 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 { + 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()) + } + + /// 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 { + 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()); + } + // the levels of a listed keyword below it, or of a photo's + let levels = k.split(SEP).count(); + let listed = self.keyword_list.values().map(|l| &l.path); + listed + .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("|")) + } + + /// 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 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))?; + 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 same(&to, &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 !same(&to, &from) && self.has_keyword(&to) { + return Err(CatalogError::KeywordExists(to)); + } + if let Op::Batch { ops: renamed } = self.rename_keyword_ops(&from, &to)? { + ops = renamed; + } + } + // 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 }) + } + + /// 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().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)))) } + } + /// 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 /// 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)); + // 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() { all.into_iter() @@ -263,6 +616,426 @@ 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())); + } + + 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); + } + + /// 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] + 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|ü"]); + } + + 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"]); + } + + /// 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"); + } + + /// 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!(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 + /// 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"); + } + + /// 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"]); + } + + /// 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"); + } + + /// 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"); + } + + /// 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"); + } + + /// 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()); + } + + /// 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"); + } + + /// 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/catalog/src/lib.rs b/crates/catalog/src/lib.rs index 192d654cf..6d6d10190 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}")] @@ -222,6 +225,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, @@ -244,6 +253,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, @@ -416,6 +428,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) @@ -668,6 +688,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 188d93414..8d4da86a6 100644 --- a/crates/catalog/src/local.rs +++ b/crates/catalog/src/local.rs @@ -300,6 +300,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 08e9abbc0..ecc7de7c1 100644 --- a/crates/catalog/src/tests_format_version.rs +++ b/crates/catalog/src/tests_format_version.rs @@ -60,6 +60,20 @@ fn v2_library_loads_and_is_upgraded() { 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 +/// in the current format. +#[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), u64::from(VERSION)); +} + #[test] fn versionless_log_only_library_is_upgraded() { let mut c = Catalog::new(); @@ -194,8 +208,9 @@ fn op_variants_are_versioned() { Op::SetBrowsed { .. } => 2, Op::SetAlbumOrder { .. } => 3, Op::SetEmbeddedLens { .. } => 4, + Op::SetKeyword { .. } => 5, } } - let newest = since(&Op::SetEmbeddedLens { id: PhotoId(0), lens: 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 9c84a84ab..6b8acdd2d 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") } } diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index 876cf485e..3d9492499 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}; +use lightcraft_catalog::keywords::{KeywordInfo, clean, closest, 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,20 +15,54 @@ 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 `{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. +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), + new_keywords_inside: base.new_keywords_inside, + } +} + +/// 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. @@ -185,6 +219,135 @@ 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.catalog.default_keyword_parent(), + }; + 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")), + }; + // 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)"))?; + 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["listed"]})) + } + ), + 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| { + let keyword = match p.get("keyword") { + Some(Value::Null) | None => None, + Some(_) => Some(named_keyword(s, p, "keyword.setDefaultParent")?), + }; + 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!( "keyword.rename", "Rename Keyword", @@ -197,9 +360,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!( @@ -229,8 +390,8 @@ 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(f) => format!("{into}{}", &k[f.len().min(k.len())..]), + Some(match closest(k, &from) { + Some(f) => reparent(k, f, &into), None => k.to_string(), }) }) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 1f2f3e012..87aba324c 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -805,16 +805,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/export.rs b/crates/engine/src/export.rs index f7dfcaedd..8ee7f9ac9 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -1232,7 +1232,8 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) } /// 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 @@ -1260,7 +1261,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. @@ -1369,7 +1372,7 @@ fn prepare_guarded( let (full_px, long) = ((p.width as usize).saturating_mul(p.height as usize), p.width.max(p.height) as usize); let (work, weight) = 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())?; let weight = render_weight(full_px, long, job.level, w.saturating_mul(h)); (Work::Render(Box::new(RenderWork { job, meta, opts: o.clone() })), weight) @@ -1837,9 +1840,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(); @@ -1848,21 +1870,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); diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index 835427a1f..59555aff7 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())) @@ -659,6 +660,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/lib.rs b/crates/engine/src/lib.rs index 8d3e9af4a..6b934c4e4 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -863,6 +863,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/sidecar.rs b/crates/engine/src/sidecar.rs index 9d130c8ac..58c82d930 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -135,7 +135,8 @@ pub fn parse_sidecar(xmp: &str, target: crate::crs::Target) -> std::result::Resu country: m.country.clone(), alt_text: m.alt_text.clone(), extended_description: m.extended_description.clone(), - keywords: (!m.keywords.is_empty()).then(|| m.keywords.clone()), + keywords: (!m.keywords.is_empty() || !m.hierarchical_keywords.is_empty()) + .then(|| lightcraft_catalog::keywords::from_file(&m.keywords, &m.hierarchical_keywords)), // A sidecar that has `mwg-rs:Regions` at all (even an empty list) was written by an app that // knows about regions, so it's authoritative: its list, empty or not, replaces the catalog's. // One without it (most writers, LightCraft's own included, which keeps another app's @@ -517,6 +518,19 @@ impl Session { #[cfg(test)] mod tests { + /// A sidecar written by Lightroom Classic (names flat, paths in `lr:hierarchicalSubject`) + /// gives the photo its keywords as paths. + #[test] + fn sidecar_keywords_keep_their_hierarchy() { + let m = lightcraft_meta::Metadata { + keywords: vec!["Lisbon".into(), "Places".into()], + hierarchical_keywords: vec!["Places|Lisbon".into()], + ..Default::default() + }; + let sc = super::parse_sidecar(&lightcraft_meta::write_xmp(&m, None), false).unwrap(); + assert_eq!(sc.keywords, Some(vec!["Places|Lisbon".to_string()])); + } + use super::*; use lightcraft_catalog::Meta; diff --git a/crates/engine/src/tests_keyword_list.rs b/crates/engine/src/tests_keyword_list.rs new file mode 100644 index 000000000..3d5e21666 --- /dev/null +++ b/crates/engine/src/tests_keyword_list.rs @@ -0,0 +1,172 @@ +//! 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|é")); +} + +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 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"); + 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.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.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.catalog.default_keyword_parent().as_deref(), Some("Occasions"), "kept with the library"); + s.execute("keyword.delete", &json!({"keyword": "occasions"})).unwrap(); + 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); +} + +/// 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()); +} + +/// 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")); +} + +/// 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()); +} + +/// 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); +} diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index 126723949..d4ae39838 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -206,5 +206,12 @@ "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.", + "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 f129711ae..8ac8a3cb5 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -1952,5 +1952,27 @@ "Keywords & People": "Stichwörter & Personen", "Description": "Beschreibung", "Camera Info": "Kamera-Info", - "Develop": "Bearbeitung" + "Develop": "Bearbeitung", + "Keyword List": "Stichwortliste", + "Filter Keywords": "Stichwörter filtern", + "No keywords yet": "Noch keine 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.", + "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…", + "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.", + "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-formats.json b/crates/ui-egui/locales/es-formats.json index 54418c768..72bec0202 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -206,5 +206,12 @@ "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.", + "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 55135fea5..176472c27 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1797,5 +1797,27 @@ "Keywords & People": "Palabras clave y personas", "Description": "Descripción", "Camera Info": "Información de la cámara", - "Develop": "Edición" + "Develop": "Edición", + "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", + "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.", + "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…", + "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.", + "Select photos to give them keywords.": "Selecciona fotos para darles palabras clave." } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index b0e0f2f99..dd6060b6f 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -206,5 +206,12 @@ "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}", + "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 1829c183f..1a3df9992 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1799,5 +1799,27 @@ "Keywords & People": "キーワードと人物", "Description": "説明", "Camera Info": "カメラ情報", - "Develop": "編集" + "Develop": "編集", + "Keyword List": "キーワードリスト", + "Filter Keywords": "キーワードを絞り込む", + "No keywords yet": "キーワードはまだありません", + "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.": "このキーワードを持つ写真はありません。下位のキーワードも削除されます。", + "Edit Keyword Tag…": "キーワードタグを編集…", + "Put New Keywords Inside This Keyword": "新しいキーワードをこのキーワードの中に入れる", + "Purge Unused Keywords": "使用されていないキーワードを削除", + "Delete Keyword…": "キーワードを削除…", + "Drop here for the top level": "ここにドロップすると最上位に移動", + "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-formats.json b/crates/ui-egui/locales/pt-br-formats.json index ab64e082d..ef31845fc 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -206,5 +206,12 @@ "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.", + "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 6ed8807cd..9fcff0c23 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1797,5 +1797,27 @@ "Keywords & People": "Palavras-chave e pessoas", "Description": "Descrição", "Camera Info": "Informações da câmera", - "Develop": "Edição" + "Develop": "Edição", + "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", + "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.", + "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…", + "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.", + "Select photos to give them keywords.": "Selecione fotos para dar palavras-chave a elas." } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index f3a0f647b..f1d4ca2f7 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -206,5 +206,12 @@ "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} вместе с вложенными ключевыми словами. Отмена вернёт его.", + "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 f5323ff1f..d9af04368 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1506,5 +1506,28 @@ "Focus (assisted culling)": "Резкость (помощь в отборе)", "Best of Similar Shots": "Лучший из похожих снимков", "Date": "Дата", - "Color Label": "Цветовая метка" + "Color Label": "Цветовая метка", + "Keyword List": "Список ключевых слов", + "Filter Keywords": "Фильтр ключевых слов", + "No keywords yet": "Ключевых слов пока нет", + "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.": "Ни у одного фото его нет; вложенные ключевые слова тоже будут удалены.", + "Edit Keyword Tag…": "Изменить ключевое слово…", + "Put New Keywords Inside This Keyword": "Помещать новые ключевые слова в это", + "Purge Unused Keywords": "Удалить неиспользуемые ключевые слова", + "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.": "Выберите фото, чтобы назначить им ключевые слова.", + "Person": "Человек" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index e32c7f667..f01ccdf73 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -206,5 +206,12 @@ "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}", + "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 57fc3303b..c12ea5c71 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1797,5 +1797,27 @@ "Keywords & People": "关键字与人物", "Description": "描述", "Camera Info": "相机信息", - "Develop": "编辑" + "Develop": "编辑", + "Keyword List": "关键字列表", + "Filter Keywords": "过滤关键字", + "No keywords yet": "还没有关键字", + "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.": "没有照片使用它;其下的关键字也会一并删除。", + "Edit Keyword Tag…": "编辑关键字标记…", + "Put New Keywords Inside This Keyword": "将新关键字放入此关键字中", + "Purge Unused Keywords": "清除未使用的关键字", + "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.": "选择照片以为其添加关键字。" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 182a7dc6b..8a4b64558 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -206,5 +206,12 @@ "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}", + "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 f72d422e1..19d74ee87 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1797,5 +1797,27 @@ "Keywords & People": "關鍵字與人物", "Description": "描述", "Camera Info": "相機資訊", - "Develop": "編輯" + "Develop": "編輯", + "Keyword List": "關鍵字清單", + "Filter Keywords": "篩選關鍵字", + "No keywords yet": "尚無關鍵字", + "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.": "沒有照片使用它;其下的關鍵字也會一併刪除。", + "Edit Keyword Tag…": "編輯關鍵字標記…", + "Put New Keywords Inside This Keyword": "將新關鍵字放入此關鍵字中", + "Purge Unused Keywords": "清除未使用的關鍵字", + "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.": "選取照片以加入關鍵字。" } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7ebda4785..463ca7d91 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -50,6 +50,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; @@ -982,6 +984,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); @@ -1090,6 +1093,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>)>, @@ -1190,6 +1195,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/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index e62317170..7f04cec1b 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -87,6 +87,10 @@ 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::MoveKeyword { .. } => "Merge Keywords", Dialog::MergeKeywords { .. } => "Merge Keywords", Dialog::NewSmartAlbum { .. } => "Create Smart Album", Dialog::AllMetadata { .. } => "All Metadata", @@ -719,6 +723,91 @@ 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)); + // 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 { + 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::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)); + 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)); @@ -822,7 +911,11 @@ 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", + // (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", Dialog::SamModel { then: Some(_), .. } if sam_installed => "Continue", Dialog::SamModel { .. } if sam_installed || sam_running || sam_nowhere => "", @@ -873,6 +966,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { | Dialog::NewAlbum { .. } | Dialog::NewSmartAlbum { .. } | Dialog::SmartRules { .. } + | Dialog::KeywordTag { .. } ) => { app.toast(ctx, e) @@ -1076,6 +1170,57 @@ 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); + 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); + 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::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), @@ -1318,3 +1463,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 new file mode 100644 index 000000000..432f75c98 --- /dev/null +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -0,0 +1,607 @@ +//! 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::{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; + +/// 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); + // 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 = 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); + } + 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| { + ui.add_space(24.0); + crate::text_field::TextField::singleline("field:keywordFilter", &mut filter) + .hint(crate::i18n::tr("Filter Keywords")) + .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| 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() + && 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); + 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(); + let ticks = app.caches.keyword_ticks(&app.session.catalog, &selection); + for r in &rows { + row(app, ui, r, &selection, &ticks, filter.trim().is_empty()); + } + ui.add_space(12.0); +} + +/// 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 { + pointer.is_some_and(|p| rect.contains(p) && ui.clip_rect().contains(p)) +} + +/// 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(); + 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; + } + match app.run("keyword.move", json!({"keyword": keyword, "parent": parent})) { + Ok(_) => follow(app, keyword, &to), + Err(e) => 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], 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); + 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 { + 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)] + }; + // 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 { + 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 = 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(); + // (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 { + 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); + } + } + // (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]})); + } + // 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) }; + 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.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); + } + let resp = resp.on_hover_text(r.path.replace('|', " › ")); + 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)); + } + 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 !shown_under(ui, rect, pointer) { + 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 { + // 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( + 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('|', " › "); + 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.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) }})); + } + 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. +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.catalog.default_keyword_parent()); + 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. +#[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, +} + +/// 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)] +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. 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!(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); + } + + /// 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"); + } +} diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index 004adf76c..87fd4f413 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 8e8d0b07a..11b3eb7ab 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; @@ -807,7 +819,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(); } @@ -883,6 +896,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..20fddc3b7 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -449,6 +449,14 @@ 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 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)] @@ -557,6 +565,36 @@ 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, + }, + /// 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, + /// Photos that have it (or one below it). + count: usize, + }, /// Merge keywords into another one on every photo. MergeKeywords { from: Vec, @@ -757,6 +795,9 @@ impl Default for UiState { slideshow: None, second_window: false, 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 new file mode 100644 index 000000000..657e5f337 --- /dev/null +++ b/crates/ui-egui/src/tests_keyword_list.rs @@ -0,0 +1,430 @@ +//! 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) { + 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, 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"})), + ("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")); +} + +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); +} + +/// 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.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"})); + 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.catalog.default_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"); + 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"]); +} + +/// 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); + 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 +/// 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()); +} + +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"); +} + +/// 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"); +} + +/// 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"])); +} + +/// 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")); +} + +/// 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)); +} + +/// 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:?}"); + } +} + +/// 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}"); +} diff --git a/docs/parity.md b/docs/parity.md index 74bccbeec..8e30077df 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`), `cmd:photo.decreaseRating`, `cmd:photo.increaseRating`, `crates/ui-egui/src/shortcuts.rs` | 0 clears; 1–5 set; Shift+0–5 applies and advances once, including with Auto Advance. Default grid brackets adjust each photo by one, clamped to 0–5; one batch undo and AutoWrite. 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. 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 8f0016d18..10759aad8 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -38,6 +38,21 @@ means no (it used to count as yes), and a value that is neither yes nor no is refused. - Any Searchable Text also finds the state / province, alt text, people and a virtual copy's name. +### 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. +- 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`, 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 Album, New Smart Album, Rename Keyword, Merge Keywords and similar dialogs, or the name field under unnamed faces for