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 <noreply@anthropic.com>
This commit is contained in:
Nathaniel Ledford 2026-10-09 14:46:39 -04:00
parent d62a5637d8
commit 3a145eef4e
No known key found for this signature in database
3 changed files with 52 additions and 2 deletions

View File

@ -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::<Vec<_>>().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<String> {
let mut out: Vec<String> = 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::<Vec<_>>();
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"]]);

View File

@ -37,7 +37,8 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) {
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};

View File

@ -133,7 +133,8 @@ pub fn parse_sidecar(xmp: &str, raw: bool) -> std::result::Result<SidecarData, S
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
@ -515,6 +516,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;