From 3a145eef4e6f3ade630369e95f950dbe7b43236f Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:46:39 -0400 Subject: [PATCH] LRC-LIB-KEYWORDS: keywords from files keep their hierarchy Exports now write names to `dc:subject` and paths to `lr:hierarchicalSubject`, as Lightroom Classic does, but import and sidecars read only `dc:subject`: an exported `travel|Italy|Rome` came back as three keywords, Rome, Italy and travel. They now take the paths of `lr:hierarchicalSubject` and the names of `dc:subject` that aren't levels of those paths (`keywords::from_file`); without paths, the names as they are, so LightCraft's own sidecars read as before. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/keywords.rs | 22 ++++++++++++++++++++++ crates/engine/src/files.rs | 16 +++++++++++++++- crates/engine/src/sidecar.rs | 16 +++++++++++++++- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index b25728de..1fe9369f 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -43,6 +43,16 @@ pub fn reparent(k: &str, from: &str, to: &str) -> String { std::iter::once(to).filter(|t| !t.is_empty()).chain(rest).collect::>().join("|") } +/// A file's keywords as a photo carries them: the paths of `lr:hierarchicalSubject`, then the +/// names of `dc:subject` that aren't levels of those paths (Lightroom Classic writes each keyword's +/// name, its parents' and synonyms flat besides its path). Each once, cleaned. +pub fn from_file(flat: &[String], hierarchical: &[String]) -> Vec { + let mut out: Vec = hierarchical.iter().map(|k| clean(k)).collect(); + out.extend(flat.iter().map(|k| clean(k)).filter(|k| !hierarchical.iter().any(|h| h.split(SEP).any(|level| same(level.trim(), k))))); + dedupe(&mut out); + out +} + /// The keyword of `from` that `k` is under, the closest one when several are (`a|b` before `a`). pub fn closest<'a>(k: &str, from: &'a [String]) -> Option<&'a String> { from.iter().filter(|f| is_under(k, f)).max_by_key(|f| f.split(SEP).count()) @@ -924,6 +934,18 @@ mod tests { assert_eq!(c.default_keyword_parent(), None, "undo of the first: none was"); } + /// A file's keywords as a photo carries them: the paths of `lr:hierarchicalSubject`, and the + /// names of `dc:subject` that aren't levels of those paths (an exported `travel|Italy` comes + /// back as one keyword, not three). Without paths, the names as they are. + #[test] + fn a_files_keywords_keep_their_hierarchy() { + let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(from_file(&v(&["Rome", "Italy", "travel", "beach"]), &v(&["travel|Italy|Rome"])), ["travel|Italy|Rome", "beach"]); + assert_eq!(from_file(&v(&["ROME"]), &v(&["travel|Rome", " travel | rome "])), ["travel|Rome"], "each once, cleaned"); + assert_eq!(from_file(&v(&["beach", " sea "]), &[]), ["beach", "sea"]); + assert_eq!(from_file(&v(&["a|b"]), &[]), ["a|b"], "LightCraft's own sidecars write paths flat"); + } + #[test] fn suggestions_rank_co_occurrence_and_prefixes() { let (c, _) = lib(&[&["dog", "park"], &["dog", "park", "ball"], &["dog", "beach"], &["cat"], &["cat"], &["cat"], &["parade"], &["eagle"]]); diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index db762f00..53afc1c2 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -37,7 +37,8 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option) { usage_terms: m.usage_terms.clone().unwrap_or_default(), copyright_url: m.copyright_url.clone().unwrap_or_default(), creator: m.artist.clone().unwrap_or_default(), - keywords: m.keywords.clone(), + // paths from `lr:hierarchicalSubject`, names from `dc:subject` that aren't their levels + keywords: lightcraft_catalog::keywords::from_file(&m.keywords, &m.hierarchical_keywords), regions: m.regions.clone(), }; (meta, m.capture_time.as_ref().map(|d| d.to_iso())) @@ -587,6 +588,19 @@ impl crate::Session { #[cfg(test)] mod tests { + /// Keywords from a file keep their hierarchy: the paths of `lr:hierarchicalSubject`, not the + /// flat names of `dc:subject` that LightCraft's exports (and Lightroom Classic) write besides + /// them; an exported `travel|Italy|Rome` comes back as itself. + #[test] + fn imported_keywords_keep_their_hierarchy() { + let m = lightcraft_meta::Metadata { + keywords: vec!["Rome".into(), "Italy".into(), "travel".into(), "beach".into()], + hierarchical_keywords: vec!["travel|Italy|Rome".into()], + ..Default::default() + }; + assert_eq!(super::meta_of(&m).0.keywords, ["travel|Italy|Rome", "beach"]); + } + use super::*; use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index 7e924948..e040658c 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -133,7 +133,8 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result