//! TRACES: FR-CAT-13 //! Standard XMP sidecars — the file everybody else reads. //! //! A photographer arrives with a library. Some of it was keyworded in //! Lightroom, some culled in Bridge, some starred in darktable, and all of that //! work is sitting in `.xmp` files beside the raws. FR-CAT-14 will import the //! catalog; this is the other half of that promise, and the more important one: //! a migration that carries a library across once and cannot then hand a //! keyword or a rating back out is a one-way door, and nobody walks their //! archive through a one-way door. //! //! # This is not the `.drsc` sidecar, and the two do not compete //! //! `dr_pipeline::sidecar` is DarkRoom's own store: the edit graph, one block //! per version, carrying the `revision` and `device` that FR-NC-9's per-node //! merge turns on. It is authoritative (ARCH §6.12), and no other application //! reads it or ever will. //! //! This is the opposite file. It is flat, it holds no edit, and its entire //! purpose is that somebody else reads it. The consequence that matters is //! structural rather than aesthetic: **XMP addresses the photograph, not a //! version of it.** There is nowhere in the format to say which virtual copy a //! rating belongs to, which settles the mapping without anyone having to //! choose — a value read from here lands on, and is written from, the //! **default version**. That is the same answer `dr_catalog::keywords::assign` //! and `dr_catalog::rating` already give for a write from the UI, and for the //! same reason: there must be exactly one place such a write goes, or two //! copies of one frame come to disagree about their own subject. //! //! Neither file is derived from the other and neither is a fallback for the //! other. What connects them is the catalog, which is where both are applied. //! //! # What DarkRoom owns, and what it must not touch //! //! An XMP sidecar is a shared document. The file beside a raw may have been //! written by Lightroom, by darktable, by Bridge or by a phone, and it will //! carry their private namespaces — `crs:` develop settings, `darktable:` //! history, `Iptc4xmpExt:` structures nothing here understands. That is //! somebody's work, and this crate is a guest in their file. //! //! **The rule, written once so there is one place to read it: DarkRoom owns //! exactly the properties in [`PROPERTIES`], and nothing else in the //! document.** On write, every owned property is removed wherever it appears — //! as an element, as an attribute on any `rdf:Description`, under whatever //! prefix — and re-emitted from what the [`Xmp`] value holds. Every other //! element, attribute, namespace declaration, comment and processing //! instruction is copied through byte for byte, including the ones this build //! has never heard of and the ones that had not been invented when it shipped. //! //! Three consequences are worth stating plainly, because each of them is the //! point rather than a detail of the implementation: //! //! - **Ownership is decided by namespace URI, never by prefix.** A prefix is a //! local nickname: `dc` is bound to Dublin Core by convention and by nothing //! stronger, and a document is free to call it something else or to bind //! `dc` to something else entirely. Matching the literal string `dc:subject` //! would miss the property it meant and, worse, would delete one it did not. //! - **A property this value holds nothing for is deleted, not left behind.** //! The owned set is replaced wholesale, so the document ends up saying //! exactly what the [`Xmp`] says — no field is half-updated and there is no //! state in which the file and the record disagree about something DarkRoom //! claims to own. That only makes sense if the value came from the same //! document, which is why the supported gesture is read, change, //! [`Xmp::rewrite`]. Constructing an [`Xmp`] and writing it over a file //! nobody read is how a title gets deleted by a build that had no opinion //! about titles. //! - **Nothing here writes a private DarkRoom namespace.** FR-CAT-13 permits //! one and this deliberately declines it: the edit graph has a home already, //! and a second copy of it inside a file that other tools rewrite would be a //! second thing to keep in step and the first thing to go stale. //! //! # When the sidecar and the catalog disagree //! //! FR-NC-9 merges the `.drsc` sidecar per node, ordering two versions of one //! edit by `revision` and breaking ties by `modified`. A standard XMP has //! neither. There is no revision, no device, and at best an optional //! `xmp:MetadataDate` written from whatever clock the other machine had. //! **There is nothing here to order two edits by**, so the merge FR-NC-9 //! performs cannot be performed against this file, and pretending otherwise //! would let a stale sidecar written by a program nobody has opened in a year //! silently overwrite an afternoon's cull. //! //! So the default, [`Precedence::Catalog`], is the conservative one, and both //! of its halves are shaped after rules already in the tree rather than //! invented here: //! //! - **Keywords and hierarchical subjects union.** Precisely what //! `dr_catalog::merge` does with keyword assignments, and for the reason it //! gives in as many words: disjoint work on two sides both survives, and the //! price is that a *removal* does not propagate. An unwanted keyword is //! taken off again in a second; an afternoon of keywording is not //! recoverable at all. //! - **Every other field is taken only where DarkRoom holds nothing.** The //! shape of `Version::merge`'s judgement rule: a rating can be *added* //! across two stores and never *erased* by one that never had it. Where both //! sides hold a value and the two differ, the catalog's stands and the field //! is reported in [`Reconciled::conflicts`] rather than resolved silently — //! which is what lets a caller offer the metadata reload FR-CAT-13 asks for, //! instead of performing it behind the photographer's back. //! //! [`Precedence::Sidecar`] is the other half of that offer: the same union for //! keywords, and the file winning every contested field. It exists for one //! caller — a person who has asked, in as many words, to reload this //! photograph's metadata from its sidecar. //! //! ## The open question, recorded rather than guessed at //! //! What is *not* settled is when [`Precedence::Sidecar`] may be applied //! without asking. FR-CAT-13 wants external modification detected and a reload //! *offered*, which reads as "never automatically" — but the same requirement //! also expects a library starred in Lightroom and then opened here to show //! Lightroom's stars, and a prompt per photograph across fifty thousand frames //! is not an offer, it is a wall. //! //! The answer probably turns on whether DarkRoom has ever written that //! photograph's metadata itself: a frame this application has never judged has //! no local judgement to defend, and taking the file's word for it costs //! nothing. That is a fact about the *catalog*, which this crate cannot see and //! should not learn — so the decision belongs to the caller that has both, and //! is deliberately left open here. Until it is settled, every automatic path //! passes [`Precedence::Catalog`] and only a person's request passes the other. //! //! # No IO, and no path //! //! Text in, text out. That is the same shape `dr_pipeline::sidecar` has — //! it parses and serialises and never opens a file — and it is not tidiness: //! Android's SAF hands out no filesystem path at all (ARCH §6.9), so a `core/` //! crate that took one would work on exactly one of the two platforms. The //! caller already holds the bytes and already knows how to store them. //! //! Which also means **nothing here decides to write anything**. NFR-R4 makes //! writing beside a source file an explicit user action; this crate produces //! bytes when asked, and the switch that asks lives with the caller. //! //! # What is deliberately not here //! //! **GPS.** FR-CAT-13 names it and this does not carry it. `exif:GPSLatitude` //! is a format of its own — `"39,7.20N"`, degrees and decimal minutes with a //! hemisphere letter — and nothing above this crate reads a location out of a //! source file yet either (`dr_export::metadata` says the same about its own //! half). Carrying coordinates through a round trip that no reader and no //! writer can currently originate would be a field that exists only to be //! preserved, and NFR-SEC's care about location data makes a half-wired //! coordinate path the wrong first thing to build. It arrives as two more rows //! in [`PROPERTIES`] on the day the decoder produces one. //! //! **The filename.** Lightroom writes `IMG_0001.xmp` beside `IMG_0001.CR3`; //! darktable writes `IMG_0001.CR3.xmp`. Both conventions are in the wild and a //! reader must accept either, which is a decision about *finding* files and //! therefore the caller's — this crate offers [`EXTENSION`] and no opinion. use std::collections::BTreeMap; use std::fmt; use dr_types::ColourLabel; mod read; mod write; /// The file extension of a standard XMP sidecar. pub const EXTENSION: &str = "xmp"; /// The RDF namespace, which is the document's own skeleton rather than a /// property anyone owns. pub const NS_RDF: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; /// Dublin Core: the subject, title, description, creator and rights. pub const NS_DC: &str = "http://purl.org/dc/elements/1.1/"; /// Adobe's basic schema, which is where the rating and the colour label live. pub const NS_XMP: &str = "http://ns.adobe.com/xap/1.0/"; /// Lightroom's schema. Only `hierarchicalSubject` is read from it, and it is /// here because every other application writing hierarchical keywords writes /// them under Lightroom's name — a de facto standard rather than a de jure one, /// which is exactly the kind interoperability runs on. pub const NS_LR: &str = "http://ns.adobe.com/lightroom/1.0/"; /// The Photoshop schema, which carries the IPTC credit line. pub const NS_PHOTOSHOP: &str = "http://ns.adobe.com/photoshop/1.0/"; /// Adobe's rights-management schema, which carries the usage terms. pub const NS_XMP_RIGHTS: &str = "http://ns.adobe.com/xap/1.0/rights/"; /// One thing DarkRoom stores in an XMP sidecar. /// /// An enum rather than a string key because it is the join between three /// things that must not drift apart: the property table below, the fields of /// [`Xmp`], and the conflict a caller reports to the user. A typo in a string /// would silently produce a field nothing reads. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum Field { /// `xmp:Rating` — stars, or the rejection that shares the same field. Rating, /// `xmp:Label` — the colour label, as free text. See [`Xmp::colour`]. Label, /// `dc:subject` — flat keywords. The interoperability surface of /// `dr_catalog::keywords`, which stores the word itself for this reason. Keywords, /// `lr:hierarchicalSubject` — keywords with their path, `Places|Iceland`. HierarchicalSubjects, /// `dc:title`. Title, /// `dc:description` — the IPTC caption. Description, /// `dc:creator` — the IPTC creator, or by-line. Creators, /// `dc:rights` — the copyright notice. Copyright, /// `photoshop:Credit` — the credit line, which is who should be named /// rather than who holds the rights. IPTC keeps them apart and so do we. Credit, /// `xmpRights:UsageTerms` — what a licensee may do with the photograph. UsageTerms, } impl Field { /// Every field, for the tests that keep the table and the type in step. pub const ALL: &'static [Field] = &[ Field::Rating, Field::Label, Field::Keywords, Field::HierarchicalSubjects, Field::Title, Field::Description, Field::Creators, Field::Copyright, Field::Credit, Field::UsageTerms, ]; /// The property that carries this field. /// /// Infallible by construction and tested to stay that way, so this is not /// an `Option` the caller has to reason about. fn property(self) -> &'static Property { PROPERTIES .iter() .find(|p| p.field == self) // Unreachable, and asserted unreachable by // `every_field_has_exactly_one_property`. A field with no row could // not be read or written at all, so the panic is not a risk this // adds — it is one the test removes. .expect("every field has a property") } } impl fmt::Display for Field { /// The qualified name as a photographer's other application would show it, /// which is what a conflict message wants — "xmp:Rating", not "Rating". fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let p = self.property(); write!(f, "{}:{}", p.prefix, p.local) } } /// How a property's value is laid out in the document. /// /// XMP does not have one container: a keyword list is an unordered `rdf:Bag`, /// a creator list an ordered `rdf:Seq`, a title a language alternative /// `rdf:Alt`, and a rating a bare number. Reading tolerates all four shapes /// whatever the property, because files in the wild are not tidy; **writing** /// uses the shape the specification names, because a file other applications /// must read is not the place to be creative. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Shape { /// The text of the element, with no container. Simple, /// `rdf:Bag` — an unordered set. Bag, /// `rdf:Seq` — an ordered list. Seq, /// `rdf:Alt` — language alternatives, of which `x-default` is the one /// meant when nobody asked for a language. Alt, } impl Shape { /// Whether this property holds several values or one. fn is_list(self) -> bool { matches!(self, Shape::Bag | Shape::Seq) } /// The RDF container this shape is written inside, if it has one. /// /// An `Option` rather than a name plus a special case, so the writer has /// no arm it has to call unreachable. fn container(self) -> Option<&'static str> { match self { Shape::Simple => None, Shape::Bag => Some("Bag"), Shape::Seq => Some("Seq"), Shape::Alt => Some("Alt"), } } } /// What happens when the catalog and a sidecar both have something to say. /// /// See the module documentation for the argument; this is the switch it names. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Merge { /// Both sides' values survive. Only keywords, and only because /// `dr_catalog::merge` already made that trade for the same data. Union, /// The value is one statement and cannot be interleaved. Two spellings of /// a copyright notice do not combine into a third that either party meant. Whole, } /// One property DarkRoom owns. /// /// The table below is the single description of the owned set, and reading, /// writing and reconciliation all drive off it — the same discipline /// `dr_pipeline::sidecar` follows with the operation descriptors, and for the /// same reason: a second list is a second thing to forget to update. #[derive(Debug, Clone, Copy)] pub struct Property { pub field: Field, /// The namespace URI. **This**, not the prefix, is what identifies the /// property; see the ownership rule in the module documentation. pub namespace: &'static str, /// The prefix used when *writing*, and never consulted when reading. /// Conventional, so a human opening the file sees what they expect. pub prefix: &'static str, pub local: &'static str, pub shape: Shape, pub merge: Merge, } /// TRACES: FR-CAT-13 /// Everything DarkRoom reads from and writes to a standard XMP sidecar. /// /// The whole of the owned set, in one place. A property absent from this table /// is not read, is not written, and — the part that matters — is not touched /// when DarkRoom rewrites a file that contains it. pub const PROPERTIES: &[Property] = &[ Property { field: Field::Rating, namespace: NS_XMP, prefix: "xmp", local: "Rating", shape: Shape::Simple, merge: Merge::Whole, }, Property { field: Field::Label, namespace: NS_XMP, prefix: "xmp", local: "Label", shape: Shape::Simple, merge: Merge::Whole, }, Property { field: Field::Keywords, namespace: NS_DC, prefix: "dc", local: "subject", shape: Shape::Bag, merge: Merge::Union, }, Property { field: Field::HierarchicalSubjects, namespace: NS_LR, prefix: "lr", local: "hierarchicalSubject", shape: Shape::Bag, merge: Merge::Union, }, Property { field: Field::Title, namespace: NS_DC, prefix: "dc", local: "title", shape: Shape::Alt, merge: Merge::Whole, }, Property { field: Field::Description, namespace: NS_DC, prefix: "dc", local: "description", shape: Shape::Alt, merge: Merge::Whole, }, Property { field: Field::Creators, namespace: NS_DC, prefix: "dc", local: "creator", shape: Shape::Seq, // Not a union, unlike the keywords above. Two applications listing // different photographers are not doing disjoint work on one list; // they disagree about who took the picture, and concatenating the two // answers produces a by-line naming somebody who was not there. merge: Merge::Whole, }, Property { field: Field::Copyright, namespace: NS_DC, prefix: "dc", local: "rights", shape: Shape::Alt, merge: Merge::Whole, }, Property { field: Field::Credit, namespace: NS_PHOTOSHOP, prefix: "photoshop", local: "Credit", shape: Shape::Simple, merge: Merge::Whole, }, Property { field: Field::UsageTerms, namespace: NS_XMP_RIGHTS, prefix: "xmpRights", local: "UsageTerms", shape: Shape::Alt, merge: Merge::Whole, }, ]; /// The property owning `(namespace, local)`, if DarkRoom owns it. /// /// The one function that answers "may this be touched", so there is one place /// to read to know what a rewrite will disturb. pub(crate) fn owner(namespace: Option<&[u8]>, local: &[u8]) -> Option<&'static Property> { let namespace = namespace?; PROPERTIES .iter() .find(|p| p.namespace.as_bytes() == namespace && p.local.as_bytes() == local) } /// Highest star rating, matching `dr_catalog::rating::MAX_RATING`. /// /// Duplicated rather than imported for the reason `dr_pipeline::sidecar` /// duplicates it: five is a statement about the XMP field's range as much as /// about ours, and the two happening to agree is not a dependency. pub const MAX_RATING: u8 = 5; /// TRACES: FR-CAT-13 | FR-CULL-4 /// The value of `xmp:Rating`, exactly as the field carries it. /// /// # Why one field carries two of DarkRoom's /// /// DarkRoom keeps stars and the pick/reject axis apart, and is right to: /// `dr_catalog::rating` explains that "I have not looked at this" and "I looked /// and it is poor" are different answers, and a reject is neither. XMP has one /// numeric field and Adobe's convention overloads it — `-1` means *rejected*, /// and `0` means unrated. /// /// Modelling that faithfully rather than translating it on the way in is what /// makes the round trip exact. A file saying `-1` reads back as `-1`, and the /// projection onto DarkRoom's two axes is done by the caller that has both of /// them, where it can be argued about in one place. The loss is inherent to /// the format and belongs on the record: a frame that is *both* rejected and /// three stars cannot be expressed here, because there is one field. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Rating { /// `xmp:Rating="-1"`. Adobe's rejection, which is not a low score. Rejected, /// Stars, 0..=[`MAX_RATING`]. Zero is *unrated*, a state rather than a /// score, exactly as in the catalog. Stars(u8), } impl Rating { /// The stars this represents, which is none for a rejection. pub fn stars(self) -> u8 { match self { Rating::Rejected => 0, Rating::Stars(n) => n, } } /// Whether this is Adobe's rejection rather than a star count. pub fn is_rejected(self) -> bool { matches!(self, Rating::Rejected) } /// Read the field's text. /// /// Anything that is not a number this build understands is `None` rather /// than a guess, and the caller keeps whatever it already had. Values above /// the maximum are clamped rather than refused: a file claiming six stars /// meant "as high as it goes", and dropping the rating entirely would lose /// more than rounding it does. fn parse(text: &str) -> Option { // A trailing `.0` is written by more than one application, and // `parse::` refuses it. Reading it as a float and rounding costs // nothing and is the difference between importing a cull and not. let value = text.trim().parse::().ok()?; if !value.is_finite() { return None; } let value = value.round(); if value <= -1.0 { return Some(Rating::Rejected); } Some(Rating::Stars(value.min(MAX_RATING as f32) as u8)) } } impl fmt::Display for Rating { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Rating::Rejected => write!(f, "-1"), Rating::Stars(n) => write!(f, "{n}"), } } } /// One value of a property, with the language it was written in. /// /// The language is carried because `rdf:Alt` exists to hold several, and /// discarding it on the way in would make a round trip rewrite a French /// caption as the default one. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct Item { /// The `xml:lang` of an `rdf:Alt` entry, where it declared one. pub lang: Option, pub text: String, } impl Item { pub(crate) fn plain(text: impl Into) -> Self { Item { lang: None, text: text.into(), } } } /// TRACES: FR-CAT-13 /// One photograph's standard metadata. /// /// Every field is optional or empty-able because every one of them is genuinely /// absent from real files: a frame off a card has no title, most photographs /// carry no rights statement, and an unrated frame is the ordinary case rather /// than the exception. /// /// This is the *whole* of what DarkRoom will read or write. Read the ownership /// rule in the module documentation before adding a field, because adding one /// widens what a rewrite is allowed to destroy. #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct Xmp { /// `xmp:Rating`. pub rating: Option, /// `xmp:Label`, as the text the file carries. /// /// Deliberately **not** a [`ColourLabel`]. The field is free text and /// Lightroom lets a user rename the labels, so a file may say "Second Pass" /// where another says "Yellow". Keeping the text means an unrecognised /// label survives a round trip instead of being silently deleted by a build /// that only knows five words; [`Xmp::colour`] is where the five are /// recognised. pub label: Option, /// `dc:subject`. pub keywords: Vec, /// `lr:hierarchicalSubject`, each entry a `|`-separated path. pub hierarchical_subjects: Vec, /// `dc:title`. pub title: Option, /// `dc:description` — the caption. pub description: Option, /// `dc:creator` — the by-line, which IPTC allows to name several people. pub creators: Vec, /// `dc:rights`. pub copyright: Option, /// `photoshop:Credit`. pub credit: Option, /// `xmpRights:UsageTerms`. pub usage_terms: Option, } impl Xmp { /// Read a standard XMP sidecar. /// /// Tolerant in the same direction `Sidecar::parse` is, and for a sharper /// reason: this file was written by somebody else's program. A property /// whose value will not parse costs that property and not the file, because /// a sidecar arriving without its rating is still worth every keyword in /// it. The one hard failure is a document with no `rdf:RDF` in it at all, /// where there is nothing to be tolerant *about* — it is not an XMP packet, /// and saying so beats returning an empty record that reads as "this /// photograph has no metadata". pub fn parse(text: &str) -> Result { read::parse(text) } /// A complete XMP packet holding exactly this. /// /// For a photograph that has no sidecar yet. Where one exists, use /// [`Xmp::rewrite`] — this produces a document containing DarkRoom's /// properties and nothing else, which is the correct answer for a new file /// and data loss for an existing one. /// /// Deterministic: the same value always produces the same bytes, so a /// caller may compare content to decide whether an upload or a write is /// needed rather than trusting a dirty flag. `Sidecar::to_text` makes the /// same guarantee for the same reason. pub fn to_text(&self) -> String { write::to_text(self) } /// The document that results from replacing `existing`'s owned properties /// with these. /// /// **This is the write path.** Everything the ownership rule promises is /// implemented here: owned properties out, this value's properties in, /// everything else through untouched. /// /// An empty or whitespace-only `existing` is a sidecar that does not exist /// yet, and yields [`Xmp::to_text`] — the caller that read a missing file /// into an empty string need not special-case it. /// /// Fails only where `existing` is not XML this crate can read, or is XML /// with no `rdf:RDF` element to put anything in. Both refuse rather than /// guess, because the alternative is overwriting a file whose contents were /// not understood. pub fn rewrite(&self, existing: &str) -> Result { write::rewrite(self, existing) } /// Whether this holds anything at all. pub fn is_empty(&self) -> bool { Field::ALL.iter().all(|f| self.values(*f).is_empty()) } /// The colour label as one of the five the application knows, where the /// file's text names one of them. /// /// Case-insensitive, because "Red", "red" and "RED" are one label written /// by three programs. A label naming anything else — a renamed Lightroom /// label, another language — is `None` here and still present in /// [`Xmp::label`], which is the whole reason the raw text is kept. pub fn colour(&self) -> Option { let label = self.label.as_deref()?.trim(); // Matched against English names because that is what the file format's // convention is written in, not because the interface is: Lightroom, // Bridge and darktable all write these five words regardless of the // language they are displaying. Some(match label.to_ascii_lowercase().as_str() { "red" => ColourLabel::Red, "yellow" => ColourLabel::Yellow, "green" => ColourLabel::Green, "blue" => ColourLabel::Blue, "purple" => ColourLabel::Purple, _ => return None, }) } /// Set the colour label, or clear it. /// /// Writes the conventional English name, which is what every other /// application reading this file expects to find. pub fn set_colour(&mut self, colour: Option) { self.label = colour.map(|c| { match c { ColourLabel::Red => "Red", ColourLabel::Yellow => "Yellow", ColourLabel::Green => "Green", ColourLabel::Blue => "Blue", ColourLabel::Purple => "Purple", } .to_string() }); } /// One field's values, as the document would carry them. /// /// The generic accessor the table drives. A scalar field yields nought or /// one; a list field yields as many as it holds. pub(crate) fn values(&self, field: Field) -> Vec { match field { Field::Rating => self.rating.iter().map(|r| r.to_string()).collect(), Field::Label => self.label.iter().cloned().collect(), Field::Keywords => self.keywords.clone(), Field::HierarchicalSubjects => self.hierarchical_subjects.clone(), Field::Title => self.title.iter().cloned().collect(), Field::Description => self.description.iter().cloned().collect(), Field::Creators => self.creators.clone(), Field::Copyright => self.copyright.iter().cloned().collect(), Field::Credit => self.credit.iter().cloned().collect(), Field::UsageTerms => self.usage_terms.iter().cloned().collect(), } } /// Put a field's values back, dropping what a scalar field cannot hold. /// /// A rating that will not parse leaves the field alone rather than clearing /// it — the tolerance the module promises, applied at the one place a value /// from outside becomes a typed one. pub(crate) fn set_values(&mut self, field: Field, values: Vec) { match field { Field::Rating => match values.first() { None => self.rating = None, Some(raw) => match Rating::parse(raw) { Some(r) => self.rating = Some(r), None => log::debug!("xmp: {raw:?} is not a rating; ignoring"), }, }, Field::Label => self.label = values.into_iter().next(), Field::Keywords => self.keywords = values, Field::HierarchicalSubjects => self.hierarchical_subjects = values, Field::Title => self.title = values.into_iter().next(), Field::Description => self.description = values.into_iter().next(), Field::Creators => self.creators = values, Field::Copyright => self.copyright = values.into_iter().next(), Field::Credit => self.credit = values.into_iter().next(), Field::UsageTerms => self.usage_terms = values.into_iter().next(), } } } /// Which side wins a field both sides hold. /// /// The module documentation carries the argument and the open question. In /// short: an automatic path passes [`Precedence::Catalog`], and only a person's /// explicit request passes the other. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum Precedence { /// What DarkRoom holds stands, and a disagreement is reported rather than /// resolved. The default, because a standard XMP carries nothing this could /// order two edits by. #[default] Catalog, /// The sidecar stands — a metadata reload, which a person asked for. Sidecar, } /// TRACES: FR-CAT-13 | FR-NC-9 /// The result of reconciling two records of one photograph. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Reconciled { pub merged: Xmp, /// The fields both sides held a value for, disagreeing. /// /// Reported rather than swallowed so a caller can offer the reload /// FR-CAT-13 asks for. Empty is the ordinary case: a disagreement needs /// both sides to have said something, and different things. pub conflicts: Vec, } /// TRACES: FR-CAT-13 | FR-NC-9 /// Reconcile what DarkRoom holds with what a sidecar says. /// /// Field by field, driven by [`PROPERTIES`]: [`Merge::Union`] fields keep /// everything either side has, and [`Merge::Whole`] fields take the other /// side's value only where this one has none — unless the two disagree, which /// is decided by `precedence` and reported either way. /// /// This is deliberately *not* `Version::merge`. That function has a revision to /// order two edits by and this has nothing; the module documentation explains /// at length why the two must not be made to look alike. pub fn reconcile(catalog: &Xmp, sidecar: &Xmp, precedence: Precedence) -> Reconciled { let mut merged = Xmp::default(); let mut conflicts = Vec::new(); for property in PROPERTIES { let mine = catalog.values(property.field); let theirs = sidecar.values(property.field); let resolved = match property.merge { // Set union, order-preserving: the catalog's words first in the // order it gave them, then whatever the file adds. Deterministic, // so reconciling twice is reconciling once. Merge::Union => { let mut out = mine; for value in theirs { if !out.contains(&value) { out.push(value); } } out } Merge::Whole => match (mine.is_empty(), theirs.is_empty()) { (true, _) => theirs, (_, true) => mine, _ if mine == theirs => mine, _ => { conflicts.push(property.field); match precedence { Precedence::Catalog => mine, Precedence::Sidecar => theirs, } } }, }; merged.set_values(property.field, resolved); } Reconciled { merged, conflicts } } /// TRACES: NFR-ARCH-4 /// Something went wrong reading or rewriting a sidecar. /// /// Typed and never a panic: this file was written by another program, may have /// been truncated by a sync that died halfway, and is entirely outside /// DarkRoom's control — so every one of these is a normal Tuesday rather than a /// bug. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub enum XmpError { #[error("not readable as XML: {0}")] NotXml(String), /// XML with no `rdf:RDF` in it. /// /// Distinguished from [`XmpError::NotXml`] because the two want different /// answers from a caller: malformed XML is a damaged file, and well-formed /// XML that is not a packet is the wrong file entirely — a `.xmp` that /// turned out to be a Lightroom preset, say. #[error("no rdf:RDF element: this is not an XMP packet")] NotXmp, } /// Group items by field, preserving the order they were met in. /// /// A `BTreeMap` so a document that spells one property twice — the attribute /// form and the element form in the same file, which Camera Raw has been known /// to produce — accumulates into one entry rather than the last one silently /// winning. pub(crate) type Found = BTreeMap>; #[cfg(test)] mod tests { use super::*; #[test] fn every_field_has_exactly_one_property() { // The table is the single description of the owned set, and // `Field::property` panics rather than returning an `Option` on the // strength of this test. A field with two rows would be read twice and // written twice; a field with none could not be read at all. for field in Field::ALL { let rows = PROPERTIES.iter().filter(|p| p.field == *field).count(); assert_eq!(rows, 1, "{field} has {rows} rows in PROPERTIES"); } assert_eq!(PROPERTIES.len(), Field::ALL.len()); } #[test] fn no_two_properties_name_the_same_xmp_property() { // Two rows for one `(namespace, local)` would make `owner` answer with // whichever came first, and the other field would be silently // unreadable. let mut names: Vec<(&str, &str)> = PROPERTIES.iter().map(|p| (p.namespace, p.local)).collect(); names.sort_unstable(); let before = names.len(); names.dedup(); assert_eq!(before, names.len(), "two properties claim one XMP name"); } #[test] fn a_rejection_is_not_a_star_count() { // Adobe overloads one field with two of DarkRoom's axes. Reading `-1` // as zero stars would turn every rejected frame into an unrated one. assert_eq!(Rating::parse("-1"), Some(Rating::Rejected)); assert!(Rating::parse("-1").unwrap().is_rejected()); assert_eq!(Rating::parse("-1").unwrap().stars(), 0); assert_eq!(Rating::parse("0"), Some(Rating::Stars(0))); } #[test] fn a_rating_that_is_not_a_number_is_no_rating() { assert_eq!(Rating::parse("later"), None); assert_eq!(Rating::parse(""), None); } #[test] fn a_rating_written_as_a_float_still_reads() { // More than one application writes `3.0`, and `parse::` refuses // it. Losing a cull to a decimal point would be a poor trade. assert_eq!(Rating::parse("3.0"), Some(Rating::Stars(3))); assert_eq!(Rating::parse(" 4 "), Some(Rating::Stars(4))); } #[test] fn a_rating_above_the_maximum_is_clamped_rather_than_dropped() { // "As high as it goes" is what six stars meant. assert_eq!(Rating::parse("9"), Some(Rating::Stars(MAX_RATING))); } #[test] fn the_five_labels_are_recognised_whatever_their_case() { let mut xmp = Xmp { label: Some("RED".into()), ..Xmp::default() }; assert_eq!(xmp.colour(), Some(ColourLabel::Red)); xmp.label = Some("purple".into()); assert_eq!(xmp.colour(), Some(ColourLabel::Purple)); } #[test] fn a_renamed_label_is_kept_even_though_it_is_not_one_of_ours() { // Lightroom lets a user rename the labels. Deleting the word because // this build does not recognise it would be exactly the data loss the // ownership rule exists to prevent. let xmp = Xmp { label: Some("Second Pass".into()), ..Xmp::default() }; assert_eq!(xmp.colour(), None); assert_eq!(xmp.label.as_deref(), Some("Second Pass")); } #[test] fn setting_a_colour_writes_the_word_other_applications_look_for() { let mut xmp = Xmp::default(); xmp.set_colour(Some(ColourLabel::Yellow)); assert_eq!(xmp.label.as_deref(), Some("Yellow")); xmp.set_colour(None); assert_eq!(xmp.label, None); } fn keyworded(words: &[&str]) -> Xmp { Xmp { keywords: words.iter().map(|w| w.to_string()).collect(), ..Xmp::default() } } #[test] fn keywords_union_so_neither_sides_work_is_lost() { // `dr_catalog::merge`'s rule, applied to the same data across a // different boundary: two devices that keyworded different frames both // keep their afternoon. let out = reconcile( &keyworded(&["puffin", "iceland"]), &keyworded(&["iceland", "gannet"]), Precedence::Catalog, ); assert_eq!(out.merged.keywords, ["puffin", "iceland", "gannet"]); assert!(out.conflicts.is_empty(), "a union cannot conflict"); } #[test] fn a_keyword_removed_here_comes_back_from_the_file() { // The acknowledged price of the union, stated in the module docs and // asserted here so nobody mistakes it for a bug: removal does not // propagate, because making it propagate needs a tombstone per // assignment that neither store has. let out = reconcile( &keyworded(&[]), &keyworded(&["puffin"]), Precedence::Catalog, ); assert_eq!(out.merged.keywords, ["puffin"]); } #[test] fn a_rating_arrives_where_we_have_none() { // The safe direction: a judgement can be added across stores. This is // the case that makes a library culled in Lightroom useful here. let sidecar = Xmp { rating: Some(Rating::Stars(4)), ..Xmp::default() }; let out = reconcile(&Xmp::default(), &sidecar, Precedence::Catalog); assert_eq!(out.merged.rating, Some(Rating::Stars(4))); assert!(out.conflicts.is_empty()); } #[test] fn an_unrated_sidecar_cannot_erase_a_rating() { // The unsafe direction, refused. A file written by a program that never // culled must not wipe the cull. let catalog = Xmp { rating: Some(Rating::Stars(5)), ..Xmp::default() }; let out = reconcile(&catalog, &Xmp::default(), Precedence::Catalog); assert_eq!(out.merged.rating, Some(Rating::Stars(5))); } #[test] fn a_genuine_disagreement_is_reported_rather_than_resolved_silently() { // There is no revision to order these two by, so the conservative // answer stands and the caller is told — which is what lets it offer // the reload FR-CAT-13 asks for. let catalog = Xmp { rating: Some(Rating::Stars(2)), ..Xmp::default() }; let sidecar = Xmp { rating: Some(Rating::Stars(5)), ..Xmp::default() }; let out = reconcile(&catalog, &sidecar, Precedence::Catalog); assert_eq!(out.merged.rating, Some(Rating::Stars(2))); assert_eq!(out.conflicts, [Field::Rating]); } #[test] fn a_requested_reload_lets_the_file_win_and_still_says_so() { let catalog = Xmp { rating: Some(Rating::Stars(2)), ..Xmp::default() }; let sidecar = Xmp { rating: Some(Rating::Stars(5)), ..Xmp::default() }; let out = reconcile(&catalog, &sidecar, Precedence::Sidecar); assert_eq!(out.merged.rating, Some(Rating::Stars(5))); assert_eq!( out.conflicts, [Field::Rating], "the user is still entitled to know what was overwritten" ); } #[test] fn two_sides_that_agree_are_not_a_conflict() { let both = Xmp { copyright: Some("© 2026 A. Photographer".into()), ..Xmp::default() }; let out = reconcile(&both, &both, Precedence::Catalog); assert!(out.conflicts.is_empty()); assert_eq!(out.merged, both); } #[test] fn creators_do_not_union() { // Two applications naming different photographers disagree; they are // not doing disjoint work. Concatenating would produce a by-line // crediting somebody who was not there. let catalog = Xmp { creators: vec!["A. Photographer".into()], ..Xmp::default() }; let sidecar = Xmp { creators: vec!["Somebody Else".into()], ..Xmp::default() }; let out = reconcile(&catalog, &sidecar, Precedence::Catalog); assert_eq!(out.merged.creators, ["A. Photographer"]); assert_eq!(out.conflicts, [Field::Creators]); } #[test] fn a_field_names_itself_the_way_the_other_application_does() { // What a conflict message quotes. "Rating" alone would not tell a // photographer which of their programs to go and look at. assert_eq!(Field::Rating.to_string(), "xmp:Rating"); assert_eq!(Field::Keywords.to_string(), "dc:subject"); assert_eq!( Field::HierarchicalSubjects.to_string(), "lr:hierarchicalSubject" ); } #[test] fn an_empty_record_knows_it_is_empty() { assert!(Xmp::default().is_empty()); assert!(!keyworded(&["puffin"]).is_empty()); } }