FR-CAT-13 asked for standard XMP and nothing in the tree parsed or wrote a byte of it. `keywords.rs` mentioned `dc:subject` in a comment about what a keyword's text is for, `dr-export`'s metadata module said "neither is read by `dr-decode` today" about its own half, and `dr-preset-xmp` reads a different file for a different requirement. So a library imported from Lightroom could come in and never go back out: a one-way door, which is not a thing a photographer walks their archive through. `core/dr-xmp` reads and writes the properties the requirement names — `dc:subject`, `lr:hierarchicalSubject`, `xmp:Rating`, `xmp:Label` and the IPTC core fields — from whichever shape the file happens to use. A property may arrive as an attribute or as an element, inside a Bag, a Seq, an Alt or no container at all, because the specification is not what wrote the file; so one collector takes whatever is in a property and the declared shape decides only how many values survive. `xmp:Rating="-1"` is modelled as Adobe's rejection rather than folded into zero stars, since DarkRoom keeps those on two axes and the mapping belongs where both are visible. Writing is a rewrite rather than a serialisation, and that is the whole design. An XMP sidecar is a shared document: the file beside a raw carries somebody else's `crs:` settings and comments and namespaces, and rendering our record over it would be data loss on every photograph but the first. The rule is stated once, in the crate documentation and in `PROPERTIES`: DarkRoom owns exactly those properties, identified by namespace URI and never by prefix, and nothing else in the document. Everything unowned is copied through byte for byte. A `Description` left empty once our properties come out of it is withdrawn, which is what keeps a rewrite idempotent instead of adding a husk to the file on every save. Precedence is settled conservatively, because a standard XMP carries no revision and no device and there is nothing in it to order two edits by. Keywords union, following the rule `dr_catalog::merge` already makes for assignments; every other field is taken only where DarkRoom holds none, following `Version::merge`'s judgement rule, and a genuine disagreement is reported rather than resolved so a caller can offer the reload the requirement asks for. What is deliberately left open — when a reload may happen without asking — is written down in the module rather than picked silently. No new dependency: quick-xml was already in the tree for WebDAV and for Lightroom presets. Nothing above the crate calls it yet, and `outstanding.md` now says so along with the two smaller gaps, GPS and the filename convention.
1065 lines
43 KiB
Rust
1065 lines
43 KiB
Rust
//! 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<Rating> {
|
|
// A trailing `.0` is written by more than one application, and
|
|
// `parse::<i32>` 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::<f32>().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<String>,
|
|
pub text: String,
|
|
}
|
|
|
|
impl Item {
|
|
pub(crate) fn plain(text: impl Into<String>) -> 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<Rating>,
|
|
/// `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<String>,
|
|
/// `dc:subject`.
|
|
pub keywords: Vec<String>,
|
|
/// `lr:hierarchicalSubject`, each entry a `|`-separated path.
|
|
pub hierarchical_subjects: Vec<String>,
|
|
/// `dc:title`.
|
|
pub title: Option<String>,
|
|
/// `dc:description` — the caption.
|
|
pub description: Option<String>,
|
|
/// `dc:creator` — the by-line, which IPTC allows to name several people.
|
|
pub creators: Vec<String>,
|
|
/// `dc:rights`.
|
|
pub copyright: Option<String>,
|
|
/// `photoshop:Credit`.
|
|
pub credit: Option<String>,
|
|
/// `xmpRights:UsageTerms`.
|
|
pub usage_terms: Option<String>,
|
|
}
|
|
|
|
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<Xmp, XmpError> {
|
|
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<String, XmpError> {
|
|
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<ColourLabel> {
|
|
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<ColourLabel>) {
|
|
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<String> {
|
|
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<String>) {
|
|
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<Field>,
|
|
}
|
|
|
|
/// 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<Field, Vec<Item>>;
|
|
|
|
#[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::<i32>` 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());
|
|
}
|
|
}
|