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 <noreply@anthropic.com>
This commit is contained in:
j-a-b-r-o-n-i
2026-10-24 15:26:42 -04:00
co-authored by Claude Opus 5.5
parent 38f8d25087
commit f6c5cab021
7 changed files with 159 additions and 8 deletions
+59 -1
View File
@@ -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::<Vec<_>>().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<String>,
/// 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<String>,
}
/// 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<String> = keywords.iter().map(|k| clean(k)).collect();
dedupe(&mut paths);
let mut flat: Vec<String> = 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::<Vec<_>>();
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<String> = 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
+5 -1
View File
@@ -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",
+74
View File
@@ -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#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:lr="http://ns.adobe.com/lightroom/1.0/"
xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
xmp:Rating="0" crs:Exposure2012="+0.40">
<dc:subject><rdf:Bag><rdf:li>Places</rdf:li><rdf:li>Italy</rdf:li><rdf:li>Rome</rdf:li><rdf:li>sunset</rdf:li></rdf:Bag></dc:subject>
<lr:hierarchicalSubject><rdf:Bag><rdf:li>Places|Italy|Rome</rdf:li><rdf:li>sunset</rdf:li></rdf:Bag></lr:hierarchicalSubject>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>"#;
/// 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("<dc:subject>").count(), 1, "{text}");
assert_eq!(text.matches("lr:hierarchicalSubject>").count(), 2, "one lr:hierarchicalSubject, opened and closed:\n{text}");
assert!(text.contains("<xmp:Rating>3</xmp:Rating>") && 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("<rdf:li>Places</rdf:li><rdf:li>Italy</rdf:li><rdf:li>Rome</rdf:li>", "<rdf:li>Places|Italy|Rome</rdf:li>")
.replace("<lr:hierarchicalSubject><rdf:Bag><rdf:li>Places|Italy|Rome</rdf:li><rdf:li>sunset</rdf:li></rdf:Bag></lr:hierarchicalSubject>", "");
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]
+1
View File
@@ -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 |
+2 -1
View File
@@ -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 (`<file>.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 `<file>.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 (`<file>.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 `<file>.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 |
+8
View File
@@ -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
+10 -5
View File
@@ -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
`<name>.xmp.bak-<date><time>` (`-1`, `-2`… if taken — a backup is never overwritten), then replaced. A sidecar that
can't be read at all (permissions) is left alone and the save fails.
Limits: LightCraft writes keywords to `dc:subject` only, so another application's `lr:hierarchicalSubject` is kept as
it was and may still list keywords removed in LightCraft. LightCraft doesn't write `crs:`: the other application's
Keywords are written the way Lightroom Classic and darktable write them, so other applications can search them: names
in `dc:subject`, paths in `lr:hierarchicalSubject` (`keywords::to_file`; read back by `keywords::from_file`, which gives
the same keywords). No export option applies to a sidecar, so no keyword is left out and no synonym added. Sidecars
from LightCraft 0.13 and older have paths in `dc:subject`; they still read as paths, and the next save writes them this
way.
Limits: LightCraft doesn't write `crs:`: the other application's
develop settings stay as that application left them, next to LightCraft's own (`lc:settings`, which LightCraft reads
first).