Files
lightcraft/crates/engine/src/sidecar.rs
T
2026-10-15 03:02:56 -04:00

902 lines
40 KiB
Rust

//! XMP sidecars: `<stem>.xmp` (default) or `<file>.xmp` next to the original.
//!
//! **Write** ([`Session::save_sidecar`], `photo.saveMetadataToFile`, or automatically after every
//! change when [`XmpPrefs::auto_write`] is on): rating, colour label, title, caption, copyright,
//! creator, keywords, capture time and GPS in the standard namespaces, plus our complete develop
//! settings (`lc:settings`, JSON), the pick/reject flag (`lc:flag`) and location (`lc:location`).
//!
//! An existing sidecar is never replaced wholesale (issue #92): LightCraft's properties
//! ([`OWNED`], [`OWNED_IF_STATED`]) are merged into it ([`lightcraft_meta::merge_xmp`]) and
//! everything else — another editor's `crs:` develop settings, `xmpMM` history, unknown
//! namespaces — is kept byte for byte. A sidecar that can't be read as XMP is first copied to
//! `<name>.xmp.bak-<time>` (never overwritten), then replaced. Writes are atomic (temp file,
//! fsync, rename).
//!
//! **Shared names** (Stem naming): `IMG_0001.CR3` and `IMG_0001.JPG` would share `IMG_0001.xmp`.
//! When two catalogued files map to one stem sidecar, it belongs to one of them (a raw first, then
//! the first by file name — [`Session::sidecar_naming`]); the others use `<file>.xmp`
//! (`IMG_0001.JPG.xmp`) for writing and reading, so neither overwrites the other.
//!
//! **Read** (on import, and `photo.readMetadataFromFile`): the sidecar wins for metadata, except
//! the capture time: a time embedded in the file wins, and the sidecar's (`exif:DateTimeOriginal`,
//! else `photoshop:DateCreated`, else `xmp:CreateDate`) fills in only when the file has none; develop
//! settings are restored from `lc:settings` when present, else mapped from interoperable `crs:`
//! fields ([`crate::crs`]). For raw/DNG files without a sidecar the file's embedded XMP is used.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use lightcraft_catalog::{ColorLabel, Flag, MediaKind, Op, Photo, PhotoId, Source};
use lightcraft_develop::DevelopSettings;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{EngineError, Result, Session};
/// Sidecar file naming.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SidecarNaming {
/// `IMG_0001.xmp` (the common convention; shared by `IMG_0001.CR3` and `IMG_0001.JPG`).
#[default]
Stem,
/// `IMG_0001.CR3.xmp` (unambiguous when raw+JPEG pairs live side by side).
Full,
}
/// Library preferences for XMP sidecars.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct XmpPrefs {
/// Write the sidecar after every change to a photo's metadata or develop settings.
pub auto_write: bool,
pub naming: SidecarNaming,
}
/// The sidecar path for an original, by naming convention.
pub fn sidecar_path(original: &str, naming: SidecarNaming) -> PathBuf {
match naming {
SidecarNaming::Stem => Path::new(original).with_extension("xmp"),
SidecarNaming::Full => PathBuf::from(format!("{original}.xmp")),
}
}
/// An existing sidecar for `original`: the preferred naming first, then the other one (also
/// accepting `.XMP` and `.Xmp`, and a full name whose photo extension differs in case, such as
/// `IMG_1.cr3.xmp` beside `IMG_1.CR3`, which a case-sensitive file system would otherwise miss).
pub fn find_sidecar(original: &str, naming: SidecarNaming) -> Option<PathBuf> {
sidecar_candidates(original, naming).find(|p| p.is_file())
}
/// The names `original`'s sidecar can have, in the order they are looked for: under `naming`,
/// then under the other naming (the full name also with the photo extension in the other case),
/// each as `.xmp`, `.XMP` and `.Xmp`.
pub(crate) fn sidecar_candidates(original: &str, naming: SidecarNaming) -> impl Iterator<Item = PathBuf> {
let other = if naming == SidecarNaming::Stem { SidecarNaming::Full } else { SidecarNaming::Stem };
let ext = Path::new(original).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default();
let base = Path::new(original).with_extension("");
let recased = |e: String| (e != ext).then(|| format!("{}.{e}", base.to_string_lossy()));
let full_variants: Vec<String> = [recased(ext.to_lowercase()), recased(ext.to_uppercase())].into_iter().flatten().collect();
[naming, other]
.into_iter()
.flat_map(move |n| {
let mut v = vec![sidecar_path(original, n)];
if n == SidecarNaming::Full {
v.extend(full_variants.iter().map(|o| sidecar_path(o, n)));
}
v
})
.flat_map(|p| [p.clone(), p.with_extension("XMP"), p.with_extension("Xmp")])
}
/// What a sidecar (or embedded XMP packet) says about a photo. `None` = not stated.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct SidecarData {
pub rating: Option<u8>,
pub flag: Option<Flag>,
pub label: Option<Option<ColorLabel>>,
/// The label's text as written (`xmp:Label`), for [`SidecarData::resolve_label`].
pub label_text: Option<String>,
pub title: Option<String>,
pub caption: Option<String>,
pub copyright: Option<String>,
/// `xmpRights:Marked`: `Some(true)` copyrighted, `Some(false)` public domain.
pub copyright_marked: Option<bool>,
pub usage_terms: Option<String>,
pub copyright_url: Option<String>,
pub creator: Option<String>,
pub location: Option<String>,
pub city: Option<String>,
pub state: Option<String>,
pub country: Option<String>,
pub alt_text: Option<String>,
pub extended_description: Option<String>,
pub keywords: Option<Vec<String>>,
pub regions: Option<Vec<lightcraft_meta::Region>>,
/// Capture time (ISO 8601) from `exif:DateTimeOriginal`, `photoshop:DateCreated` or
/// `xmp:CreateDate` (first found). Used only when the file itself has no capture time.
pub captured: Option<String>,
pub develop: Option<DevelopPatch>,
/// Actionable diagnostics for foreign develop settings that were not fully carried over.
/// These are surfaced in the photo import report and never persisted in the catalog.
pub warnings: Vec<crate::import::ImportWarning>,
}
#[derive(Clone, Debug, PartialEq)]
pub enum DevelopPatch {
/// Our own complete settings (`lc:settings`).
Full(Box<DevelopSettings>),
/// Mapped `crs:` fields (partial JSON, merged like a preset).
Partial(Value),
}
/// Parse a sidecar / XMP packet. `target` selects how a `crs:` white balance is read
/// ([`crate::crs::Target`]: absolute Kelvin for raws with a measured illuminant, a shift from the
/// as-shot white for everything developed relative to it).
pub fn parse_sidecar(xmp: &str, target: crate::crs::Target) -> std::result::Result<SidecarData, String> {
let d = lightcraft_meta::parse_xmp(xmp).map_err(|e| e.to_string())?;
let m = &d.metadata;
let lc = |k: &str| d.properties.get(&format!("lc:{k}")).and_then(|v| v.first()).cloned();
let mut out = SidecarData {
title: m.title.clone(),
caption: m.caption.clone(),
copyright: m.copyright.clone(),
copyright_marked: m.copyright_marked,
usage_terms: m.usage_terms.clone(),
copyright_url: m.copyright_url.clone(),
creator: m.artist.clone(),
location: lc("location").or_else(|| m.sublocation.clone()),
city: m.city.clone(),
state: m.state.clone(),
country: m.country.clone(),
alt_text: m.alt_text.clone(),
extended_description: m.extended_description.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
// `mwg-rs:Regions` byte for byte but never writes one) says nothing about regions, and the
// photo's are kept.
regions: d.values.contains_key("mwg-rs:Regions").then(|| m.regions.clone()),
captured: m.capture_time.map(|d| d.to_iso()),
..Default::default()
};
if let Some(r) = m.rating {
if r < 0 {
out.flag = Some(Flag::Reject);
out.rating = Some(0);
} else {
out.rating = Some(r.clamp(0, 5) as u8);
}
}
if let Some(f) = lc("flag").and_then(|f| Flag::parse(&f)) {
out.flag = Some(f);
}
if let Some(l) = &m.label {
out.label = Some(ColorLabel::parse(l.trim()));
out.label_text = Some(l.trim().to_string());
}
let full = d.lc_settings.as_deref().and_then(|j| serde_json::from_str::<Value>(j).ok()).and_then(|v| DevelopSettings::from_json(&v).ok());
out.develop = match full {
Some(s) => Some(DevelopPatch::Full(Box::new(s))),
None if crate::crs::has_adjustments(&d.properties) => {
let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), target, crate::crs_masks::DEFAULT_ASPECT);
// Lightroom's default profiles are named by nearly every sidecar; our own starting look stands
// in for them, so only another profile is worth a warning
let profile = crate::crs::camera_profile_name(&d.properties)
.filter(|name| !matches!(name.trim(), "Adobe Standard" | "Adobe Color"))
.map(str::to_string);
if let Some(name) = profile {
out.warnings.push(crate::import::ImportWarning {
path: String::new(),
code: crate::import::ImportWarningCode::CameraProfile,
profile: Some(name),
mode: None,
fields: Vec::new(),
});
}
let upright = crate::crs::has_saved_upright_transform(&d.properties);
if upright {
let mode = d
.properties
.get("crs:PerspectiveUpright")
.and_then(|values| values.first())
.map(String::as_str)
.map(|mode| match mode {
"0" => crate::import::ImportUprightMode::Off,
"1" => crate::import::ImportUprightMode::Auto,
"2" => crate::import::ImportUprightMode::Level,
"3" => crate::import::ImportUprightMode::Vertical,
"4" => crate::import::ImportUprightMode::Full,
"5" => crate::import::ImportUprightMode::Guided,
_ => crate::import::ImportUprightMode::Unknown,
})
.unwrap_or(crate::import::ImportUprightMode::Unknown);
out.warnings.push(crate::import::ImportWarning {
path: String::new(),
code: crate::import::ImportWarningCode::UprightGeometry,
profile: None,
mode: Some(mode),
fields: Vec::new(),
});
}
let other: Vec<&str> = unmapped
.iter()
.map(String::as_str)
.filter(|name| {
*name != "CameraProfile"
&& !name.starts_with("UprightTransform_")
&& !matches!(*name, "UprightTransformCount" | "UprightCenterNormX" | "UprightCenterNormY" | "UprightVersion")
})
.collect();
if !other.is_empty() {
out.warnings.push(crate::import::ImportWarning {
path: String::new(),
code: crate::import::ImportWarningCode::UnmappedXmp,
profile: None,
mode: None,
fields: other.into_iter().map(str::to_string).collect(),
});
}
Some(DevelopPatch::Partial(partial))
}
None => None,
};
Ok(out)
}
/// Apply sidecar data to a photo record (the sidecar wins, except that a capture time embedded in
/// the file is kept). Returns true if develop changed.
pub fn merge_into(p: &mut Photo, sc: &SidecarData, now: &str) -> bool {
if p.captured.is_none() {
p.captured = sc.captured.clone();
}
if let Some(r) = sc.rating {
p.rating = r;
}
if let Some(f) = sc.flag {
p.flag = f;
}
if let Some(l) = sc.label {
p.label = l;
}
let m = &mut p.meta;
for (src, dst) in [
(&sc.title, &mut m.title),
(&sc.caption, &mut m.caption),
(&sc.copyright, &mut m.copyright),
(&sc.usage_terms, &mut m.usage_terms),
(&sc.copyright_url, &mut m.copyright_url),
(&sc.creator, &mut m.creator),
(&sc.location, &mut m.location),
(&sc.city, &mut m.city),
(&sc.state, &mut m.state),
(&sc.country, &mut m.country),
(&sc.alt_text, &mut m.alt_text),
(&sc.extended_description, &mut m.extended_description),
] {
if let Some(v) = src {
*dst = v.clone();
}
}
if let Some(marked) = sc.copyright_marked {
m.copyright_status = lightcraft_catalog::CopyrightStatus::from_marked(Some(marked));
}
if let Some(k) = &sc.keywords {
m.keywords = k.clone();
}
if let Some(r) = &sc.regions {
m.regions = r.clone();
}
let develop = match &sc.develop {
Some(DevelopPatch::Full(s)) => (**s).clone(),
Some(DevelopPatch::Partial(v)) => {
let mut v = v.clone();
if p.width > 0 && p.height > 0 {
crate::crs_masks::refit_radials(&mut v, crate::crs_masks::DEFAULT_ASPECT, p.width as f64 / p.height as f64);
}
lightcraft_develop::apply_partial(&p.develop, &v, 1.0)
}
None => return false,
};
if develop == *p.develop {
return false;
}
p.develop = Arc::new(develop);
p.edited = Some(now.to_string());
true
}
impl SidecarData {
/// Read the label through the catalog's label names: other editors write the label's name
/// (a custom set's "To Do" as well as "Red"); unknown names clear the label.
pub fn resolve_label(mut self, cat: &lightcraft_catalog::Catalog) -> Self {
if let Some(t) = &self.label_text {
self.label = Some(if t.is_empty() { None } else { cat.label_from_name(t) });
}
self
}
}
/// The sidecar packet for a photo; the colour label is written by its name in `cat` (custom
/// label names included, as other editors do).
pub fn sidecar_packet(p: &Photo, cat: &lightcraft_catalog::Catalog) -> String {
sidecar_packet_with(p, cat, false)
}
/// [`sidecar_packet`], without any location (GPS, sublocation, city, state, country) when
/// `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());
let meta = lightcraft_meta::Metadata {
software: Some("LightCraft".into()),
title: nz(&p.meta.title),
caption: nz(&p.meta.caption),
alt_text: nz(&p.meta.alt_text),
extended_description: nz(&p.meta.extended_description),
sublocation: nz(&p.meta.location),
city: nz(&p.meta.city),
state: nz(&p.meta.state),
country: nz(&p.meta.country),
copyright: nz(&p.meta.copyright),
copyright_marked: p.meta.copyright_status.marked(),
usage_terms: nz(&p.meta.usage_terms),
copyright_url: nz(&p.meta.copyright_url),
artist: nz(&p.meta.creator),
keywords: p.meta.keywords.clone(),
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),
gps: p.meta.gps.map(|(latitude, longitude)| lightcraft_meta::Gps { latitude, longitude, altitude: None }),
..Default::default()
};
let mut meta = meta;
if remove_location {
(meta.sublocation, meta.city, meta.state, meta.country, meta.gps) = (None, None, None, None, None);
}
let settings = serde_json::to_string(&*p.develop).unwrap_or_default();
let flag = match p.flag {
Flag::None => "none",
Flag::Pick => "pick",
Flag::Reject => "reject",
};
let mut lc = vec![("settings", settings.as_str()), ("flag", flag)];
if !remove_location && !p.meta.location.trim().is_empty() {
lc.push(("location", p.meta.location.as_str()));
}
lightcraft_meta::write_xmp_lc(&meta, &lc)
}
/// The properties LightCraft writes and owns in a sidecar: replaced on every save (removed when
/// LightCraft has no value, so clearing a field clears it in the file too). All of them are read
/// back on import and by Read Metadata from File.
pub const OWNED: &[&str] = &[
"xmp:Rating",
"xmp:Label",
"dc:title",
"dc:description",
"dc:rights",
"dc:creator",
"dc:subject",
"Iptc4xmpCore:Location",
"Iptc4xmpCore:AltTextAccessibility",
"Iptc4xmpCore:ExtDescrAccessibility",
"photoshop:City",
"photoshop:State",
"photoshop:Country",
"xmpRights:Marked",
"xmpRights:UsageTerms",
"xmpRights:WebStatement",
"lc:*",
];
/// Properties replaced only when LightCraft has a value (capture time; GPS, which LightCraft
/// doesn't read from sidecars): otherwise the sidecar's own value stays. Anything else LightCraft
/// writes (`xmp:CreatorTool`) goes only into new sidecars.
pub const OWNED_IF_STATED: &[&str] = &["exif:DateTimeOriginal", "photoshop:DateCreated", "exif:GPSLatitude", "exif:GPSLongitude"];
/// Write `data` to `path` atomically: a temp file next to it, synced, then renamed over it.
fn write_atomic(path: &Path, data: &[u8]) -> std::io::Result<()> {
lightcraft_catalog::safe_file::write_atomic(path, data)
}
/// Copy `path` to a new `<name>.bak-<stamp>` (`-1`, `-2`… if taken; never overwriting).
fn backup(path: &Path, stamp: &str) -> std::io::Result<PathBuf> {
use std::io::Write;
let bytes = std::fs::read(path)?;
let stamp: String = stamp.chars().filter(char::is_ascii_alphanumeric).collect();
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
for k in 0..1000 {
let b = path.with_file_name(if k == 0 { format!("{name}.bak-{stamp}") } else { format!("{name}.bak-{stamp}-{k}") });
match std::fs::OpenOptions::new().write(true).create_new(true).open(&b) {
Ok(mut f) => {
f.write_all(&bytes)?;
f.sync_all()?;
return Ok(b);
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e),
}
}
Err(std::io::Error::other("no free backup name"))
}
/// What [`Session::save_sidecar`] did.
#[derive(Clone, Debug, PartialEq)]
pub struct SidecarSaved {
pub path: PathBuf,
/// An existing sidecar was merged into (its other contents kept).
pub merged: bool,
/// An existing sidecar that couldn't be read as XMP was copied here before being replaced.
pub backup: Option<PathBuf>,
}
/// The new contents for the sidecar at `path`: `fresh` merged into the existing file, if any.
/// Returns (contents, merged, backup).
fn sidecar_contents(path: &Path, fresh: String, stamp: &str) -> std::io::Result<(String, bool, Option<PathBuf>)> {
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok((fresh, false, None)),
// can't see what is there: never overwrite it blindly
Err(e) => return Err(e),
};
if bytes.iter().all(u8::is_ascii_whitespace) {
return Ok((fresh, false, None));
}
let rules = lightcraft_meta::MergeRules { owned: OWNED, owned_if_present: OWNED_IF_STATED };
match std::str::from_utf8(&bytes)
.map_err(|e| e.to_string())
.and_then(|old| lightcraft_meta::merge_xmp(old, &fresh, rules).map_err(|e| e.to_string()))
{
Ok(merged) => Ok((merged, true, None)),
Err(why) => {
let b = backup(path, stamp)?;
log::warn!("{}: not readable as XMP ({why}); kept a copy as {} before replacing it", path.display(), b.display());
Ok((fresh, false, Some(b)))
}
}
}
/// Key of the stem sidecar a file maps to (case-insensitive).
fn stem_key(original: &str) -> String {
sidecar_path(original, SidecarNaming::Stem).to_string_lossy().to_lowercase()
}
/// Where a plain path is cut to make its [`stem_key`]: at the dot of its extension, which is
/// what `with_extension` replaces. A plain path is ASCII and ends in its file's name with an
/// extension, which is what a library's paths are; `None` for any other shape.
fn plain_cut(original: &str) -> Option<usize> {
let path = Path::new(original);
let (name, stem, ext) = (path.file_name()?.to_str()?, path.file_stem()?.to_str()?, path.extension()?.to_str()?);
// (the path ends in its file's name, as it stands: no `/.` or separator after it; and the
// name has a stem and an extension of its own, which `..jpg` and `a.` have not)
let plain = original.is_ascii() && original.ends_with(name) && !ext.is_empty() && !stem.is_empty() && stem != "." && stem != "..";
let cut = original.len().checked_sub(ext.len().saturating_add(1))?;
(plain && original.as_bytes().get(cut) == Some(&b'.')).then_some(cut)
}
/// [`stem_key`] written into `key`, which is reused: nothing is allocated for a plain path
/// ([`plain_cut`]). Any other shape goes the long way.
fn stem_key_into(key: &mut String, original: &str) {
key.clear();
match plain_cut(original) {
Some(cut) => {
key.push_str(original.get(..cut).unwrap_or_default());
key.make_ascii_lowercase();
key.push_str(".xmp");
}
None => key.push_str(&stem_key(original)),
}
}
fn file_path(p: &Photo) -> Option<&str> {
match &p.source {
Source::File { path } => Some(path),
Source::Demo { .. } => None,
}
}
/// The XMP packet that describes a photo on disk: its sidecar, else (raw/DNG) the embedded XMP.
pub fn read_packet(original: &str, kind: MediaKind, naming: SidecarNaming) -> Option<(String, PathBuf)> {
if let Some(sc) = find_sidecar(original, naming)
&& let Ok(s) = std::fs::read_to_string(&sc)
{
return Some((s, sc));
}
if kind == MediaKind::Raw {
let bytes = std::fs::read(original).ok()?;
let x = lightcraft_meta::embedded(&bytes).xmp?;
return Some((x, PathBuf::from(original)));
}
None
}
/// Photo ids touched by an op (for auto-write).
pub(crate) fn op_photos(op: &Op, out: &mut Vec<PhotoId>) {
match op {
Op::SetRating { id, .. } | Op::SetFlag { id, .. } | Op::SetLabel { id, .. } | Op::SetDevelop { id, .. } | Op::SetMeta { id, .. }
if !out.contains(id) =>
{
out.push(*id);
}
Op::Batch { ops } => ops.iter().for_each(|o| op_photos(o, out)),
_ => {}
}
}
/// Which photo owns each stem sidecar that several catalogued files map to (see
/// [`Session::sidecar_naming`]).
pub(crate) struct StemOwners(HashMap<String, PhotoId>);
impl StemOwners {
/// The owners of every shared stem in the library, each file's key made the long way: what
/// [`StemOwners::among`] must agree with for the photos it is asked about (this is how the
/// owners were worked out before, for every write).
#[cfg(test)]
pub(crate) fn of(cat: &lightcraft_catalog::Catalog) -> StemOwners {
let mut best: HashMap<String, ((bool, String, u64), PhotoId, usize)> = HashMap::new();
for p in cat.photos().filter(|p| p.copy_of.is_none()) {
let Some(path) = file_path(p) else { continue };
let rank = (p.kind != MediaKind::Raw, path.to_lowercase(), p.id.0);
let e = best.entry(stem_key(path)).or_insert_with(|| (rank.clone(), p.id, 0));
e.2 += 1;
if rank < e.0 {
(e.0, e.1) = (rank, p.id);
}
}
StemOwners(best.into_iter().filter(|(_, v)| v.2 > 1).map(|(k, v)| (k, v.1)).collect())
}
/// The owners of the stems photos `ids` map to: all that naming those photos needs. The
/// library is passed over once, and only files of those stems are looked at closer, so a
/// sidecar written for one photo does not cost a table of the whole library.
pub(crate) fn among(cat: &lightcraft_catalog::Catalog, ids: &[PhotoId]) -> StemOwners {
let wanted: std::collections::HashSet<String> =
ids.iter().filter_map(|id| cat.photo(*id)).filter_map(|p| file_path(p)).map(stem_key).collect();
if wanted.is_empty() {
return StemOwners(HashMap::new());
}
Self::where_wanted(cat, &wanted)
}
/// The owners of the shared stems in `wanted`.
fn where_wanted(cat: &lightcraft_catalog::Catalog, wanted: &std::collections::HashSet<String>) -> StemOwners {
// per stem: (raw first, then file name, then id) of the best candidate; and how many
let mut best: HashMap<String, ((bool, String, u64), PhotoId, usize)> = HashMap::new();
let mut key = String::new();
for p in cat.photos().filter(|p| p.copy_of.is_none()) {
let Some(path) = file_path(p) else { continue };
stem_key_into(&mut key, path);
if !wanted.contains(key.as_str()) {
continue;
}
let rank = (p.kind != MediaKind::Raw, path.to_lowercase(), p.id.0);
let e = best.entry(key.clone()).or_insert_with(|| (rank.clone(), p.id, 0));
e.2 += 1;
if rank < e.0 {
(e.0, e.1) = (rank, p.id);
}
}
StemOwners(best.into_iter().filter(|(_, v)| v.2 > 1).map(|(k, v)| (k, v.1)).collect())
}
/// `naming` for photo `p`, except that a stem sidecar another file owns becomes Full.
fn naming(&self, p: &Photo, naming: SidecarNaming) -> SidecarNaming {
match (naming, file_path(p)) {
(SidecarNaming::Stem, Some(path)) if self.0.get(&stem_key(path)).is_some_and(|owner| *owner != p.id) => SidecarNaming::Full,
_ => naming,
}
}
}
/// How the sidecar of each of photos `ids` is named in library `cat`, whose naming is `naming`
/// ([`Session::sidecar_namings`], for a thread that has the library and not the session).
pub(crate) fn namings_in(cat: &lightcraft_catalog::Catalog, naming: SidecarNaming, ids: &[PhotoId]) -> Vec<SidecarNaming> {
let owners = (naming == SidecarNaming::Stem).then(|| StemOwners::among(cat, ids));
ids.iter()
.map(|id| match (cat.photo(*id), &owners) {
(Some(p), Some(o)) => o.naming(p, naming),
_ => naming,
})
.collect()
}
impl Session {
/// The sidecar naming for photo `id`: the library's, except under Stem naming when another
/// catalogued file shares the stem (`IMG_0001.CR3` + `IMG_0001.JPG`) and owns the stem
/// sidecar — a raw first, else the first by file name. The others use Full naming
/// (`IMG_0001.JPG.xmp`), so their metadata never overwrites each other.
/// [`Session::sidecar_naming`] for many photos, their stems' owners worked out once (per
/// photo that is a pass over the whole catalog).
pub fn sidecar_namings(&self, ids: &[PhotoId]) -> Vec<SidecarNaming> {
namings_in(&self.catalog, self.xmp.naming, ids)
}
pub fn sidecar_naming(&self, id: PhotoId) -> SidecarNaming {
match self.catalog.photo(id) {
Some(p) if self.xmp.naming == SidecarNaming::Stem => StemOwners::among(&self.catalog, &[id]).naming(p, self.xmp.naming),
_ => self.xmp.naming,
}
}
/// Write the photo's XMP sidecar, merged into an existing one (see the module docs).
pub fn save_sidecar(&mut self, id: PhotoId) -> Result<SidecarSaved> {
self.save_sidecar_with(id, &StemOwners::among(&self.catalog, &[id]))
}
pub(crate) fn save_sidecar_with(&mut self, id: PhotoId, owners: &StemOwners) -> Result<SidecarSaved> {
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
if p.copy_of.is_some() {
return Err(EngineError::Other(format!("{} is a virtual copy: its settings live only in the library", p.file_name)));
}
let orig = file_path(p).ok_or_else(|| EngineError::Other(format!("{} is not a file on disk", p.file_name)))?;
let naming = owners.naming(p, self.xmp.naming);
let preferred = sidecar_path(orig, naming);
let other = if naming == SidecarNaming::Stem { SidecarNaming::Full } else { SidecarNaming::Stem };
let candidates: Vec<PathBuf> = [naming, other]
.into_iter()
.filter(|n| *n != SidecarNaming::Stem || owners.naming(p, SidecarNaming::Stem) == SidecarNaming::Stem)
.flat_map(|n| {
let path = sidecar_path(orig, n);
[path.clone(), path.with_extension("XMP")]
})
.collect();
// A separated packet is still the photo's metadata. Repair it before a fresh write
// occupies its destination; a failure preserves the queue and both existing packets.
self.repair_sidecars_for_paths(&candidates, &crate::rename::RealFs)
.map_err(|e| EngineError::Other(format!("{}: unfinished XMP repair: {e}", preferred.display())))?;
// Keep an existing packet's spelling/style so foreign properties survive this save and
// subsequent Undo/Redo writes. Shared stems owned by another photo were excluded above.
// On a case-insensitive volume `a.xmp` also answers for `a.XMP`: the spelling the folder
// lists comes first, so the atomic write doesn't rename the packet.
use crate::rename::MoveFs;
let existing: Vec<PathBuf> = candidates.into_iter().filter(|path| path.is_file()).collect();
let listed = existing.iter().find(|path| crate::rename::RealFs.listed(path)).cloned();
let path = listed.or_else(|| existing.into_iter().next()).unwrap_or(preferred);
self.validate_sidecar_packets_for_paths(std::slice::from_ref(&path), &crate::rename::RealFs)?;
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
let err = |e: std::io::Error| EngineError::Other(format!("{}: {e}", path.display()));
let (data, merged, backup) = sidecar_contents(&path, sidecar_packet(p, &self.catalog), &(self.clock)()).map_err(err)?;
write_atomic(&path, data.as_bytes()).map_err(err)?;
self.note_sidecar_write(&path, data.as_bytes());
Ok(SidecarSaved { path, merged, backup })
}
/// The op that applies a photo's sidecar (or embedded XMP) to the catalog, if there is one.
pub fn read_sidecar_op(&self, id: PhotoId) -> Result<Option<(Op, PathBuf)>> {
self.read_sidecar_op_with_warnings(id).map(|result| result.map(|(op, from, _)| (op, from)))
}
/// Like [`Session::read_sidecar_op`], retaining transient diagnostics for the command report.
pub fn read_sidecar_op_with_warnings(&self, id: PhotoId) -> Result<Option<(Op, PathBuf, Vec<crate::import::ImportWarning>)>> {
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
let Some(orig) = file_path(p) else { return Ok(None) };
let Some((packet, from)) = read_packet(orig, p.kind, self.sidecar_naming(id)) else { return Ok(None) };
let sc = parse_sidecar(&packet, crate::crs::Target::for_photo(p)).map_err(|e| EngineError::Other(format!("{}: {e}", from.display())))?;
let mut warnings = sc.warnings.clone();
for warning in &mut warnings {
warning.path = orig.to_string();
}
let op = self.sidecar_op(id, sc)?;
Ok(Some((op, from, warnings)))
}
/// The op that applies what a sidecar says (already read and parsed, e.g. on a worker
/// thread) to photo `id`: the sidecar wins. No file is read.
pub fn sidecar_op(&self, id: PhotoId, sc: SidecarData) -> Result<Op> {
let p = self.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?;
let sc = sc.resolve_label(&self.catalog);
let mut q = (**p).clone();
let develop_changed = merge_into(&mut q, &sc, &(self.clock)());
let mut ops = vec![
Op::SetRating { id, rating: q.rating },
Op::SetFlag { id, flag: q.flag },
Op::SetLabel { id, label: q.label },
Op::SetMeta { id, meta: Box::new(q.meta.clone()) },
];
if q.captured != p.captured {
ops.push(Op::SetCaptured { id, captured: q.captured.clone() });
}
if develop_changed {
ops.extend(self.develop_op(id, (*q.develop).clone(), "Read Metadata from File"));
}
Ok(Op::Batch { ops })
}
/// Auto-write: sidecars for photos changed by `ops` (errors are logged, not returned).
pub(crate) fn auto_write_sidecars(&mut self, ops: &[Op]) {
let mut ids = Vec::new();
ops.iter().for_each(|o| op_photos(o, &mut ids));
self.auto_write_sidecar_ids(ids);
}
/// Gather changed IDs before the saves mutate retained physical repair queues.
pub(crate) fn auto_write_sidecars_since(&mut self, log_start: usize) {
let mut ids = Vec::new();
if let Some(ops) = self.pending_log.get(log_start..) {
ops.iter().for_each(|o| op_photos(o, &mut ids));
}
self.auto_write_sidecar_ids(ids);
}
fn auto_write_sidecar_ids(&mut self, ids: Vec<PhotoId>) {
if ids.is_empty() {
return;
}
let owners = StemOwners::among(&self.catalog, &ids);
for id in ids {
if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some() && p.copy_of.is_none()) {
match self.save_sidecar_with(id, &owners) {
Ok(_) => {
self.unsaved_sidecars.remove(&id);
}
Err(e) => {
// kept for a retry once the file is reachable: a network share that dropped out mustn't leave
// the sidecar silently behind the edit
log::warn!("auto-write XMP: {e}");
self.unsaved_sidecars.insert(id);
}
}
}
}
}
/// How many photos have an automatic sidecar write waiting (it failed: the folder offline, read-only or full).
pub fn unsaved_sidecars(&self) -> usize {
self.unsaved_sidecars.len()
}
/// Write the waiting sidecars whose originals are reachable again (the availability cache answers without
/// touching the disk for the others); photos that are gone, or with auto-write off, are dropped from the wait.
/// Returns how many were written.
pub fn retry_unsaved_sidecars(&mut self) -> usize {
if !self.xmp.auto_write {
self.unsaved_sidecars.clear();
return 0;
}
let mut ready = Vec::new();
for id in self.unsaved_sidecars.clone() {
match self.catalog.photo(id).filter(|p| p.copy_of.is_none()).and_then(|p| file_path(p).map(str::to_owned)) {
None => {
self.unsaved_sidecars.remove(&id);
}
Some(path) if self.media.availability.exists(&path) == Some(true) => ready.push(id),
Some(_) => {}
}
}
if ready.is_empty() {
return 0;
}
let owners = StemOwners::among(&self.catalog, &ready);
let mut written = 0;
for id in ready {
match self.save_sidecar_with(id, &owners) {
Ok(_) => {
self.unsaved_sidecars.remove(&id);
written += 1;
}
Err(e) => log::debug!("retry XMP: {e}"),
}
}
written
}
}
#[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), crate::crs::Target::Rendered).unwrap();
assert_eq!(sc.keywords, Some(vec!["Places|Lisbon".to_string()]));
}
use super::*;
use lightcraft_catalog::Meta;
fn photo() -> Photo {
let mut p = Photo::new(PhotoId(7), Source::File { path: "/x/IMG_1.jpg".into() }, "IMG_1.jpg", "JPEG", 40, 30, "2026-01-01T00:00:00");
p.rating = 4;
p.flag = Flag::Pick;
p.label = Some(ColorLabel::Purple);
p.captured = Some("2025-06-07T08:09:10".into());
p.meta = Meta {
title: "Dune & sky".into(),
caption: "Line <one>\nline two".into(),
copyright: "© 2026 Me".into(),
creator: "Ann Lee".into(),
location: "Namib".into(),
keywords: vec!["desert".into(), "red".into()],
gps: Some((-24.75, 15.3)),
..Default::default()
};
let mut d = DevelopSettings::default();
d.light.exposure = 0.75;
d.effects.clarity = 22.0;
d.crop.geometry.angle = 2.5;
d.masks.push(lightcraft_develop::Mask { id: 1, ..Default::default() });
p.develop = Arc::new(d);
p
}
#[test]
fn naming() {
assert_eq!(sidecar_path("/a/b/IMG_1.CR3", SidecarNaming::Stem), PathBuf::from("/a/b/IMG_1.xmp"));
assert_eq!(sidecar_path("/a/b/IMG_1.CR3", SidecarNaming::Full), PathBuf::from("/a/b/IMG_1.CR3.xmp"));
}
#[test]
fn packet_roundtrip_restores_everything() {
let p = photo();
let x = sidecar_packet(&p, &lightcraft_catalog::Catalog::new());
let sc = parse_sidecar(&x, crate::crs::Target::Rendered).unwrap();
assert_eq!(sc.rating, Some(4));
assert_eq!(sc.flag, Some(Flag::Pick));
assert_eq!(sc.label, Some(Some(ColorLabel::Purple)));
let mut q = Photo::new(PhotoId(7), p.source.clone(), "IMG_1.jpg", "JPEG", 40, 30, "2026-01-01T00:00:00");
q.captured = p.captured.clone();
q.meta.gps = p.meta.gps;
assert!(merge_into(&mut q, &sc, "now"));
assert_eq!(q.develop, p.develop);
assert_eq!(q.meta, p.meta);
assert_eq!((q.rating, q.flag, q.label), (p.rating, p.flag, p.label));
assert_eq!(q.edited.as_deref(), Some("now"));
}
#[test]
fn foreign_sidecar_rating_reject_label_and_crs() {
let x = r#"<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:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
xmp:Rating="-1" xmp:Label="Green" crs:Exposure2012="-0.40" crs:Vibrance="+12"/></rdf:RDF></x:xmpmeta>"#;
let sc = parse_sidecar(x, crate::crs::Target::RawAbsolute).unwrap();
assert_eq!((sc.rating, sc.flag, sc.label), (Some(0), Some(Flag::Reject), Some(Some(ColorLabel::Green))));
let mut p = photo();
let before = p.develop.clone();
assert!(merge_into(&mut p, &sc, "t"));
assert_eq!(p.develop.light.exposure, -0.4);
assert_eq!(p.develop.color.vibrance, 12.0);
assert_eq!(p.develop.effects.clarity, before.effects.clarity, "crs partial keeps unrelated settings");
assert_eq!(p.meta.title, "Dune & sky", "fields the sidecar doesn't state are kept");
}
#[test]
fn op_photo_collection() {
let mut v = Vec::new();
op_photos(
&Op::Batch {
ops: vec![
Op::SetRating { id: PhotoId(1), rating: 2 },
Op::SetFlag { id: PhotoId(1), flag: Flag::Pick },
Op::SetLabel { id: PhotoId(3), label: None },
],
},
&mut v,
);
assert_eq!(v, vec![PhotoId(1), PhotoId(3)]);
}
}
#[cfg(test)]
#[path = "sidecar/tests_repair_saves.rs"]
mod tests_repair_saves;
#[cfg(test)]
#[path = "sidecar/tests_repair_companions.rs"]
mod tests_repair_companions;
#[cfg(test)]
#[path = "sidecar/tests_repair_provenance.rs"]
mod tests_repair_provenance;
#[cfg(test)]
#[path = "sidecar/tests_stem_owners.rs"]
mod tests_stem_owners;