From f6c5cab0210a22a13b3d3a42c35da020a0ef4ef8 Mon Sep 17 00:00:00 2001 From: j-a-b-r-o-n-i <4079197+j-a-b-r-o-n-i@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:26:42 -0400 Subject: [PATCH] LR-META-XMP: write sidecar keywords as names in dc:subject and paths in lr:hierarchicalSubject Saving a sidecar wrote each keyword's path into dc:subject. A photo keyworded Places|Italy|Rome got "Places|Italy|Rome" there instead of "Places", "Italy" and "Rome", after any change with auto-write on, even a rating. dc:subject is the flat list Lightroom, darktable, digiKam and exiftool-based tools search, so the photo stopped turning up for "Rome" in a library shared with them. keywords::to_file is the inverse of from_file: every level of every keyword goes to dc:subject, and when any keyword has levels, every keyword goes to lr:hierarchicalSubject as its path. It applies no export options (a sidecar is the photo's own record), so from_file reads the pair back as the same keywords. lr:hierarchicalSubject is now owned, so it is replaced on save and removed when the photo has no keywords, which also ends the documented limit where a keyword removed here stayed in another app's hierarchical list. Sidecars from 0.13 and older (paths in dc:subject) still read as paths and are written the new way on the next save. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/keywords.rs | 60 ++++++++++++++++++++++++++- crates/engine/src/sidecar.rs | 6 ++- crates/engine/src/tests_xmp.rs | 74 ++++++++++++++++++++++++++++++++++ docs/gaps.md | 1 + docs/parity-checklist.md | 3 +- docs/whats-new.md | 8 ++++ docs/xmp-interop.md | 15 ++++--- 7 files changed, 159 insertions(+), 8 deletions(-) diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index 6b0c97f5a..3adb1a748 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -43,6 +43,33 @@ pub fn reparent(k: &str, from: &str, to: &str) -> String { std::iter::once(to).filter(|t| !t.is_empty()).chain(rest).collect::>().join("|") } +/// A photo's keywords as a sidecar writes them: the two lists [`to_file`] fills. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FileKeywords { + /// For `dc:subject`: every level of every keyword, each once. + pub flat: Vec, + /// For `lr:hierarchicalSubject`: every keyword as its path, when any keyword has levels; + /// empty otherwise, so a library without hierarchy doesn't gain the property. + pub hierarchical: Vec, +} + +/// A photo's keywords as a sidecar writes them, the inverse of [`from_file`]. `dc:subject` is the +/// flat list other applications search, so it gets names (`travel`, `Italy`, `Rome`), never paths, +/// as Lightroom Classic and darktable write it; the paths go to `lr:hierarchicalSubject`. Unlike +/// [`Catalog::export_keywords`] it applies no export options (a sidecar is the photo's own record, +/// not an export): no keyword is left out and no synonym added, so [`from_file`] reads the pair +/// back as the same keywords. When any keyword has levels, every keyword is listed in +/// `hierarchical`, the flat ones too: a flat `Rome` beside `travel|Italy|Rome` would otherwise be +/// taken for a level of the path on the way back. +pub fn to_file(keywords: &[String]) -> FileKeywords { + let mut paths: Vec = keywords.iter().map(|k| clean(k)).collect(); + dedupe(&mut paths); + let mut flat: Vec = paths.iter().flat_map(|p| p.split(SEP).map(str::to_string)).collect(); + dedupe(&mut flat); + let hierarchical = if paths.iter().any(|p| p.contains(SEP)) { paths } else { Vec::new() }; + FileKeywords { flat, hierarchical } +} + /// 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. @@ -1264,7 +1291,38 @@ mod tests { 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"); + assert_eq!(from_file(&v(&["a|b"]), &[]), ["a|b"], "sidecars from LightCraft 0.13 and older wrote paths flat"); + } + + /// A sidecar's keywords: names in `dc:subject`, never paths, and the paths in + /// `lr:hierarchicalSubject`; read back, the same keywords, however they were spelt or nested. + #[test] + fn a_sidecar_writes_names_flat_and_paths_hierarchical() { + let v = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::>(); + let f = to_file(&v(&["Places|Italy|Rome", "sunset"])); + assert_eq!(f.flat, ["Places", "Italy", "Rome", "sunset"]); + assert_eq!(f.hierarchical, ["Places|Italy|Rome", "sunset"]); + assert!(f.flat.iter().all(|k| !k.contains(SEP)), "no path in dc:subject"); + + let f = to_file(&v(&["beach", " sea ", "Beach"])); + assert_eq!((f.flat, f.hierarchical), (v(&["beach", "sea"]), v(&[])), "no hierarchy: no lr:hierarchicalSubject"); + assert_eq!(to_file(&[]), FileKeywords::default()); + + for keywords in [ + v(&["Places|Italy|Rome", "sunset"]), + v(&["travel|Italy|Rome", "Rome"]), + v(&["travel|Italy", "travel|France", "Italy"]), + v(&["beach", "sea"]), + v(&[" a | b ", "A|B|c"]), + ] { + let f = to_file(&keywords); + let mut back = from_file(&f.flat, &f.hierarchical); + let mut want: Vec = keywords.iter().map(|k| clean(k)).collect(); + dedupe(&mut want); + back.sort_by_key(|k| k.to_lowercase()); + want.sort_by_key(|k| k.to_lowercase()); + assert_eq!(back, want, "round trip of {keywords:?}"); + } } /// A new keyword inside one the library has takes the library's spelling of it: creating diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index d13b95125..bd0b04b9a 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -354,6 +354,8 @@ pub fn sidecar_packet(p: &Photo, cat: &lightcraft_catalog::Catalog) -> String { /// `remove_location` is set (an export with "Remove location info"). pub fn sidecar_packet_with(p: &Photo, cat: &lightcraft_catalog::Catalog, remove_location: bool) -> String { let nz = |s: &str| (!s.trim().is_empty()).then(|| s.to_string()); + // Names in `dc:subject`, paths in `lr:hierarchicalSubject` (`keywords::to_file`). + let keywords = lightcraft_catalog::keywords::to_file(&p.meta.keywords); let meta = lightcraft_meta::Metadata { software: Some("LightCraft".into()), title: nz(&p.meta.title), @@ -369,7 +371,8 @@ pub fn sidecar_packet_with(p: &Photo, cat: &lightcraft_catalog::Catalog, remove_ usage_terms: nz(&p.meta.usage_terms), copyright_url: nz(&p.meta.copyright_url), artist: nz(&p.meta.creator), - keywords: p.meta.keywords.clone(), + keywords: keywords.flat, + hierarchical_keywords: keywords.hierarchical, rating: Some(p.rating.min(5) as i8), label: p.label.map(|l| cat.label_name(l)), capture_time: p.captured.as_deref().and_then(lightcraft_meta::DateTime::parse_iso), @@ -404,6 +407,7 @@ pub const OWNED: &[&str] = &[ "dc:rights", "dc:creator", "dc:subject", + "lr:hierarchicalSubject", "Iptc4xmpCore:Location", "Iptc4xmpCore:AltTextAccessibility", "Iptc4xmpCore:ExtDescrAccessibility", diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 129662e4d..8f85f6b85 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -288,6 +288,80 @@ fn a_crs_change_after_a_read_is_taken_with_auto_write_on() { let _ = std::fs::remove_dir_all(&src); } +/// A sidecar with keywords as Lightroom Classic and darktable write them: names in `dc:subject`, +/// paths in `lr:hierarchicalSubject`. +const KEYWORDED: &str = r#" + + + + PlacesItalyRomesunset + Places|Italy|Romesunset + + + +"#; + +/// Saving a sidecar keeps keywords where other applications look for them: names in `dc:subject` +/// and paths in `lr:hierarchicalSubject`, each property once. LightCraft 0.13 wrote the paths into +/// `dc:subject` (`Places|Italy|Rome`), so after any change, even a rating, a search for "Rome" in +/// another DAM no longer found the photo. A sidecar written that way is put right on the next save, +/// and keywords added here keep their hierarchy when the sidecar is read back. +#[test] +fn saving_a_sidecar_writes_keyword_names_flat_and_paths_hierarchical() { + let src = temp_dir("keywords-flat"); + write_png(&src.join("rome.png"), 8); + let sidecar = src.join("rome.xmp"); + std::fs::write(&sidecar, KEYWORDED).unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let id = only(&s); + assert_eq!(s.catalog.photo(id).unwrap().meta.keywords, ["Places|Italy|Rome", "sunset"]); + s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap(); + s.execute("photo.rate", &json!({"ids": [id.0], "rating": 3})).unwrap(); + + let lists = |text: &str| { + let x = lightcraft_meta::parse_xmp(text).unwrap(); + (x.metadata.keywords, x.metadata.hierarchical_keywords) + }; + let text = std::fs::read_to_string(&sidecar).unwrap(); + let (flat, hierarchical) = lists(&text); + assert_eq!(flat, ["Places", "Italy", "Rome", "sunset"], "names, not paths, in dc:subject:\n{text}"); + assert_eq!(hierarchical, ["Places|Italy|Rome", "sunset"], "{text}"); + assert_eq!(text.matches("").count(), 1, "{text}"); + assert_eq!(text.matches("lr:hierarchicalSubject>").count(), 2, "one lr:hierarchicalSubject, opened and closed:\n{text}"); + assert!(text.contains("3") && text.contains(r#"crs:Exposure2012="+0.40""#), "{text}"); + + // a keyword added here keeps its hierarchy through the file + s.execute("photo.setMeta", &json!({"ids": [id.0], "keywords": ["Places|Italy|Rome", "sunset", "people|Ada"]})).unwrap(); + let (flat, hierarchical) = lists(&std::fs::read_to_string(&sidecar).unwrap()); + assert_eq!(flat, ["Places", "Italy", "Rome", "sunset", "people", "Ada"]); + assert_eq!(hierarchical, ["Places|Italy|Rome", "sunset", "people|Ada"]); + s.execute("photo.setMeta", &json!({"ids": [id.0], "keywords": []})).unwrap(); + let text = std::fs::read_to_string(&sidecar).unwrap(); + assert!(!text.contains("dc:subject") && !text.contains("lr:hierarchicalSubject"), "clearing keywords clears both lists:\n{text}"); + s.execute("edit.undo", &json!({})).unwrap(); // undo rewrites the sidecar with the three keywords + s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().meta.keywords, ["Places|Italy|Rome", "sunset", "people|Ada"], "read back as written"); + + // a sidecar from LightCraft 0.13 (paths in dc:subject) reads as paths and is written properly + let old = KEYWORDED + .replace("PlacesItalyRome", "Places|Italy|Rome") + .replace("Places|Italy|Romesunset", ""); + std::fs::write(&sidecar, old).unwrap(); + s.execute("photo.readMetadataFromFile", &json!({"ids": [id.0]})).unwrap(); + assert_eq!(s.catalog.photo(id).unwrap().meta.keywords, ["Places|Italy|Rome", "sunset"]); + s.execute("photo.rate", &json!({"ids": [id.0], "rating": 4})).unwrap(); + let (flat, hierarchical) = lists(&std::fs::read_to_string(&sidecar).unwrap()); + assert_eq!(flat, ["Places", "Italy", "Rome", "sunset"]); + assert_eq!(hierarchical, ["Places|Italy|Rome", "sunset"]); + let _ = std::fs::remove_dir_all(&src); +} + /// Lightroom's default profiles (`Adobe Standard`, `Adobe Color`) are named by nearly every sidecar /// and our own starting look stands in for them: importing one gives no camera-profile warning. #[test] diff --git a/docs/gaps.md b/docs/gaps.md index ceea3724f..bfa7b4c50 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -150,3 +150,4 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | 2026-10-10 | minor | Gap 8: Sony ILCE-7RM6 in-file distortion added next to the ILCE-7RM4A's, its lateral CA parsed but off by default (docs/sony-ilce7rm6-lens-corrections.md); the gap stays open (no lens database), hours unchanged | | 2026-10-14 | minor | Gap 10 shrinks: 97 Canon CR2, Pentax and Ricoh models verified on one CC0 file each (334 models, ~50% on a verified body) | | 2026-10-14 | minor | Gap 10: 515 of 1,446 models verified (36%), ~84% of photographers on a verified body (integration batch 36) | +| 2026-10-24 | patch | LR-META-XMP: sidecar keywords are written as names in `dc:subject` and paths in `lr:hierarchicalSubject`, so other DAMs can search them; ranked gaps and estimates unchanged | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index d059494ce..48ca9f319 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -168,7 +168,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | βœ… | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings β†’ Import β†’ Metadata; fills only photos without their own | | LR-META-LOCATION | Location editing | P2 | 🟑 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`), `cmd:metadata.reverseGeocode` | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; Fill Location fills empty City / State / Country from GPS offline (GeoNames, downloaded on request: `cmd:metadata.downloadPlaceNames`); no embedded map | | LR-META-COPYPASTE | Copy / paste metadata | P2 | βœ… | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset | -| LR-META-XMP | XMP read/write | P0 | βœ… | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | manual rereading also reads sidecar-free JPEG embedded XMP (first complete standard APP1 before SOS/EOI, within 16 MiB of headers and 4096 marker/fill steps; no extended assembly or between-scan packets, not strict whole-JPEG validation; reader cases in `crates/engine/src/sidecar.rs`); a discovered unreadable sidecar fails without embedded fallback; procedural refresh/undo, non-UTF-8 sidecar and two explicit malformed fixtures in `crates/engine/src/tests_xmp.rs` (file-write checks use auto-write off); sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte (ownership follows namespace URI: foreign namespaces using `lc:` or `xmp:` cannot supply native settings or ratings and survive saving), an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw; an import ranks the files it is adding the same way, so a TIFF or JPEG arriving beside its raw reads its own `.xmp` and never the raw's `IMG_0001.xmp`, issue #1905); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` β†’ Spot removal); radial masks (`Mask/CircularGradient`) keep Lightroom's inside / outside: `crs:Flipped="true"` is the effect-inside flag of current masks and of the legacy Radial Filter, `crs:MaskInverted` the inversion, checked against Lightroom Classic's renders (issue #1815); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934); settings Lightroom writes only for itself (`CompatibleVersion`, the profile digests, the lens profile's name / file / setup, `CropConstrainToUnitSquare`, the lens profile's strength at 100 %) are not reported as unimported (`crates/engine/src/crs.rs`) | +| LR-META-XMP | XMP read/write | P0 | βœ… | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | manual rereading also reads sidecar-free JPEG embedded XMP (first complete standard APP1 before SOS/EOI, within 16 MiB of headers and 4096 marker/fill steps; no extended assembly or between-scan packets, not strict whole-JPEG validation; reader cases in `crates/engine/src/sidecar.rs`); a discovered unreadable sidecar fails without embedded fallback; procedural refresh/undo, non-UTF-8 sidecar and two explicit malformed fixtures in `crates/engine/src/tests_xmp.rs` (file-write checks use auto-write off); sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; keywords are saved as names in `dc:subject` and paths in `lr:hierarchicalSubject` (`keywords::to_file`; before, paths went into `dc:subject`); saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte (ownership follows namespace URI: foreign namespaces using `lc:` or `xmp:` cannot supply native settings or ratings and survive saving), an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`.xmp` for all but the raw; an import ranks the files it is adding the same way, so a TIFF or JPEG arriving beside its raw reads its own `.xmp` and never the raw's `IMG_0001.xmp`, issue #1905); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` β†’ Spot removal); radial masks (`Mask/CircularGradient`) keep Lightroom's inside / outside: `crs:Flipped="true"` is the effect-inside flag of current masks and of the legacy Radial Filter, `crs:MaskInverted` the inversion, checked against Lightroom Classic's renders (issue #1815); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934); settings Lightroom writes only for itself (`CompatibleVersion`, the profile digests, the lens profile's name / file / setup, `CropConstrainToUnitSquare`, the lens profile's strength at 100 %) are not reported as unimported (`crates/engine/src/crs.rs`) | | LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | βœ… | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | `photo.allMetadata` reuses loaded original bytes for embedded XMP and keeps sidecar precedence (`crates/engine/src/tests_xmp.rs`); read and written on export; Info β–Έ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; PSD/PSB EXIF resource 1058 and XMP resource 1060 feed the same metadata rows and preserve sidecar precedence; metadata presets | ## F. Edit panel β€” global adjustments (EDIT) @@ -956,3 +956,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟑) | | 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10Β° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1Β° fell from 79 to 19 of the 300. Downgraded βœ… β†’ 🟑: it still leaves most tilted photos alone | | 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (βœ…) | +| 2026-10-24 | patch | LR-META-XMP: sidecars get keyword names in `dc:subject` and paths in `lr:hierarchicalSubject` (now owned), instead of paths in `dc:subject`; older sidecars still read as paths; status and counts unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index c6c259200..7c5fddf5e 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,14 @@ ## 2026-10-24 +### Sidecar keywords stay searchable in other applications +- Saving an XMP sidecar wrote each keyword's path into `dc:subject`, so after any change, even a star rating, a + photo keyworded `Places|Italy|Rome` listed `Places|Italy|Rome` there instead of `Places`, `Italy` and `Rome`, + and a search for "Rome" in Lightroom, darktable, digiKam or another DAM no longer found it. Sidecars now get the + names in `dc:subject` and the paths in `lr:hierarchicalSubject`, as Lightroom Classic writes them, and LightCraft + reads them back as the same keywords. A sidecar written by an earlier version is put right the next time it is + saved. + ### Relative-date filters and smart albums follow the clock - A current view or source count using β€œin the last” rules now refreshes when the session clock moves into a new minute, even when no catalog edit occurred. This fixes stale photos remaining in the grid and stale counts for diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index 7fd11a172..6c17a0df8 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -22,7 +22,7 @@ What we write (standard namespaces, so other tools can read the metadata): | Title / caption / copyright / creator | `dc:title` / `dc:description` / `dc:rights` / `dc:creator` | | Copyright status | `xmpRights:Marked` (`True` copyrighted, `False` public domain, absent = unknown) | | Rights usage terms / copyright info URL | `xmpRights:UsageTerms` / `xmpRights:WebStatement` | -| Keywords | `dc:subject` | +| Keywords | `dc:subject` (names: `Places`, `Italy`, `Rome`) and, when any keyword has levels, `lr:hierarchicalSubject` (paths: `Places\|Italy\|Rome`, with the flat keywords too) | | Capture time, GPS | `exif:DateTimeOriginal`, `photoshop:DateCreated`, `exif:GPSLatitude`/`GPSLongitude` | | Pick / reject flag | `lc:flag` (`pick`, `reject`, `none`) | | Location | `lc:location` | @@ -70,14 +70,14 @@ A sidecar may already hold another application's data β€” e.g. its `crs:` develo the only copy of those edits outside that application's catalog. Saving never drops it: - LightCraft **owns** the properties in the table above: `xmp:Rating`, `xmp:Label`, `dc:title`, `dc:description`, - `dc:rights`, `dc:creator`, `dc:subject`, `Iptc4xmpCore:Location`, `Iptc4xmpCore:AltTextAccessibility`, + `dc:rights`, `dc:creator`, `dc:subject`, `lr:hierarchicalSubject`, `Iptc4xmpCore:Location`, `Iptc4xmpCore:AltTextAccessibility`, `Iptc4xmpCore:ExtDescrAccessibility`, `photoshop:City`/`State`/`Country`, `xmpRights:Marked`/`UsageTerms`/ `WebStatement` and everything in `lc:`. They are replaced on every save, and removed when LightCraft has no value (clearing a title in LightCraft clears it in the file). All of them are read back on import, so the library starts from what the sidecar said. - The **capture time** (`exif:DateTimeOriginal`, `photoshop:DateCreated`) and **GPS** (`exif:GPSLatitude`, `exif:GPSLongitude`) are replaced only when LightCraft has a value; otherwise the file's stay. -- **Everything else is kept byte for byte**: other namespaces (`crs:`, `xmpMM:`, `lr:hierarchicalSubject`, unknown +- **Everything else is kept byte for byte**: other namespaces (`crs:`, `xmpMM:`, the rest of `lr:`, unknown ones), `xmp:CreatorTool`, comments, the packet wrapper and padding. LightCraft's properties go into one `rdf:Description` of their own; owned properties written by another application (in attribute or element form) are removed from its description, which otherwise stays as it was. @@ -88,8 +88,13 @@ the only copy of those edits outside that application's catalog. Saving never dr `.xmp.bak-