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:
parent
d62a5637d8
commit
3a145eef4e
|
|
@ -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"]]);
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue