LRC-LIB-KEYWORDS: the keywords an exported file carries

`Catalog::export_keywords` applies Lightroom Classic's keyword tag
options: a keyword left out of export goes nowhere; otherwise its name
and the keywords containing it that are exported themselves (unless
"export containing keywords" is off) and its synonyms (unless "export
synonyms" is off) go out flat, for `dc:subject`, and its full path for
`lr:hierarchicalSubject`. Each name once, whatever its case.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford 2026-10-09 14:06:28 -04:00
parent fba3bdef2c
commit 7029c8fbbd
No known key found for this signature in database
1 changed files with 97 additions and 0 deletions

View File

@ -83,6 +83,16 @@ pub struct ListedKeyword {
pub info: KeywordInfo,
}
/// A photo's keywords as an exported file carries them.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ExportKeywords {
/// Names, flat (`dc:subject`): each keyword's own, the keywords containing it and synonyms, as
/// their options say.
pub flat: Vec<String>,
/// Full paths (`lr:hierarchicalSubject`).
pub hierarchical: Vec<String>,
}
/// One node of the keyword tree.
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct KeywordNode {
@ -250,6 +260,52 @@ impl Catalog {
Ok(Op::Batch { ops })
}
/// What exported files carry for a photo with these keywords (Lightroom Classic's keyword tag
/// options, [`KeywordInfo`]): a keyword left out of export goes nowhere; otherwise its name
/// and full path, the keywords containing it that are exported themselves (unless "export
/// containing keywords" is off) and synonyms (unless "export synonyms" is off). Each name once,
/// whatever its case.
pub fn export_keywords(&self, keywords: &[String]) -> ExportKeywords {
let defaults = KeywordInfo::default();
let info = |path: &str| self.keyword_info(path).unwrap_or(&defaults);
let mut out = ExportKeywords::default();
let push = |v: &mut Vec<String>, s: &str| {
if !s.is_empty() && !v.iter().any(|x| x.eq_ignore_ascii_case(s)) {
v.push(s.to_string());
}
};
for k in keywords {
let path = clean(k);
let me = info(&path);
if path.is_empty() || !me.include_on_export {
continue;
}
let levels: Vec<&str> = path.split(SEP).collect();
let mut names = |at: usize, i: &KeywordInfo| {
if let Some(name) = levels.get(at) {
push(&mut out.flat, name);
}
if i.export_synonyms {
for s in &i.synonyms {
push(&mut out.flat, s.trim());
}
}
};
names(levels.len().saturating_sub(1), me);
if me.export_containing {
for at in (0..levels.len().saturating_sub(1)).rev() {
let parent = levels.get(..=at).map(|l| l.join("|")).unwrap_or_default();
let pi = info(&parent);
if pi.include_on_export {
names(at, pi);
}
}
}
push(&mut out.hierarchical, &path);
}
out
}
/// The keyword is in the tree: listed, or on a photo (itself or one below it).
pub fn has_keyword(&self, path: &str) -> bool {
let k = clean(path);
@ -682,6 +738,47 @@ mod tests {
assert_eq!(kws(&c, ids[1]), ["Weg|ü"]);
}
fn exported(c: &Catalog, keywords: &[&str]) -> (Vec<String>, Vec<String>) {
let e = c.export_keywords(&keywords.iter().map(|k| k.to_string()).collect::<Vec<_>>());
(e.flat, e.hierarchical)
}
/// By default a keyword is exported as its name together with the keywords containing it (flat,
/// `dc:subject`), and as its full path (`lr:hierarchicalSubject`); each name once.
#[test]
fn a_keyword_exports_its_name_its_parents_and_its_path() {
let c = Catalog::new();
let (flat, paths) = exported(&c, &["travel|Italy|Rome", "Travel|Spain", "beach"]);
assert_eq!(flat, ["Rome", "Italy", "travel", "Spain", "beach"]);
assert_eq!(paths, ["travel|Italy|Rome", "Travel|Spain", "beach"]);
}
/// A keyword not included on export is left out, path and all; a parent left out (a keyword that
/// only organizes others) isn't exported as a containing keyword, but its children are.
#[test]
fn keywords_left_out_of_export_stay_out() {
let mut c = Catalog::new();
let out = KeywordInfo { include_on_export: false, ..KeywordInfo::default() };
c.apply(Op::SetKeyword { path: "Places".into(), info: Some(out.clone()) }).unwrap();
c.apply(Op::SetKeyword { path: "draft".into(), info: Some(out) }).unwrap();
let (flat, paths) = exported(&c, &["Places|Lisbon", "draft"]);
assert_eq!(flat, ["Lisbon"]);
assert_eq!(paths, ["Places|Lisbon"]);
}
/// Without "export containing keywords" only the keyword's own name goes out flat; synonyms go
/// out with it unless "export synonyms" is off.
#[test]
fn containing_keywords_and_synonyms_follow_their_options() {
let mut c = Catalog::new();
let alone = KeywordInfo { export_containing: false, ..with_synonyms(&["Lisboa"]) };
c.apply(Op::SetKeyword { path: "Places|Lisbon".into(), info: Some(alone) }).unwrap();
let quiet = KeywordInfo { export_synonyms: false, ..with_synonyms(&["marriage"]) };
c.apply(Op::SetKeyword { path: "Events|Weddings".into(), info: Some(quiet) }).unwrap();
let (flat, _) = exported(&c, &["Places|Lisbon", "Events|Weddings"]);
assert_eq!(flat, ["Lisbon", "Lisboa", "Weddings", "Events"]);
}
#[test]
fn suggestions_rank_co_occurrence_and_prefixes() {
let (c, _) = lib(&[&["dog", "park"], &["dog", "park", "ball"], &["dog", "beach"], &["cat"], &["cat"], &["cat"], &["parade"], &["eagle"]]);