diff --git a/Cargo.lock b/Cargo.lock index eb8af4a..6449452 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1684,6 +1684,16 @@ dependencies = [ "wgpu", ] +[[package]] +name = "dr-xmp" +version = "0.10.1" +dependencies = [ + "dr-types", + "log", + "quick-xml", + "thiserror 2.0.20", +] + [[package]] name = "drm" version = "0.14.1" diff --git a/Cargo.toml b/Cargo.toml index 00c41a6..46bbd85 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ members = [ "core/dr-sync", "core/dr-sync-folder", "core/dr-sync-nextcloud", + "core/dr-xmp", "platform/dr-plat", "ui/dr-ui", "apps/darkroom-desktop", @@ -59,6 +60,7 @@ dr-plat = { path = "platform/dr-plat" } dr-sync = { path = "core/dr-sync" } dr-sync-folder = { path = "core/dr-sync-folder" } dr-sync-nextcloud = { path = "core/dr-sync-nextcloud" } +dr-xmp = { path = "core/dr-xmp" } dr-ui = { path = "ui/dr-ui" } # GPU + UI diff --git a/core/dr-xmp/Cargo.toml b/core/dr-xmp/Cargo.toml new file mode 100644 index 0000000..dafad5c --- /dev/null +++ b/core/dr-xmp/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "dr-xmp" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +# No platform dependency and no filesystem, deliberately — the same rule +# `dr-export` states for the same reason. This crate turns *text* into a record +# and a record back into *text*; where those bytes come from and go is the +# caller's problem, because the answer differs by more than a path. On Linux it +# is a file beside the raw, on Android a SAF document with no path at all +# (ARCH §6.9), and on either it may be a `GET` and a `PUT` against Nextcloud. +[dependencies] +# `ColourLabel` alone. The mapping from `xmp:Label`'s text to the five labels +# the rest of the application already knows has to live somewhere, and anywhere +# else is a second table to keep in step with `dr_types::selector`. +dr-types.workspace = true +# XMP is real XML with namespaces and is not worth hand-rolling — the same +# sentence `dr-preset-xmp` writes, and for the same reason. Already in the tree +# for WebDAV and for Lightroom presets, so this is a use rather than a new +# dependency. +quick-xml.workspace = true +log.workspace = true +thiserror.workspace = true diff --git a/core/dr-xmp/src/lib.rs b/core/dr-xmp/src/lib.rs new file mode 100644 index 0000000..0508de5 --- /dev/null +++ b/core/dr-xmp/src/lib.rs @@ -0,0 +1,1064 @@ +//! 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()); + } +} diff --git a/core/dr-xmp/src/read.rs b/core/dr-xmp/src/read.rs new file mode 100644 index 0000000..7b657f1 --- /dev/null +++ b/core/dr-xmp/src/read.rs @@ -0,0 +1,571 @@ +//! TRACES: FR-CAT-13 +//! Reading a standard XMP sidecar. +//! +//! # One collector for four shapes +//! +//! A property may appear in this file in more ways than the specification +//! needs, because the specification is not what wrote it. `xmp:Rating` is an +//! attribute on `rdf:Description` in one application's output and a child +//! element in another's; `dc:subject` is an `rdf:Bag` of `rdf:li` in both, but +//! a phone that half-implemented XMP will write it as a bare string. A reader +//! that insisted on the declared [`Shape`](crate::Shape) per property would +//! reject files that every other editor reads. +//! +//! So reading is shape-blind: whatever is inside an owned property's element, +//! it is collected as a list of `(language, text)` items, and the property's +//! declared shape decides only *how many* of them survive — all of them for a +//! list, one for a scalar. That is one collector rather than four parsers, and +//! it is why a `dc:title` written as a plain string and one written as an +//! `rdf:Alt` both arrive as the same title. +//! +//! # Text arrives in pieces +//! +//! quick-xml reports an entity reference as its own event rather than folding +//! it into the surrounding text, so `Bells & whistles` is three events. The +//! collector therefore accumulates rather than taking the first run — a keyword +//! with an ampersand in it is an ordinary keyword, and reading it as "Bells" +//! would be a silent, permanent corruption of the user's vocabulary. + +use quick_xml::escape::unescape; +use quick_xml::events::Event; +use quick_xml::name::ResolveResult; +use quick_xml::{NsReader, XmlVersion}; + +use crate::{owner, Field, Found, Item, Shape, Xmp, XmpError, NS_RDF}; + +/// The property currently open, and what has been collected of it. +struct Capture { + field: Field, + /// How many elements are open *inside* the property. Zero means the next + /// `End` closes the property itself. + depth: usize, + items: Vec, + /// The `rdf:li` currently open, if one is. + li: Option, + /// Text found directly inside the property element, which is what a + /// container-less value looks like. + direct: String, +} + +impl Capture { + fn new(field: Field) -> Self { + Capture { + field, + depth: 0, + items: Vec::new(), + li: None, + direct: String::new(), + } + } + + /// Append a run of text to wherever we currently are. + fn push_text(&mut self, text: &str) { + match self.li.as_mut() { + Some(li) => li.text.push_str(text), + None => self.direct.push_str(text), + } + } + + /// What this property turned out to hold. + /// + /// The `rdf:li` entries where there were any, and the direct text + /// otherwise. Blank entries are dropped: an empty `rdf:li` is a container + /// somebody left behind, not a keyword. + fn finish(mut self) -> Vec { + for item in &mut self.items { + item.text = item.text.trim().to_string(); + } + self.items.retain(|i| !i.text.is_empty()); + if self.items.is_empty() { + let direct = self.direct.trim(); + if !direct.is_empty() { + self.items.push(Item::plain(direct)); + } + } + self.items + } +} + +pub(crate) fn parse(text: &str) -> Result { + let mut reader = NsReader::from_str(text); + let mut found: Found = Found::new(); + let mut capture: Option = None; + let mut saw_rdf = false; + + loop { + // The resolved namespace borrows the reader, and the next read needs it + // mutably, so it is turned into bytes we own inside this block and the + // borrow ends with the block. A sidecar is a few kilobytes and this is + // one small allocation per element, which is not the cost worth + // contorting the loop to avoid. + let (namespace, event) = { + let (resolved, event) = reader + .read_resolved_event() + .map_err(|e| XmpError::NotXml(e.to_string()))?; + (owned_namespace(&resolved), event) + }; + let is_empty = matches!(event, Event::Empty(_)); + + match event { + Event::Eof => break, + + Event::Start(e) | Event::Empty(e) => { + let local = e.local_name().as_ref().to_vec(); + + if let Some(open) = capture.as_mut() { + // Inside a property. The only element that means anything + // here is `rdf:li`; a container — `rdf:Bag`, `rdf:Seq`, + // `rdf:Alt` — is stepped over, because which one it is + // changes nothing about the values inside it. + if is_rdf(&namespace, &local, b"li") { + let item = Item { + lang: language_of(&e), + text: String::new(), + }; + if is_empty { + open.items.push(item); + } else { + open.li = Some(item); + } + } + if !is_empty { + open.depth += 1; + } + continue; + } + + if is_rdf(&namespace, &local, b"RDF") { + saw_rdf = true; + } + + // The attribute form. Every element is checked rather than only + // `rdf:Description`, because the property is identified by its + // name and the element it hangs off is not part of that + // identity — and a file that put one somewhere unexpected is + // still a file whose rating we would rather read than lose. + for attribute in e.attributes().flatten() { + let (resolved, attr_local) = reader.resolver().resolve_attribute(attribute.key); + let ns = owned_namespace(&resolved); + let Some(property) = owner(ns.as_deref(), attr_local.as_ref()) else { + continue; + }; + // `normalized_value` rather than the deprecated + // `unescape_value`, and `Implicit1_0` because an XMP packet + // opens with `` rather than an XML declaration, + // so the version is unstated and the specification says to + // assume 1.0 — the same call `dr-preset-xmp` makes. + let Ok(value) = attribute.normalized_value(XmlVersion::Implicit1_0) else { + continue; + }; + let value = value.trim(); + if !value.is_empty() { + found + .entry(property.field) + .or_default() + .push(Item::plain(value)); + } + } + + // The element form. + if let Some(property) = owner(namespace.as_deref(), &local) { + if is_empty { + // ``. The property is present and says + // nothing, which is different from being absent only to + // a writer; here it contributes no items. + found.entry(property.field).or_default(); + } else { + capture = Some(Capture::new(property.field)); + } + } + } + + Event::End(e) => { + let closes_li = is_rdf(&namespace, e.local_name().as_ref(), b"li"); + let closed = match capture.as_mut() { + None => false, + Some(open) if open.depth == 0 => true, + Some(open) => { + open.depth -= 1; + if closes_li { + if let Some(li) = open.li.take() { + open.items.push(li); + } + } + false + } + }; + if closed { + let open = capture.take().expect("checked in the match above"); + found.entry(open.field).or_default().extend(open.finish()); + } + } + + Event::Text(t) => { + if let Some(open) = capture.as_mut() { + if let Ok(text) = t.xml10_content() { + open.push_text(&text); + } + } + } + + Event::CData(t) => { + if let Some(open) = capture.as_mut() { + if let Ok(text) = t.decode() { + open.push_text(&text); + } + } + } + + Event::GeneralRef(r) => { + if let Some(open) = capture.as_mut() { + // The event carries the entity's *name*, so it is put back + // between its delimiters and unescaped — which resolves the + // five predefined entities and the numeric character + // references in one call rather than in a table of our own. + let Ok(name) = r.decode() else { continue }; + match unescape(&format!("&{name};")) { + Ok(resolved) => open.push_text(&resolved), + // A document-defined entity, which an XMP packet has no + // business carrying. Dropped with a note rather than + // failing the file: it costs one character of one + // value, and refusing would cost every keyword in it. + Err(_) => log::debug!("xmp: unknown entity &{name};, dropped"), + } + } + } + + _ => {} + } + } + + if !saw_rdf { + return Err(XmpError::NotXmp); + } + + let mut xmp = Xmp::default(); + for property in crate::PROPERTIES { + let Some(items) = found.get(&property.field) else { + continue; + }; + xmp.set_values(property.field, select(property.shape, items)); + } + Ok(xmp) +} + +/// The values of one property, as its declared shape wants them. +/// +/// A list keeps everything, de-duplicated: an `rdf:Bag` is a set, and a word +/// listed twice would become two identical rows in the keyword panel that no +/// user could tell apart — the same argument `dr_catalog::keywords::create` +/// makes for resolving rather than duplicating. +/// +/// A scalar keeps one, and prefers `x-default` — the entry an `rdf:Alt` means +/// when nobody asked for a language. Where there is no `x-default` the first +/// entry stands, because a caption in one language beats no caption. +fn select(shape: Shape, items: &[Item]) -> Vec { + if shape.is_list() { + let mut out: Vec = Vec::with_capacity(items.len()); + for item in items { + if !item.text.is_empty() && !out.contains(&item.text) { + out.push(item.text.clone()); + } + } + return out; + } + + items + .iter() + .find(|i| i.lang.as_deref() == Some("x-default") && !i.text.is_empty()) + .or_else(|| items.iter().find(|i| !i.text.is_empty())) + .map(|i| vec![i.text.clone()]) + .unwrap_or_default() +} + +/// The namespace a resolver bound this name to, as bytes we own. +fn owned_namespace(resolved: &ResolveResult<'_>) -> Option> { + match resolved { + ResolveResult::Bound(ns) => Some(ns.0.to_vec()), + // An unprefixed name, or a prefix nothing declared. Neither can be one + // of ours: every property this crate owns lives in a namespace, and a + // prefix with no declaration is a broken document rather than a hint. + _ => None, + } +} + +/// Whether this is the named RDF element. +fn is_rdf(namespace: &Option>, local: &[u8], name: &[u8]) -> bool { + namespace.as_deref() == Some(NS_RDF.as_bytes()) && local == name +} + +/// The `xml:lang` an `rdf:Alt` entry declares. +/// +/// Matched on the literal `xml:lang` rather than through the resolver, and +/// that is correct rather than lazy: `xml` is the one prefix the XML +/// specification reserves and binds itself, so it means the same thing in every +/// document and no declaration is required for it — which is exactly why a +/// resolver may not have a binding to report. +fn language_of(e: &quick_xml::events::BytesStart<'_>) -> Option { + for attribute in e.attributes().flatten() { + if attribute.key.as_ref() == b"xml:lang" { + let value = attribute.normalized_value(XmlVersion::Implicit1_0).ok()?; + return Some(value.trim().to_string()); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Rating; + use dr_types::ColourLabel; + + /// The shape Lightroom writes: everything as attributes on one + /// `rdf:Description`, keywords in a `Bag`, captions in an `Alt`. + const LIGHTROOM: &str = r#" + + + + + + puffin + Iceland + + + + + Places|Iceland + + + + + Puffin on a cliff + + + + + Látrabjarg, late evening. + + + + + A. Photographer + + + + + © 2026 A. Photographer + + + + + Editorial use only. + + + + + +"#; + + #[test] + fn a_lightroom_sidecar_reads_in_full() { + // The requirement in one test: keywords, rating, colour label and the + // IPTC core fields, out of a file this application did not write. + let xmp = Xmp::parse(LIGHTROOM).unwrap(); + + assert_eq!(xmp.rating, Some(Rating::Stars(4))); + assert_eq!(xmp.colour(), Some(ColourLabel::Yellow)); + assert_eq!(xmp.keywords, ["puffin", "Iceland"]); + assert_eq!(xmp.hierarchical_subjects, ["Places|Iceland"]); + assert_eq!(xmp.title.as_deref(), Some("Puffin on a cliff")); + assert_eq!( + xmp.description.as_deref(), + Some("Látrabjarg, late evening.") + ); + assert_eq!(xmp.creators, ["A. Photographer"]); + assert_eq!(xmp.copyright.as_deref(), Some("© 2026 A. Photographer")); + assert_eq!(xmp.credit.as_deref(), Some("Puffin Pictures")); + assert_eq!(xmp.usage_terms.as_deref(), Some("Editorial use only.")); + } + + #[test] + fn a_property_written_as_an_element_reads_the_same_as_one_written_as_an_attribute() { + // Two applications, two shapes, one meaning. Insisting on either would + // reject files every other editor reads. + let element = r#" + + + 3 + + +"#; + let attribute = r#" + + + +"#; + + assert_eq!( + Xmp::parse(element).unwrap().rating, + Xmp::parse(attribute).unwrap().rating + ); + assert_eq!(Xmp::parse(element).unwrap().rating, Some(Rating::Stars(3))); + } + + /// A document binding the conventional prefixes to nothing of the sort, and + /// naming the real namespaces under prefixes of its own. + const HOSTILE_PREFIXES: &str = r#" + + + + impostor + + + puffin + + + +"#; + + #[test] + fn a_property_is_identified_by_its_namespace_and_not_by_its_prefix() { + // The ownership rule's first consequence, tested rather than asserted + // in prose: matching the string "dc:subject" would read the impostor + // and miss the keyword. + let xmp = Xmp::parse(HOSTILE_PREFIXES).unwrap(); + assert_eq!(xmp.keywords, ["puffin"]); + } + + #[test] + fn a_value_containing_an_entity_arrives_whole() { + // The parser reports `&` as its own event, so taking the first run + // of text would store "Bells " and lose the rest for ever. + let text = r#" + + Bells & whistles + + "#; + assert_eq!(Xmp::parse(text).unwrap().keywords, ["Bells & whistles"]); + } + + #[test] + fn a_container_less_value_still_reads() { + // A phone that half-implemented XMP. The declared shape is a Bag and + // this is a bare string; refusing it would lose a real keyword. + let text = r#" + + puffin + Straight to the point + + "#; + let xmp = Xmp::parse(text).unwrap(); + assert_eq!(xmp.keywords, ["puffin"]); + assert_eq!(xmp.title.as_deref(), Some("Straight to the point")); + } + + #[test] + fn the_default_language_wins_where_a_caption_has_several() { + let text = r#" + + + + Macareux + Puffin + + + + "#; + assert_eq!(Xmp::parse(text).unwrap().title.as_deref(), Some("Puffin")); + } + + #[test] + fn a_caption_with_no_default_language_still_reads() { + // A caption in one language beats no caption. + let text = r#" + + Macareux + + "#; + assert_eq!(Xmp::parse(text).unwrap().title.as_deref(), Some("Macareux")); + } + + #[test] + fn a_word_listed_twice_is_one_keyword() { + let text = r#" + + + puffinpuffin + + + "#; + assert_eq!(Xmp::parse(text).unwrap().keywords, ["puffin"]); + } + + #[test] + fn a_rejection_survives_the_read() { + let text = r#" + + "#; + assert_eq!(Xmp::parse(text).unwrap().rating, Some(Rating::Rejected)); + } + + #[test] + fn an_unreadable_rating_costs_the_rating_and_not_the_file() { + // The tolerance the module promises: a sidecar arriving without its + // rating is still worth every keyword in it. + let text = r#" + + puffin + + "#; + let xmp = Xmp::parse(text).unwrap(); + assert_eq!(xmp.rating, None); + assert_eq!(xmp.keywords, ["puffin"]); + } + + #[test] + fn xml_that_is_not_a_packet_says_so_rather_than_reading_as_empty() { + // "This photograph has no metadata" and "this is the wrong file" are + // different answers, and a caller acts differently on each. + let err = Xmp::parse("Warm").unwrap_err(); + assert_eq!(err, XmpError::NotXmp); + } + + #[test] + fn malformed_xml_is_a_typed_error() { + // A closing tag that does not match its opening one. Distinguished from + // `NotXmp` because the two mean different things to a caller: this file + // is damaged, where the test above's is merely the wrong file. + assert!(matches!( + Xmp::parse(""), + Err(XmpError::NotXml(_)) + )); + } + + #[test] + fn a_document_carrying_nothing_of_ours_reads_as_empty_rather_than_failing() { + // Somebody else's sidecar, with only their own namespaces in it. That + // is a perfectly good file about a photograph we have nothing to say + // about yet. + let text = r#" + + "#; + assert!(Xmp::parse(text).unwrap().is_empty()); + } +} diff --git a/core/dr-xmp/src/write.rs b/core/dr-xmp/src/write.rs new file mode 100644 index 0000000..6236777 --- /dev/null +++ b/core/dr-xmp/src/write.rs @@ -0,0 +1,768 @@ +//! TRACES: FR-CAT-13 +//! Writing a standard XMP sidecar without wrecking somebody else's. +//! +//! # Why this is a rewrite and not a serialisation +//! +//! The obvious implementation is to render an [`Xmp`] as a document and save +//! it. That is correct exactly once — for a photograph that has no sidecar — +//! and is data loss every other time, because the file it replaces belongs +//! partly to whoever wrote it first. The ownership rule in the crate +//! documentation is the whole design, and [`rewrite`] is where it is enforced: +//! owned properties out, ours in, everything else through untouched. +//! +//! "Untouched" is meant literally. Events the reader hands back are written +//! straight to the output, so an element nobody here understands is reproduced +//! byte for byte, comments and processing instructions included. The only tags +//! rebuilt are the ones an owned *attribute* was removed from, and those are +//! being modified anyway. +//! +//! # Where the new properties go +//! +//! Into an `rdf:Description` of our own, appended just before ``, +//! carrying its own namespace declarations. +//! +//! That looks like the long way round — the file usually has an +//! `rdf:Description` already, and the properties could be injected into it. +//! But doing so needs the prefixes we write to be in scope there, and deciding +//! that means reasoning about what every ancestor declared and whether a +//! prefix we want is already bound to something else. RDF allows any number of +//! `rdf:Description` elements about one resource and XMP itself groups them by +//! schema, so a block of our own is both the idiomatic shape and the one that +//! needs to know nothing at all about the document it is joining. + +use std::fmt::Write as _; +use std::io::Write as _; + +use quick_xml::escape::escape; +use quick_xml::events::{BytesStart, Event}; +use quick_xml::name::ResolveResult; +use quick_xml::{NsReader, Writer}; + +use crate::{owner, Property, Shape, Xmp, XmpError, NS_RDF, PROPERTIES}; + +/// The packet identifier every XMP file carries, fixed by the specification. +const PACKET_ID: &str = "W5M0MpCehiHzreSzNTczkc9d"; + +/// What `x:xmptk` says on a document this crate created. +/// +/// Written on a *new* packet only. An existing file's toolkit attribute is +/// left exactly as it was, because rewriting it would claim authorship of a +/// document we contributed ten lines to — and because it is not in +/// [`PROPERTIES`], which is the same statement made once, formally. +const TOOLKIT: &str = "DarkRoom"; + +pub(crate) fn to_text(xmp: &Xmp) -> String { + // Writes into a `String` cannot fail, so their results are discarded — the + // same choice `Sidecar::to_text` makes, and for the same reason. + let mut out = String::new(); + // The leading U+FEFF inside `begin` is what the specification asks for and + // what every reader looks for; it is part of the packet's syntax rather + // than a byte-order mark on the file. + let _ = writeln!(out, ""); + let _ = writeln!( + out, + "" + ); + let _ = writeln!(out, " "); + if let Some(block) = description_block(xmp, " ") { + let _ = writeln!(out, "{block}"); + } + let _ = writeln!(out, " "); + let _ = writeln!(out, ""); + // No padding whitespace before the trailing packet marker. Adobe writes + // kilobytes of it so a packet embedded *inside* an image file can grow + // without moving everything after it; a standalone sidecar is rewritten + // whole, so the padding would be bytes that sync on every edit for nothing. + let _ = writeln!(out, ""); + out +} + +pub(crate) fn rewrite(xmp: &Xmp, existing: &str) -> Result { + if existing.trim().is_empty() { + return Ok(to_text(xmp)); + } + + let mut reader = NsReader::from_str(existing); + let mut writer = Writer::new(Vec::new()); + let block = description_block(xmp, " "); + + // How deep we are inside an owned property that is being dropped. `None` + // is the ordinary state; `Some(n)` means an owned property's start tag has + // been swallowed and `n` elements are open inside it, so its own end tag + // brings the count to zero. + let mut dropping: Option = None; + // An `rdf:Description` whose fate is not yet decided. See [`Pending`]. + let mut pending: Option = None; + // Whitespace between elements, written only once we know what follows it. + // See [`flush`] for why it cannot simply be passed through. + let mut held: Vec = Vec::new(); + let mut injected = false; + let mut saw_rdf = false; + + loop { + let (namespace, event) = { + let (resolved, event) = reader + .read_resolved_event() + .map_err(|e| XmpError::NotXml(e.to_string()))?; + (owned_namespace(&resolved), event) + }; + let is_empty = matches!(event, Event::Empty(_)); + + if let Some(depth) = dropping.as_mut() { + match &event { + Event::Start(_) => *depth += 1, + Event::End(_) => { + *depth -= 1; + if *depth == 0 { + dropping = None; + } + } + // A property whose end tag never arrives. The reader would + // normally have refused the document before this, but a + // truncated file must not silently produce output with half of + // it missing. + Event::Eof => { + return Err(XmpError::NotXml( + "the document ends inside a property".into(), + )) + } + _ => {} + } + continue; + } + + match event { + Event::Eof => break, + + Event::Start(e) | Event::Empty(e) => { + let local = e.local_name().as_ref().to_vec(); + + // An owned property, wherever it sits and whatever prefix it + // wears. Dropped here and re-emitted below from what we hold, + // which is what makes the owned set a *replacement* rather than + // an addition — without this, a rating edited here and a rating + // already in the file would both be in the result and the next + // reader would pick one at random. + if owner(namespace.as_deref(), &local).is_some() { + if !is_empty { + dropping = Some(1); + } + continue; + } + + let in_rdf = namespace.as_deref() == Some(NS_RDF.as_bytes()); + let is_rdf_root = in_rdf && local == b"RDF"; + if is_rdf_root { + saw_rdf = true; + } + + let tag = match strip_owned_attributes(&reader, &e) { + Some(stripped) => stripped, + None => e, + }; + + // A `Description` at the top level may turn out to hold nothing + // once our properties are out of it, so it is held back until + // its end tag says. + if in_rdf && local == b"Description" && pending.is_none() { + let data = carries_data(&reader, &tag); + if !is_empty { + pending = Some(Pending::new(tag.into_owned(), data)); + continue; + } + if !data { + // `` asserts nothing. + held.clear(); + continue; + } + } + + if let Some(open) = pending.as_mut() { + open.meaningful = true; + if !is_empty { + open.depth += 1; + } + let _ = open.body.write_event(if is_empty { + Event::Empty(tag) + } else { + Event::Start(tag) + }); + continue; + } + + flush(&mut writer, &mut held); + if is_rdf_root && is_empty { + // ``. There is no end tag to put anything before, + // so the empty element becomes an open tag, our block, and + // a close — the one place this rewrites the document's + // shape rather than its content. + let _ = writer.write_event(Event::Start(tag.borrow())); + write_block(&mut writer, block.as_deref()); + let _ = writer.write_event(Event::End(tag.to_end())); + injected = true; + } else if is_empty { + let _ = writer.write_event(Event::Empty(tag)); + } else { + let _ = writer.write_event(Event::Start(tag)); + } + } + + Event::End(e) => { + if pending.is_some() { + if pending.as_ref().is_some_and(|p| p.depth == 0) { + if let Some(open) = pending.take() { + if open.keep() { + flush(&mut writer, &mut held); + open.write_into(&mut writer); + } else { + // Withdrawn, and the whitespace leading up to it + // goes with it. Leaving that behind is what + // would make a file grow a blank line on every + // save. + held.clear(); + } + } + } else if let Some(open) = pending.as_mut() { + open.depth -= 1; + let _ = open.body.write_event(Event::End(e)); + } + continue; + } + + let closes_rdf = namespace.as_deref() == Some(NS_RDF.as_bytes()) + && e.local_name().as_ref() == b"RDF"; + if closes_rdf && !injected { + // The whitespace before `` is replaced rather than + // kept, so the bytes around the injection point are the same + // however many times this has run over the same file. + held.clear(); + write_block(&mut writer, block.as_deref()); + injected = true; + } + flush(&mut writer, &mut held); + let _ = writer.write_event(Event::End(e)); + } + + Event::Text(t) => { + // Whitespace between elements is not content, and holding it is + // what lets a withdrawn `Description` take its own indentation + // with it. + let blank = t + .xml10_content() + .map(|s| s.trim().is_empty()) + .unwrap_or(false); + match pending.as_mut() { + Some(open) => { + if !blank { + open.meaningful = true; + } + let _ = open.body.write_event(Event::Text(t)); + } + None if blank => held.extend_from_slice(&t.into_inner()), + None => { + flush(&mut writer, &mut held); + let _ = writer.write_event(Event::Text(t)); + } + } + } + + other => match pending.as_mut() { + // A comment, a processing instruction or a chunk of CDATA + // inside a `Description` is somebody's content, and keeps the + // `Description` alive whatever else was taken out of it. + Some(open) => { + open.meaningful = true; + let _ = open.body.write_event(other); + } + None => { + flush(&mut writer, &mut held); + let _ = writer.write_event(other); + } + }, + } + } + + // Whatever trailing whitespace the document ended with. + flush(&mut writer, &mut held); + + if !saw_rdf { + return Err(XmpError::NotXmp); + } + + String::from_utf8(writer.into_inner()).map_err(|e| XmpError::NotXml(e.to_string())) +} + +/// An `rdf:Description` held back until its end tag says whether it survives. +/// +/// # Why an empty one is withdrawn rather than left +/// +/// This is not tidiness, it is what makes a rewrite idempotent. The block this +/// crate injects *is* an `rdf:Description` containing only owned properties, so +/// the next write strips those out and what is left is an empty husk. A build +/// that kept those would add one to the file on every save, for ever, and each +/// one would sync. +/// +/// It is also the right answer for a `Description` somebody else wrote that +/// happened to hold only DarkRoom's properties: with those gone it asserts +/// nothing, and an element asserting nothing is not something to preserve. +/// +/// **What it may not withdraw** is a `Description` still carrying content or an +/// attribute of substance, which is what [`Self::keep`] decides. +struct Pending { + /// The start tag, already stripped of any owned attribute. + tag: BytesStart<'static>, + /// Everything written inside it so far, verbatim. + body: Writer>, + /// Elements open inside it. Zero means the next end tag is its own. + depth: usize, + /// Whether anything worth keeping has gone into the body. + meaningful: bool, + /// Whether the start tag itself carries data. See [`carries_data`]. + data: bool, +} + +impl Pending { + fn new(tag: BytesStart<'static>, data: bool) -> Self { + Pending { + tag, + body: Writer::new(Vec::new()), + depth: 0, + meaningful: false, + data, + } + } + + /// Whether this `Description` still says anything. + fn keep(&self) -> bool { + self.meaningful || self.data + } + + fn write_into(self, writer: &mut Writer>) { + let Pending { tag, body, .. } = self; + let _ = writer.write_event(Event::Start(tag.borrow())); + let _ = writer.get_mut().write_all(&body.into_inner()); + let _ = writer.write_event(Event::End(tag.to_end())); + } +} + +/// Whether a start tag carries anything but bookkeeping. +/// +/// Namespace declarations and RDF's own attributes — `rdf:about` and its +/// relatives — describe the document rather than the photograph, so a +/// `Description` holding only those and no children states nothing at all. That +/// is what makes it safe to withdraw; anything else on the tag is somebody's +/// data written in attribute form, and the element stays. +/// +/// `xmlns` is matched literally because it is the one prefix the Namespaces +/// specification reserves and binds itself — the same reasoning `read`'s +/// `xml:lang` follows — while `rdf` is resolved, because that prefix is an +/// ordinary one a document may spell however it likes. +fn carries_data(reader: &NsReader<&[u8]>, tag: &BytesStart<'_>) -> bool { + tag.attributes().flatten().any(|attribute| { + let key = attribute.key.as_ref(); + if key == b"xmlns" || key.starts_with(b"xmlns:") { + return false; + } + let (resolved, _) = reader.resolver().resolve_attribute(attribute.key); + owned_namespace(&resolved).as_deref() != Some(NS_RDF.as_bytes()) + }) +} + +/// Write the whitespace that has been held back, if any. +/// +/// Every path that writes a real event calls this first, so held whitespace +/// reaches the output in the order it arrived — the only paths that do not are +/// the two that deliberately discard it, and both are the sites this rewrite +/// changes the document at. +fn flush(writer: &mut Writer>, held: &mut Vec) { + if held.is_empty() { + return; + } + let _ = writer.get_mut().write_all(&held[..]); + held.clear(); +} + +/// Put our block into the output, on a line of its own. +/// +/// The newline is written even when there is no block, so the bytes around the +/// injection point depend only on what DarkRoom holds and not on how many times +/// this has been run over the file. +fn write_block(writer: &mut Writer>, block: Option<&str>) { + // Straight into the buffer rather than as events, because this is a + // fragment we composed and already escaped; round-tripping it through the + // reader to produce events would be a parser in the middle of a serialiser. + // Writes to a `Vec` cannot fail. + let _ = writer.get_mut().write_all(b"\n"); + if let Some(block) = block { + let _ = writer.get_mut().write_all(block.as_bytes()); + let _ = writer.get_mut().write_all(b"\n"); + } +} + +/// The same start tag with every owned attribute taken off it. +/// +/// `None` where it carried none, which is the overwhelmingly common case and +/// the one where the original tag is passed through untouched — quoting style, +/// attribute order and the whitespace between them included. A tag this does +/// rebuild is one being modified anyway, so reformatting it is not a loss. +fn strip_owned_attributes( + reader: &NsReader<&[u8]>, + e: &BytesStart<'_>, +) -> Option> { + let owns_any = e.attributes().flatten().any(|a| { + let (resolved, local) = reader.resolver().resolve_attribute(a.key); + owner(owned_namespace(&resolved).as_deref(), local.as_ref()).is_some() + }); + if !owns_any { + return None; + } + + let mut kept = BytesStart::new(String::from_utf8_lossy(e.name().as_ref()).into_owned()); + for attribute in e.attributes().flatten() { + let (resolved, local) = reader.resolver().resolve_attribute(attribute.key); + if owner(owned_namespace(&resolved).as_deref(), local.as_ref()).is_some() { + continue; + } + // The value goes back exactly as it arrived: the reader hands over the + // raw, still-escaped bytes and this writes them verbatim, so an + // attribute we are not interested in survives its own escaping. + kept.push_attribute(attribute); + } + Some(kept) +} + +/// The `rdf:Description` holding everything DarkRoom has to say. +/// +/// `None` where it has nothing — a photograph whose every owned property is +/// empty produces no block at all, rather than an empty element that would +/// grow the file and mean nothing. +/// +/// Namespaces are declared on the block itself, in first-use order, and only +/// the ones actually used. Declaring them here rather than relying on the +/// document is what makes the block portable into any packet: see the module +/// documentation for why that beats injecting into an existing `Description`. +fn description_block(xmp: &Xmp, indent: &str) -> Option { + let inner = format!("{indent} "); + // `rdf` first and always: the block's own element needs it, whether or not + // any property inside uses a container. + let mut namespaces: Vec<(&'static str, &'static str)> = vec![("rdf", NS_RDF)]; + let mut body = String::new(); + + for property in PROPERTIES { + let values = xmp.values(property.field); + if values.is_empty() { + continue; + } + if !namespaces.iter().any(|(_, ns)| *ns == property.namespace) { + namespaces.push((property.prefix, property.namespace)); + } + write_property(&mut body, &inner, property, &values); + } + + if body.is_empty() { + return None; + } + + let mut out = format!("{indent}\n{body}{indent}"); + Some(out) +} + +/// One property, in the container its declared shape names. +/// +/// Reading tolerates any shape; writing does not improvise. A file other +/// applications have to read is not the place to be creative about which RDF +/// container a keyword list lives in. +fn write_property(out: &mut String, indent: &str, property: &Property, values: &[String]) { + let (prefix, local) = (property.prefix, property.local); + + match property.shape.container() { + // A simple property is one value by definition. Where a caller somehow + // holds several, the first is written and the rest are dropped rather + // than concatenated into a value nobody wrote. + None => { + if let Some(first) = values.first() { + let _ = writeln!( + out, + "{indent}<{prefix}:{local}>{}", + escape(first.as_str()) + ); + } + } + Some(container) => { + let _ = writeln!(out, "{indent}<{prefix}:{local}>"); + let _ = writeln!(out, "{indent} "); + for value in values { + // `x-default` on an `rdf:Alt` entry, and nothing on a `Bag` or + // a `Seq`. An alternative with no default is one a reader + // asking for "the caption" finds nothing in, which is how a + // caption goes missing in an application that never showed a + // language picker. + let lang = if matches!(property.shape, Shape::Alt) { + " xml:lang=\"x-default\"" + } else { + "" + }; + let _ = writeln!( + out, + "{indent} {}", + escape(value.as_str()) + ); + } + let _ = writeln!(out, "{indent} "); + let _ = writeln!(out, "{indent}"); + } + } +} + +/// The namespace a resolver bound this name to, as bytes we own. +/// +/// The twin of `read`'s helper. Duplicated rather than shared because the two +/// modules are the two halves of the format and neither should have to reach +/// into the other for a four-line match. +fn owned_namespace(resolved: &ResolveResult<'_>) -> Option> { + match resolved { + ResolveResult::Bound(ns) => Some(ns.0.to_vec()), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Rating; + + fn sample() -> Xmp { + Xmp { + rating: Some(Rating::Stars(4)), + label: Some("Yellow".into()), + keywords: vec!["puffin".into(), "Iceland".into()], + hierarchical_subjects: vec!["Places|Iceland".into()], + title: Some("Puffin on a cliff".into()), + description: Some("Látrabjarg, late evening.".into()), + creators: vec!["A. Photographer".into()], + copyright: Some("© 2026 A. Photographer".into()), + credit: Some("Puffin Pictures".into()), + usage_terms: Some("Editorial use only.".into()), + } + } + + /// A sidecar written by another application, carrying develop settings, a + /// comment and a processing instruction that mean nothing here. + const FOREIGN: &str = r#" + + + + + + 0, 0255, 255 + + + + +"#; + + #[test] + fn a_new_packet_reads_back_as_what_went_into_it() { + let before = sample(); + let after = Xmp::parse(&before.to_text()).unwrap(); + assert_eq!(after, before); + } + + #[test] + fn an_empty_record_still_produces_a_valid_packet() { + // Everything cleared is a state a user can reach, and the file that + // results has to be readable rather than empty. + let text = Xmp::default().to_text(); + assert!(Xmp::parse(&text).unwrap().is_empty()); + } + + #[test] + fn the_same_record_always_produces_the_same_bytes() { + // What lets a caller compare content instead of trusting a dirty flag, + // which is how a sidecar avoids syncing on every keystroke. + assert_eq!(sample().to_text(), sample().to_text()); + assert_eq!( + sample().rewrite(FOREIGN).unwrap(), + sample().rewrite(FOREIGN).unwrap() + ); + } + + #[test] + fn another_application_s_work_survives_a_rewrite() { + // The ownership rule, tested. Every one of these is somebody else's and + // none of them is in PROPERTIES. + let out = sample().rewrite(FOREIGN).unwrap(); + assert!(out.contains("crs:Exposure2012=\"+0.75\""), "{out}"); + assert!(out.contains("crs:Version=\"15.0\""), "{out}"); + assert!(out.contains("crs:ToneCurvePV2012"), "{out}"); + assert!(out.contains(""), "{out}"); + assert!(out.contains(""), "{out}"); + assert!(out.contains("x:xmptk=\"Adobe XMP Core 5.6\""), "{out}"); + } + + #[test] + fn an_owned_property_is_replaced_rather_than_joined() { + // The file said two stars and we say four. Leaving both in would make + // the next reader's answer depend on which it happened to meet first. + let out = sample().rewrite(FOREIGN).unwrap(); + assert!(!out.contains("xmp:Rating=\"2\""), "{out}"); + assert_eq!( + Xmp::parse(&out).unwrap().rating, + Some(Rating::Stars(4)), + "{out}" + ); + } + + #[test] + fn a_rewrite_round_trips_everything_it_owns() { + let out = sample().rewrite(FOREIGN).unwrap(); + assert_eq!(Xmp::parse(&out).unwrap(), sample()); + } + + #[test] + fn clearing_a_field_removes_it_from_the_file() { + // The second consequence of the ownership rule: the owned set is + // replaced wholesale, so "no rating" means the property goes. + let with = sample().rewrite(FOREIGN).unwrap(); + let cleared = Xmp { + rating: None, + ..sample() + }; + let out = cleared.rewrite(&with).unwrap(); + assert_eq!(Xmp::parse(&out).unwrap().rating, None, "{out}"); + assert!(out.contains("crs:Exposure2012"), "{out}"); + } + + #[test] + fn no_empty_description_is_left_behind() { + // The husk this crate would otherwise add to the file on every save: + // our own injected block is an `rdf:Description` holding only owned + // properties, so the next write strips it back to an empty element. + // Exactly one `Description` should survive here — theirs. + let once = sample().rewrite(FOREIGN).unwrap(); + let twice = sample().rewrite(&once).unwrap(); + assert_eq!( + twice.matches(" + + + +"#; + let out = Xmp::default().rewrite(theirs).unwrap(); + assert!(!out.contains("rdf:Description"), "{out}"); + assert!(Xmp::parse(&out).unwrap().is_empty()); + } + + #[test] + fn a_rewrite_is_idempotent() { + // Writing the same record twice must not accumulate blocks, which is + // the failure that turns a sidecar into a file that grows on every + // save. + let once = sample().rewrite(FOREIGN).unwrap(); + let twice = sample().rewrite(&once).unwrap(); + assert_eq!(once, twice); + } + + #[test] + fn an_owned_property_is_removed_whatever_prefix_it_wore() { + // Ownership is by namespace. A file calling the basic schema `foo` + // still has its rating replaced rather than duplicated. + let odd = r#" + + 1 + + "#; + let out = sample().rewrite(odd).unwrap(); + assert!(!out.contains("foo:Rating"), "{out}"); + assert_eq!(Xmp::parse(&out).unwrap().rating, Some(Rating::Stars(4))); + } + + #[test] + fn a_property_of_somebody_else_s_with_one_of_our_local_names_is_left_alone() { + // The other direction of the same rule, and the one that would be data + // loss: `subject` in a namespace that is not Dublin Core is not ours. + let theirs = r#" + + not ours to delete + + "#; + let out = sample().rewrite(theirs).unwrap(); + assert!(out.contains("not ours to delete"), "{out}"); + } + + #[test] + fn an_empty_rdf_root_gains_somewhere_to_put_things() { + let empty = r#" + +"#; + let out = sample().rewrite(empty).unwrap(); + assert_eq!(Xmp::parse(&out).unwrap(), sample(), "{out}"); + } + + #[test] + fn a_missing_sidecar_is_a_new_one() { + // The caller that read a file that was not there holds an empty string, + // and should not have to special-case it. + assert_eq!(sample().rewrite("").unwrap(), sample().to_text()); + assert_eq!(sample().rewrite(" \n").unwrap(), sample().to_text()); + } + + #[test] + fn xml_that_is_not_a_packet_is_refused_rather_than_overwritten() { + // Refusing is the point: the alternative is replacing a file whose + // contents were not understood. + assert_eq!( + sample().rewrite("Warm"), + Err(XmpError::NotXmp) + ); + } + + #[test] + fn a_value_needing_escaping_survives_the_round_trip() { + let awkward = Xmp { + keywords: vec!["Bells & whistles".into(), "".into()], + title: Some("\"quoted\"".into()), + ..Xmp::default() + }; + let out = Xmp::parse(&awkward.to_text()).unwrap(); + assert_eq!(out, awkward); + } + + #[test] + fn a_rejection_survives_the_round_trip() { + let rejected = Xmp { + rating: Some(Rating::Rejected), + ..Xmp::default() + }; + let out = Xmp::parse(&rejected.to_text()).unwrap(); + assert_eq!(out.rating, Some(Rating::Rejected)); + } + + #[test] + fn a_label_this_build_does_not_recognise_survives_the_round_trip() { + let renamed = Xmp { + label: Some("Second Pass".into()), + ..Xmp::default() + }; + assert_eq!(Xmp::parse(&renamed.to_text()).unwrap(), renamed); + } +} diff --git a/core/dr-xmp/tests/round_trip.rs b/core/dr-xmp/tests/round_trip.rs new file mode 100644 index 0000000..863c2b8 --- /dev/null +++ b/core/dr-xmp/tests/round_trip.rs @@ -0,0 +1,224 @@ +//! TRACES: FR-CAT-13 +//! The interoperability contract, exercised from outside the crate. +//! +//! The unit tests beside each module check the pieces. This checks the promise +//! FR-CAT-13 actually makes to a photographer, through the public API only and +//! with no knowledge of how any of it works: what DarkRoom writes, another +//! application can read; what another application wrote, DarkRoom can read; and +//! what DarkRoom does not understand, it does not destroy. + +use dr_types::ColourLabel; +use dr_xmp::{reconcile, Field, Precedence, Rating, Xmp, XmpError, PROPERTIES}; + +/// Everything the requirement names, in one record. +fn everything() -> Xmp { + let mut xmp = Xmp { + rating: Some(Rating::Stars(4)), + keywords: vec!["puffin".into(), "Iceland".into(), "Bells & whistles".into()], + hierarchical_subjects: vec!["Places|Iceland|Látrabjarg".into()], + title: Some("Puffin on a cliff".into()), + description: Some("Late evening, wind off the sea.".into()), + creators: vec!["A. Photographer".into(), "An Assistant".into()], + copyright: Some("© 2026 A. Photographer".into()), + credit: Some("Puffin Pictures".into()), + usage_terms: Some("Editorial use only. No .".into()), + label: None, + }; + xmp.set_colour(Some(ColourLabel::Green)); + xmp +} + +/// A sidecar as another application left it: its own develop settings, its own +/// structures, and its own opinion about the rating. +const SOMEBODY_ELSES: &str = r#" + + + + + + + 0, 0 + 255, 255 + + + + + gannet + + + + + +"#; + +#[test] +fn what_was_written_is_what_reads_back() { + // The round trip the requirement asks for, over every field it names. + let before = everything(); + let after = Xmp::parse(&before.to_text()).expect("our own packet must read"); + assert_eq!(after, before); +} + +#[test] +fn the_round_trip_survives_a_second_pass() { + // Parse, write, parse, write: a format that drifts on the second pass is a + // format that drifts once per sync. + let once = everything().to_text(); + let twice = Xmp::parse(&once).unwrap().to_text(); + assert_eq!(once, twice); +} + +#[test] +fn what_was_written_into_another_applications_file_reads_back() { + // The same round trip, through the path that actually runs in the field: + // there was already a sidecar there. + let before = everything(); + let text = before.rewrite(SOMEBODY_ELSES).expect("a packet to rewrite"); + assert_eq!(Xmp::parse(&text).unwrap(), before); +} + +#[test] +fn nothing_of_theirs_is_lost_when_we_write_ours() { + // The ownership rule, from the outside. Every assertion here is somebody + // else's work, and none of it is a property DarkRoom claims. + let text = everything().rewrite(SOMEBODY_ELSES).unwrap(); + + for theirs in [ + "crs:Version=\"15.0\"", + "crs:Exposure2012=\"+0.75\"", + "crs:ProcessVersion=\"11.0\"", + "tiff:Orientation=\"6\"", + "crs:ToneCurvePV2012", + "0, 0", + "255, 255", + "", + "x:xmptk=\"Adobe XMP Core 5.6-c140\"", + "", + ] { + assert!(text.contains(theirs), "lost {theirs:?} from:\n{text}"); + } +} + +#[test] +fn theirs_is_replaced_rather_than_left_beside_ours() { + // The other half of ownership. Their rating and label and keyword are gone, + // because those are ours to state; leaving both copies in would make the + // next reader's answer depend on which it met first. + let text = everything().rewrite(SOMEBODY_ELSES).unwrap(); + assert!(!text.contains("xmp:Rating=\"2\""), "{text}"); + assert!(!text.contains("xmp:Label=\"Red\""), "{text}"); + assert!(!text.contains("gannet"), "{text}"); + + let back = Xmp::parse(&text).unwrap(); + assert_eq!(back.rating, Some(Rating::Stars(4))); + assert_eq!(back.colour(), Some(ColourLabel::Green)); +} + +#[test] +fn writing_the_same_thing_twice_changes_nothing() { + // A sidecar that grew a block on every save would double in size across a + // week of culling, and would sync every time. + let once = everything().rewrite(SOMEBODY_ELSES).unwrap(); + let twice = everything().rewrite(&once).unwrap(); + assert_eq!(once, twice); +} + +#[test] +fn a_photograph_with_no_sidecar_gets_a_whole_one() { + // The caller that looked for a file and found none holds an empty string. + let text = everything().rewrite("").unwrap(); + assert_eq!(Xmp::parse(&text).unwrap(), everything()); +} + +#[test] +fn a_file_that_is_not_a_packet_is_refused_rather_than_replaced() { + // Overwriting a file whose contents were not understood is the one failure + // a photographer cannot recover from. + assert_eq!( + everything().rewrite("not xmp at all"), + Err(XmpError::NotXmp) + ); + assert!(matches!( + Xmp::parse(""), + Err(XmpError::NotXml(_)) + )); +} + +#[test] +fn reading_their_file_finds_the_work_they_did_in_it() { + // The migration case: a library culled and keyworded somewhere else. + let theirs = Xmp::parse(SOMEBODY_ELSES).unwrap(); + assert_eq!(theirs.rating, Some(Rating::Stars(2))); + assert_eq!(theirs.colour(), Some(ColourLabel::Red)); + assert_eq!(theirs.keywords, ["gannet"]); +} + +#[test] +fn a_disagreement_is_carried_out_conservatively_and_reported() { + // The recorded precedence decision, end to end: their keyword joins ours, + // our rating stands because nothing here can order the two edits, and the + // caller is told which field disagreed so it can offer the reload. + let mine = Xmp { + rating: Some(Rating::Stars(5)), + keywords: vec!["puffin".into()], + ..Xmp::default() + }; + let theirs = Xmp::parse(SOMEBODY_ELSES).unwrap(); + + let out = reconcile(&mine, &theirs, Precedence::Catalog); + assert_eq!(out.merged.keywords, ["puffin", "gannet"]); + assert_eq!(out.merged.rating, Some(Rating::Stars(5))); + assert_eq!(out.conflicts, [Field::Rating]); + assert_eq!( + out.merged.colour(), + Some(ColourLabel::Red), + "a label we never set is not a disagreement, it is an arrival" + ); + + // And the other way, which only a person's explicit request reaches. + let reload = reconcile(&mine, &theirs, Precedence::Sidecar); + assert_eq!(reload.merged.rating, Some(Rating::Stars(2))); + assert_eq!( + reload.merged.keywords, + ["puffin", "gannet"], + "a reload is still not a reason to throw away a keyword" + ); +} + +#[test] +fn every_property_the_requirement_names_is_owned() { + // The list FR-CAT-13 gives, checked against the table that decides what a + // rewrite may touch. A property missing here is one this application can + // neither read nor hand back. + let names: Vec = PROPERTIES + .iter() + .map(|p| format!("{}:{}", p.prefix, p.local)) + .collect(); + for required in [ + "dc:subject", + "lr:hierarchicalSubject", + "xmp:Rating", + "xmp:Label", + "dc:title", + "dc:description", + "dc:creator", + "dc:rights", + "photoshop:Credit", + "xmpRights:UsageTerms", + ] { + assert!( + names.iter().any(|n| n == required), + "{required} is not in PROPERTIES" + ); + } +} diff --git a/docs/outstanding.md b/docs/outstanding.md index 18d056a..2ec0e2b 100644 --- a/docs/outstanding.md +++ b/docs/outstanding.md @@ -305,14 +305,31 @@ well optimised — ETag pruning under FR-NC-4 turns an unchanged 50k library int gap is narrower than it reads. It is the *first* build against a large remote library that pays, and that is the moment a new user meets. -**FR-CAT-13 — XMP interoperability, untagged and not met.** Read and write standard XMP sidecars. -Its single tag sat on `keywords.rs`, which stores keywords in the catalog and mentions `dc:subject` -in a comment about what a keyword's text is *for*; no XMP is parsed or written anywhere in the tree, -and `dr-export`'s metadata module says so about its own half ("neither is read by `dr-decode` -today"). The tag has been removed — `dr-preset-xmp` is not the counter-example it looks like, being -a reader of Lightroom *presets* under FR-DEV-6, which is a different file and a different purpose. -Listed here rather than silently, because a tag makes a gap invisible and this one is load-bearing -for interoperating with the editors FR-CAT-14 imports from. +**FR-CAT-13 — XMP interoperability, built but not yet wired.** `core/dr-xmp` now reads and writes +standard XMP sidecars: `dc:subject` and `lr:hierarchicalSubject`, `xmp:Rating` and `xmp:Label`, and +the IPTC core fields, in both the attribute and the element form and whatever RDF container a file +happened to use. It states the ownership rule in one place — DarkRoom owns the properties in +`PROPERTIES` and nothing else in the document, identified by namespace URI rather than by prefix — +and enforces it by rewriting a packet event by event rather than serialising over it, so another +application's `crs:` settings, comments and processing instructions survive a write byte for byte. + +**What remains is the wiring, and it is the larger half.** Nothing above the crate calls it: no scan +finds a `.xmp` beside a raw, no catalog row is populated from one, no edit writes one back, and the +external-modification detection the requirement also asks for does not exist. Two smaller gaps go +with it — GPS is not carried (`exif:GPSLatitude` is a format of its own, and `dr-decode` produces no +location for it to carry yet, which `dr-export`'s metadata module says about its own half), and the +filename convention is left to the caller, because Lightroom writes `IMG_0001.xmp` and darktable +writes `IMG_0001.CR3.xmp` and finding a file is not this crate's business. + +The precedence question is settled conservatively rather than fully: keywords union, following +`dr_catalog::merge`, and every other field is taken only where DarkRoom holds none, following +`Version::merge`'s judgement rule — because a standard XMP carries no revision and no device, so +FR-NC-9's ordering cannot be performed against it. What is *not* settled, and is written down in the +crate rather than guessed at, is when a reload may happen without asking; see the module +documentation's "The open question". + +`dr-preset-xmp` remains what it always was and is still not the counter-example it looks like: a +reader of Lightroom *presets* under FR-DEV-6, a different file for a different purpose. --- diff --git a/docs/traceability.md b/docs/traceability.md index e8a9e09..90ca634 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -9,17 +9,17 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 337 | -| TRACES tags found | 1175 | -| Requirements defined | 181 | +| Source files scanned | 340 | +| TRACES tags found | 1135 | +| Requirements defined | 180 | | Requirements covered | 128 | -| **Coverage** | **70.7%** (128/181) | +| **Coverage** | **71.1%** (128/180) | ### By type | Type | Covered | Defined | |---|---|---| -| FR | 95 | 126 | +| FR | 95 | 125 | | NFR | 30 | 49 | | R | 3 | 6 | @@ -37,6 +37,7 @@ _None._ | FR-CAT-10 | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-types/src/settings.rs:206`](../core/dr-types/src/settings.rs#L206), [`platform/dr-plat/src/storage.rs:287`](../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:586`](../platform/dr-plat/src/storage.rs#L586), [`platform/dr-plat/src/volumes.rs:1`](../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../platform/dr-plat/src/volumes.rs#L62), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:336`](../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1304`](../ui/dr-ui/src/lib.rs#L1304), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:1028`](../ui/dr-ui/ui/library.slint#L1028), [`ui/dr-ui/ui/library.slint:1244`](../ui/dr-ui/ui/library.slint#L1244), [`ui/dr-ui/ui/library.slint:974`](../ui/dr-ui/ui/library.slint#L974) | | FR-CAT-11 | [`core/dr-catalog/src/dedup.rs:1`](../core/dr-catalog/src/dedup.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:392`](../core/dr-ingest/src/lib.rs#L392), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1304`](../ui/dr-ui/src/lib.rs#L1304), [`ui/dr-ui/src/library.rs:183`](../ui/dr-ui/src/library.rs#L183), [`ui/dr-ui/src/library.rs:2819`](../ui/dr-ui/src/library.rs#L2819), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | | FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:120`](../core/dr-pipeline/src/sidecar.rs#L120) | +| FR-CAT-13 | [`core/dr-xmp/src/lib.rs:1`](../core/dr-xmp/src/lib.rs#L1), [`core/dr-xmp/src/lib.rs:331`](../core/dr-xmp/src/lib.rs#L331), [`core/dr-xmp/src/lib.rs:442`](../core/dr-xmp/src/lib.rs#L442), [`core/dr-xmp/src/lib.rs:535`](../core/dr-xmp/src/lib.rs#L535), [`core/dr-xmp/src/lib.rs:733`](../core/dr-xmp/src/lib.rs#L733), [`core/dr-xmp/src/lib.rs:746`](../core/dr-xmp/src/lib.rs#L746), [`core/dr-xmp/src/read.rs:1`](../core/dr-xmp/src/read.rs#L1), [`core/dr-xmp/src/write.rs:1`](../core/dr-xmp/src/write.rs#L1), [`core/dr-xmp/tests/round_trip.rs:1`](../core/dr-xmp/tests/round_trip.rs#L1) | | FR-CAT-15 | [`core/dr-catalog/src/schema.rs:813`](../core/dr-catalog/src/schema.rs#L813), [`core/dr-catalog/src/trash.rs:1`](../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:457`](../core/dr-sync-nextcloud/src/lib.rs#L457), [`core/dr-sync/src/lib.rs:135`](../core/dr-sync/src/lib.rs#L135), [`core/dr-sync/src/scan.rs:604`](../core/dr-sync/src/scan.rs#L604), [`core/dr-sync/src/scan.rs:82`](../core/dr-sync/src/scan.rs#L82), [`core/dr-thumbs/src/lib.rs:376`](../core/dr-thumbs/src/lib.rs#L376), [`core/dr-types/src/place.rs:91`](../core/dr-types/src/place.rs#L91), [`ui/dr-ui/src/collections_ui.rs:1283`](../ui/dr-ui/src/collections_ui.rs#L1283), [`ui/dr-ui/src/collections_ui.rs:2189`](../ui/dr-ui/src/collections_ui.rs#L2189), [`ui/dr-ui/src/library.rs:183`](../ui/dr-ui/src/library.rs#L183), [`ui/dr-ui/src/library.rs:200`](../ui/dr-ui/src/library.rs#L200), [`ui/dr-ui/src/library.rs:249`](../ui/dr-ui/src/library.rs#L249), [`ui/dr-ui/src/library.rs:4440`](../ui/dr-ui/src/library.rs#L4440), [`ui/dr-ui/src/library.rs:4474`](../ui/dr-ui/src/library.rs#L4474), [`ui/dr-ui/src/library_ui.rs:238`](../ui/dr-ui/src/library_ui.rs#L238), [`ui/dr-ui/src/library_ui.rs:838`](../ui/dr-ui/src/library_ui.rs#L838), [`ui/dr-ui/src/trash.rs:1`](../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:621`](../ui/dr-ui/ui/collections.slint#L621) | | FR-CAT-1a | [`core/dr-catalog/src/walk.rs:1`](../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:56`](../core/dr-types/src/lib.rs#L56), [`platform/dr-plat/src/storage.rs:1`](../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../platform/dr-plat/src/storage.rs#L216), [`platform/dr-plat/src/storage.rs:46`](../platform/dr-plat/src/storage.rs#L46) | | FR-CAT-2 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1) | @@ -52,8 +53,8 @@ _None._ | FR-CULL-11 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:614`](../core/dr-catalog/src/schema.rs#L614), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/library.rs:295`](../ui/dr-ui/src/library.rs#L295), [`ui/dr-ui/src/library.rs:325`](../ui/dr-ui/src/library.rs#L325), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | | FR-CULL-12 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:395`](../core/dr-catalog/src/schema.rs#L395), [`core/dr-catalog/src/schema.rs:614`](../core/dr-catalog/src/schema.rs#L614), [`ui/dr-ui/src/identity.rs:1`](../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../ui/dr-ui/ui/identity.slint#L1) | | FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../core/dr-decode/src/preview.rs#L148), [`ui/dr-ui/src/import.rs:463`](../ui/dr-ui/src/import.rs#L463) | -| FR-CULL-3 | [`core/dr-gpu/src/focus.rs:154`](../core/dr-gpu/src/focus.rs#L154), [`core/dr-gpu/src/focus.rs:186`](../core/dr-gpu/src/focus.rs#L186), [`core/dr-gpu/src/focus.rs:1`](../core/dr-gpu/src/focus.rs#L1), [`core/dr-gpu/src/focus.rs:317`](../core/dr-gpu/src/focus.rs#L317), [`core/dr-gpu/src/raw_histogram.rs:129`](../core/dr-gpu/src/raw_histogram.rs#L129), [`core/dr-gpu/src/raw_histogram.rs:1`](../core/dr-gpu/src/raw_histogram.rs#L1), [`core/dr-gpu/src/raw_histogram.rs:272`](../core/dr-gpu/src/raw_histogram.rs#L272), [`core/dr-gpu/src/raw_histogram.rs:407`](../core/dr-gpu/src/raw_histogram.rs#L407), [`core/dr-gpu/src/shaders/focus_peak.wgsl:1`](../core/dr-gpu/src/shaders/focus_peak.wgsl#L1), [`core/dr-gpu/src/shaders/raw_histogram.wgsl:1`](../core/dr-gpu/src/shaders/raw_histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:3411`](../ui/dr-ui/src/develop.rs#L3411), [`ui/dr-ui/src/develop.rs:3424`](../ui/dr-ui/src/develop.rs#L3424), [`ui/dr-ui/src/develop.rs:3469`](../ui/dr-ui/src/develop.rs#L3469), [`ui/dr-ui/src/develop.rs:3480`](../ui/dr-ui/src/develop.rs#L3480), [`ui/dr-ui/src/develop.rs:3486`](../ui/dr-ui/src/develop.rs#L3486), [`ui/dr-ui/src/develop.rs:3503`](../ui/dr-ui/src/develop.rs#L3503), [`ui/dr-ui/src/develop.rs:7069`](../ui/dr-ui/src/develop.rs#L7069), [`ui/dr-ui/src/develop.rs:7133`](../ui/dr-ui/src/develop.rs#L7133), [`ui/dr-ui/src/develop.rs:7156`](../ui/dr-ui/src/develop.rs#L7156), [`ui/dr-ui/src/develop.rs:763`](../ui/dr-ui/src/develop.rs#L763), [`ui/dr-ui/src/develop.rs:783`](../ui/dr-ui/src/develop.rs#L783), [`ui/dr-ui/src/develop.rs:789`](../ui/dr-ui/src/develop.rs#L789), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/histogram.rs:208`](../ui/dr-ui/src/histogram.rs#L208), [`ui/dr-ui/src/histogram.rs:228`](../ui/dr-ui/src/histogram.rs#L228), [`ui/dr-ui/src/histogram.rs:272`](../ui/dr-ui/src/histogram.rs#L272), [`ui/dr-ui/src/histogram.rs:544`](../ui/dr-ui/src/histogram.rs#L544), [`ui/dr-ui/src/histogram.rs:565`](../ui/dr-ui/src/histogram.rs#L565), [`ui/dr-ui/src/histogram.rs:593`](../ui/dr-ui/src/histogram.rs#L593), [`ui/dr-ui/src/histogram.rs:621`](../ui/dr-ui/src/histogram.rs#L621), [`ui/dr-ui/src/histogram.rs:658`](../ui/dr-ui/src/histogram.rs#L658), [`ui/dr-ui/src/lib.rs:1637`](../ui/dr-ui/src/lib.rs#L1637), [`ui/dr-ui/src/lib.rs:1715`](../ui/dr-ui/src/lib.rs#L1715), [`ui/dr-ui/src/lib.rs:1814`](../ui/dr-ui/src/lib.rs#L1814), [`ui/dr-ui/src/lib.rs:1855`](../ui/dr-ui/src/lib.rs#L1855), [`ui/dr-ui/src/lib.rs:3125`](../ui/dr-ui/src/lib.rs#L3125), [`ui/dr-ui/src/lib.rs:383`](../ui/dr-ui/src/lib.rs#L383), [`ui/dr-ui/src/peaking.rs:1`](../ui/dr-ui/src/peaking.rs#L1), [`ui/dr-ui/ui/app.slint:1886`](../ui/dr-ui/ui/app.slint#L1886), [`ui/dr-ui/ui/app.slint:2505`](../ui/dr-ui/ui/app.slint#L2505), [`ui/dr-ui/ui/app.slint:95`](../ui/dr-ui/ui/app.slint#L95), [`ui/dr-ui/ui/peaking.slint:1`](../ui/dr-ui/ui/peaking.slint#L1), [`ui/dr-ui/ui/peaking.slint:25`](../ui/dr-ui/ui/peaking.slint#L25), [`ui/dr-ui/ui/peaking.slint:56`](../ui/dr-ui/ui/peaking.slint#L56) | -| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:137`](../core/dr-pipeline/src/sidecar.rs#L137), [`ui/dr-ui/src/library.rs:254`](../ui/dr-ui/src/library.rs#L254), [`ui/dr-ui/src/library.rs:500`](../ui/dr-ui/src/library.rs#L500) | +| FR-CULL-3 | [`core/dr-gpu/src/focus.rs:154`](../core/dr-gpu/src/focus.rs#L154), [`core/dr-gpu/src/focus.rs:186`](../core/dr-gpu/src/focus.rs#L186), [`core/dr-gpu/src/focus.rs:1`](../core/dr-gpu/src/focus.rs#L1), [`core/dr-gpu/src/focus.rs:317`](../core/dr-gpu/src/focus.rs#L317), [`core/dr-gpu/src/raw_histogram.rs:129`](../core/dr-gpu/src/raw_histogram.rs#L129), [`core/dr-gpu/src/raw_histogram.rs:1`](../core/dr-gpu/src/raw_histogram.rs#L1), [`core/dr-gpu/src/raw_histogram.rs:272`](../core/dr-gpu/src/raw_histogram.rs#L272), [`core/dr-gpu/src/raw_histogram.rs:407`](../core/dr-gpu/src/raw_histogram.rs#L407), [`core/dr-gpu/src/shaders/focus_peak.wgsl:1`](../core/dr-gpu/src/shaders/focus_peak.wgsl#L1), [`core/dr-gpu/src/shaders/raw_histogram.wgsl:1`](../core/dr-gpu/src/shaders/raw_histogram.wgsl#L1), [`ui/dr-ui/src/develop.rs:3261`](../ui/dr-ui/src/develop.rs#L3261), [`ui/dr-ui/src/develop.rs:3274`](../ui/dr-ui/src/develop.rs#L3274), [`ui/dr-ui/src/develop.rs:3319`](../ui/dr-ui/src/develop.rs#L3319), [`ui/dr-ui/src/develop.rs:3330`](../ui/dr-ui/src/develop.rs#L3330), [`ui/dr-ui/src/develop.rs:3336`](../ui/dr-ui/src/develop.rs#L3336), [`ui/dr-ui/src/develop.rs:3353`](../ui/dr-ui/src/develop.rs#L3353), [`ui/dr-ui/src/develop.rs:6919`](../ui/dr-ui/src/develop.rs#L6919), [`ui/dr-ui/src/develop.rs:6983`](../ui/dr-ui/src/develop.rs#L6983), [`ui/dr-ui/src/develop.rs:7006`](../ui/dr-ui/src/develop.rs#L7006), [`ui/dr-ui/src/develop.rs:763`](../ui/dr-ui/src/develop.rs#L763), [`ui/dr-ui/src/develop.rs:783`](../ui/dr-ui/src/develop.rs#L783), [`ui/dr-ui/src/develop.rs:789`](../ui/dr-ui/src/develop.rs#L789), [`ui/dr-ui/src/histogram.rs:1`](../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/histogram.rs:208`](../ui/dr-ui/src/histogram.rs#L208), [`ui/dr-ui/src/histogram.rs:228`](../ui/dr-ui/src/histogram.rs#L228), [`ui/dr-ui/src/histogram.rs:272`](../ui/dr-ui/src/histogram.rs#L272), [`ui/dr-ui/src/histogram.rs:544`](../ui/dr-ui/src/histogram.rs#L544), [`ui/dr-ui/src/histogram.rs:565`](../ui/dr-ui/src/histogram.rs#L565), [`ui/dr-ui/src/histogram.rs:593`](../ui/dr-ui/src/histogram.rs#L593), [`ui/dr-ui/src/histogram.rs:621`](../ui/dr-ui/src/histogram.rs#L621), [`ui/dr-ui/src/histogram.rs:658`](../ui/dr-ui/src/histogram.rs#L658), [`ui/dr-ui/src/lib.rs:1637`](../ui/dr-ui/src/lib.rs#L1637), [`ui/dr-ui/src/lib.rs:1715`](../ui/dr-ui/src/lib.rs#L1715), [`ui/dr-ui/src/lib.rs:1814`](../ui/dr-ui/src/lib.rs#L1814), [`ui/dr-ui/src/lib.rs:1855`](../ui/dr-ui/src/lib.rs#L1855), [`ui/dr-ui/src/lib.rs:3125`](../ui/dr-ui/src/lib.rs#L3125), [`ui/dr-ui/src/lib.rs:383`](../ui/dr-ui/src/lib.rs#L383), [`ui/dr-ui/src/peaking.rs:1`](../ui/dr-ui/src/peaking.rs#L1), [`ui/dr-ui/ui/app.slint:1883`](../ui/dr-ui/ui/app.slint#L1883), [`ui/dr-ui/ui/app.slint:2502`](../ui/dr-ui/ui/app.slint#L2502), [`ui/dr-ui/ui/app.slint:95`](../ui/dr-ui/ui/app.slint#L95), [`ui/dr-ui/ui/peaking.slint:1`](../ui/dr-ui/ui/peaking.slint#L1), [`ui/dr-ui/ui/peaking.slint:25`](../ui/dr-ui/ui/peaking.slint#L25), [`ui/dr-ui/ui/peaking.slint:56`](../ui/dr-ui/ui/peaking.slint#L56) | +| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:137`](../core/dr-pipeline/src/sidecar.rs#L137), [`core/dr-xmp/src/lib.rs:442`](../core/dr-xmp/src/lib.rs#L442), [`ui/dr-ui/src/library.rs:254`](../ui/dr-ui/src/library.rs#L254), [`ui/dr-ui/src/library.rs:500`](../ui/dr-ui/src/library.rs#L500) | | FR-CULL-5 | [`core/dr-catalog/src/bursts.rs:1`](../core/dr-catalog/src/bursts.rs#L1), [`core/dr-catalog/src/schema.rs:438`](../core/dr-catalog/src/schema.rs#L438), [`ui/dr-ui/src/bursts.rs:1`](../ui/dr-ui/src/bursts.rs#L1), [`ui/dr-ui/src/library.rs:210`](../ui/dr-ui/src/library.rs#L210), [`ui/dr-ui/src/library.rs:5853`](../ui/dr-ui/src/library.rs#L5853) | | FR-CULL-8 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:506`](../core/dr-catalog/src/schema.rs#L506), [`core/dr-catalog/src/schema.rs:573`](../core/dr-catalog/src/schema.rs#L573), [`core/dr-catalog/src/schema.rs:614`](../core/dr-catalog/src/schema.rs#L614), [`core/dr-face/src/align.rs:291`](../core/dr-face/src/align.rs#L291), [`ui/dr-ui/examples/face_native.rs:1`](../ui/dr-ui/examples/face_native.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/faces.rs:330`](../ui/dr-ui/src/faces.rs#L330), [`ui/dr-ui/src/faces.rs:346`](../ui/dr-ui/src/faces.rs#L346), [`ui/dr-ui/src/identity_ui.rs:540`](../ui/dr-ui/src/identity_ui.rs#L540), [`ui/dr-ui/src/lib.rs:1256`](../ui/dr-ui/src/lib.rs#L1256), [`ui/dr-ui/src/library.rs:3293`](../ui/dr-ui/src/library.rs#L3293), [`ui/dr-ui/src/library.rs:3385`](../ui/dr-ui/src/library.rs#L3385), [`ui/dr-ui/src/library.rs:3606`](../ui/dr-ui/src/library.rs#L3606), [`ui/dr-ui/src/library.rs:3742`](../ui/dr-ui/src/library.rs#L3742), [`ui/dr-ui/src/library.rs:3778`](../ui/dr-ui/src/library.rs#L3778), [`ui/dr-ui/ui/settings.slint:427`](../ui/dr-ui/ui/settings.slint#L427), [`ui/dr-ui/ui/settings.slint:82`](../ui/dr-ui/ui/settings.slint#L82) | | FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:614`](../core/dr-catalog/src/schema.rs#L614), [`core/dr-face/src/assign.rs:1`](../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../core/dr-face/src/neighbours.rs#L1), [`core/dr-types/src/settings.rs:117`](../core/dr-types/src/settings.rs#L117), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/ui/identity.slint:283`](../ui/dr-ui/ui/identity.slint#L283) | @@ -106,8 +107,8 @@ _None._ | FR-NC-7 | [`core/dr-catalog/src/face_shard.rs:1`](../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:105`](../core/dr-sync-nextcloud/src/lib.rs#L105), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library.rs:3883`](../ui/dr-ui/src/library.rs#L3883), [`ui/dr-ui/src/library_ui.rs:4235`](../ui/dr-ui/src/library_ui.rs#L4235), [`ui/dr-ui/ui/settings.slint:395`](../ui/dr-ui/ui/settings.slint#L395) | | FR-NC-7a | [`core/dr-ingest/src/layout.rs:1`](../core/dr-ingest/src/layout.rs#L1), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`core/dr-sync/src/upload.rs:40`](../core/dr-sync/src/upload.rs#L40), [`core/dr-types/src/settings.rs:206`](../core/dr-types/src/settings.rs#L206), [`ui/dr-ui/src/import.rs:1`](../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1304`](../ui/dr-ui/src/lib.rs#L1304), [`ui/dr-ui/ui/import.slint:5`](../ui/dr-ui/ui/import.slint#L5) | | FR-NC-7b | [`core/dr-ingest/src/lib.rs:733`](../core/dr-ingest/src/lib.rs#L733), [`core/dr-sync/src/upload.rs:1`](../core/dr-sync/src/upload.rs#L1), [`ui/dr-ui/src/import.rs:122`](../ui/dr-ui/src/import.rs#L122), [`ui/dr-ui/src/import.rs:336`](../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import.rs:584`](../ui/dr-ui/src/import.rs#L584), [`ui/dr-ui/src/import.rs:97`](../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1304`](../ui/dr-ui/src/lib.rs#L1304) | -| FR-NC-8 | [`core/dr-catalog/src/rating.rs:204`](../core/dr-catalog/src/rating.rs#L204), [`core/dr-catalog/src/rating.rs:66`](../core/dr-catalog/src/rating.rs#L66), [`core/dr-catalog/src/rating.rs:900`](../core/dr-catalog/src/rating.rs#L900), [`core/dr-catalog/src/schema.rs:155`](../core/dr-catalog/src/schema.rs#L155), [`core/dr-pipeline/src/sidecar.rs:120`](../core/dr-pipeline/src/sidecar.rs#L120), [`core/dr-pipeline/src/sidecar.rs:2574`](../core/dr-pipeline/src/sidecar.rs#L2574), [`core/dr-pipeline/src/sidecar.rs:619`](../core/dr-pipeline/src/sidecar.rs#L619), [`core/dr-pipeline/src/sidecar.rs:649`](../core/dr-pipeline/src/sidecar.rs#L649), [`core/dr-pipeline/src/sidecar.rs:94`](../core/dr-pipeline/src/sidecar.rs#L94), [`ui/dr-ui/src/lib.rs:2102`](../ui/dr-ui/src/lib.rs#L2102), [`ui/dr-ui/src/library.rs:1045`](../ui/dr-ui/src/library.rs#L1045), [`ui/dr-ui/src/library.rs:2310`](../ui/dr-ui/src/library.rs#L2310), [`ui/dr-ui/src/library.rs:500`](../ui/dr-ui/src/library.rs#L500), [`ui/dr-ui/src/library.rs:7312`](../ui/dr-ui/src/library.rs#L7312), [`ui/dr-ui/src/library.rs:755`](../ui/dr-ui/src/library.rs#L755), [`ui/dr-ui/src/library_ui.rs:554`](../ui/dr-ui/src/library_ui.rs#L554), [`ui/dr-ui/src/presets.rs:288`](../ui/dr-ui/src/presets.rs#L288), [`ui/dr-ui/src/presets.rs:335`](../ui/dr-ui/src/presets.rs#L335) | -| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:204`](../core/dr-catalog/src/rating.rs#L204), [`core/dr-catalog/src/rating.rs:66`](../core/dr-catalog/src/rating.rs#L66), [`core/dr-catalog/src/rating.rs:900`](../core/dr-catalog/src/rating.rs#L900), [`core/dr-catalog/src/schema.rs:155`](../core/dr-catalog/src/schema.rs#L155), [`core/dr-catalog/src/schema.rs:532`](../core/dr-catalog/src/schema.rs#L532), [`core/dr-catalog/src/schema.rs:728`](../core/dr-catalog/src/schema.rs#L728), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:158`](../core/dr-pipeline/src/sidecar.rs#L158), [`core/dr-pipeline/src/sidecar.rs:185`](../core/dr-pipeline/src/sidecar.rs#L185), [`core/dr-pipeline/src/sidecar.rs:2340`](../core/dr-pipeline/src/sidecar.rs#L2340), [`core/dr-pipeline/src/sidecar.rs:2574`](../core/dr-pipeline/src/sidecar.rs#L2574), [`core/dr-pipeline/src/sidecar.rs:354`](../core/dr-pipeline/src/sidecar.rs#L354), [`core/dr-pipeline/src/sidecar.rs:452`](../core/dr-pipeline/src/sidecar.rs#L452), [`core/dr-pipeline/src/sidecar.rs:619`](../core/dr-pipeline/src/sidecar.rs#L619), [`core/dr-pipeline/src/sidecar.rs:649`](../core/dr-pipeline/src/sidecar.rs#L649), [`core/dr-pipeline/src/spot.rs:245`](../core/dr-pipeline/src/spot.rs#L245), [`core/dr-pipeline/tests/mask_sidecar.rs:1055`](../core/dr-pipeline/tests/mask_sidecar.rs#L1055), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-sync/src/scan.rs:31`](../core/dr-sync/src/scan.rs#L31), [`core/dr-sync/src/scan.rs:45`](../core/dr-sync/src/scan.rs#L45), [`core/dr-sync/src/scan.rs:912`](../core/dr-sync/src/scan.rs#L912), [`ui/dr-ui/src/derived_sync.rs:585`](../ui/dr-ui/src/derived_sync.rs#L585), [`ui/dr-ui/src/library.rs:1045`](../ui/dr-ui/src/library.rs#L1045), [`ui/dr-ui/src/library.rs:1065`](../ui/dr-ui/src/library.rs#L1065), [`ui/dr-ui/src/library.rs:1376`](../ui/dr-ui/src/library.rs#L1376), [`ui/dr-ui/src/library.rs:1411`](../ui/dr-ui/src/library.rs#L1411), [`ui/dr-ui/src/library.rs:2310`](../ui/dr-ui/src/library.rs#L2310), [`ui/dr-ui/src/library.rs:7144`](../ui/dr-ui/src/library.rs#L7144), [`ui/dr-ui/src/library.rs:7312`](../ui/dr-ui/src/library.rs#L7312), [`ui/dr-ui/src/library.rs:73`](../ui/dr-ui/src/library.rs#L73), [`ui/dr-ui/src/library.rs:755`](../ui/dr-ui/src/library.rs#L755), [`ui/dr-ui/src/library.rs:899`](../ui/dr-ui/src/library.rs#L899), [`ui/dr-ui/src/library_ui.rs:1265`](../ui/dr-ui/src/library_ui.rs#L1265), [`ui/dr-ui/src/presets.rs:288`](../ui/dr-ui/src/presets.rs#L288), [`ui/dr-ui/src/presets.rs:335`](../ui/dr-ui/src/presets.rs#L335) | +| FR-NC-8 | [`core/dr-catalog/src/rating.rs:204`](../core/dr-catalog/src/rating.rs#L204), [`core/dr-catalog/src/rating.rs:66`](../core/dr-catalog/src/rating.rs#L66), [`core/dr-catalog/src/rating.rs:900`](../core/dr-catalog/src/rating.rs#L900), [`core/dr-catalog/src/schema.rs:155`](../core/dr-catalog/src/schema.rs#L155), [`core/dr-pipeline/src/sidecar.rs:120`](../core/dr-pipeline/src/sidecar.rs#L120), [`core/dr-pipeline/src/sidecar.rs:2487`](../core/dr-pipeline/src/sidecar.rs#L2487), [`core/dr-pipeline/src/sidecar.rs:619`](../core/dr-pipeline/src/sidecar.rs#L619), [`core/dr-pipeline/src/sidecar.rs:649`](../core/dr-pipeline/src/sidecar.rs#L649), [`core/dr-pipeline/src/sidecar.rs:94`](../core/dr-pipeline/src/sidecar.rs#L94), [`ui/dr-ui/src/lib.rs:2102`](../ui/dr-ui/src/lib.rs#L2102), [`ui/dr-ui/src/library.rs:1045`](../ui/dr-ui/src/library.rs#L1045), [`ui/dr-ui/src/library.rs:2310`](../ui/dr-ui/src/library.rs#L2310), [`ui/dr-ui/src/library.rs:500`](../ui/dr-ui/src/library.rs#L500), [`ui/dr-ui/src/library.rs:7312`](../ui/dr-ui/src/library.rs#L7312), [`ui/dr-ui/src/library.rs:755`](../ui/dr-ui/src/library.rs#L755), [`ui/dr-ui/src/library_ui.rs:554`](../ui/dr-ui/src/library_ui.rs#L554), [`ui/dr-ui/src/presets.rs:288`](../ui/dr-ui/src/presets.rs#L288), [`ui/dr-ui/src/presets.rs:335`](../ui/dr-ui/src/presets.rs#L335) | +| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:204`](../core/dr-catalog/src/rating.rs#L204), [`core/dr-catalog/src/rating.rs:66`](../core/dr-catalog/src/rating.rs#L66), [`core/dr-catalog/src/rating.rs:900`](../core/dr-catalog/src/rating.rs#L900), [`core/dr-catalog/src/schema.rs:155`](../core/dr-catalog/src/schema.rs#L155), [`core/dr-catalog/src/schema.rs:532`](../core/dr-catalog/src/schema.rs#L532), [`core/dr-catalog/src/schema.rs:728`](../core/dr-catalog/src/schema.rs#L728), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:158`](../core/dr-pipeline/src/sidecar.rs#L158), [`core/dr-pipeline/src/sidecar.rs:185`](../core/dr-pipeline/src/sidecar.rs#L185), [`core/dr-pipeline/src/sidecar.rs:2253`](../core/dr-pipeline/src/sidecar.rs#L2253), [`core/dr-pipeline/src/sidecar.rs:2487`](../core/dr-pipeline/src/sidecar.rs#L2487), [`core/dr-pipeline/src/sidecar.rs:354`](../core/dr-pipeline/src/sidecar.rs#L354), [`core/dr-pipeline/src/sidecar.rs:452`](../core/dr-pipeline/src/sidecar.rs#L452), [`core/dr-pipeline/src/sidecar.rs:619`](../core/dr-pipeline/src/sidecar.rs#L619), [`core/dr-pipeline/src/sidecar.rs:649`](../core/dr-pipeline/src/sidecar.rs#L649), [`core/dr-pipeline/src/spot.rs:245`](../core/dr-pipeline/src/spot.rs#L245), [`core/dr-pipeline/tests/mask_sidecar.rs:1055`](../core/dr-pipeline/tests/mask_sidecar.rs#L1055), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-sync/src/scan.rs:31`](../core/dr-sync/src/scan.rs#L31), [`core/dr-sync/src/scan.rs:45`](../core/dr-sync/src/scan.rs#L45), [`core/dr-sync/src/scan.rs:912`](../core/dr-sync/src/scan.rs#L912), [`core/dr-xmp/src/lib.rs:733`](../core/dr-xmp/src/lib.rs#L733), [`core/dr-xmp/src/lib.rs:746`](../core/dr-xmp/src/lib.rs#L746), [`ui/dr-ui/src/derived_sync.rs:585`](../ui/dr-ui/src/derived_sync.rs#L585), [`ui/dr-ui/src/library.rs:1045`](../ui/dr-ui/src/library.rs#L1045), [`ui/dr-ui/src/library.rs:1065`](../ui/dr-ui/src/library.rs#L1065), [`ui/dr-ui/src/library.rs:1376`](../ui/dr-ui/src/library.rs#L1376), [`ui/dr-ui/src/library.rs:1411`](../ui/dr-ui/src/library.rs#L1411), [`ui/dr-ui/src/library.rs:2310`](../ui/dr-ui/src/library.rs#L2310), [`ui/dr-ui/src/library.rs:7144`](../ui/dr-ui/src/library.rs#L7144), [`ui/dr-ui/src/library.rs:7312`](../ui/dr-ui/src/library.rs#L7312), [`ui/dr-ui/src/library.rs:73`](../ui/dr-ui/src/library.rs#L73), [`ui/dr-ui/src/library.rs:755`](../ui/dr-ui/src/library.rs#L755), [`ui/dr-ui/src/library.rs:899`](../ui/dr-ui/src/library.rs#L899), [`ui/dr-ui/src/library_ui.rs:1265`](../ui/dr-ui/src/library_ui.rs#L1265), [`ui/dr-ui/src/presets.rs:288`](../ui/dr-ui/src/presets.rs#L288), [`ui/dr-ui/src/presets.rs:335`](../ui/dr-ui/src/presets.rs#L335) | | FR-PLAT-AND-2 | [`core/dr-catalog/src/walk.rs:1022`](../core/dr-catalog/src/walk.rs#L1022), [`core/dr-catalog/src/walk.rs:435`](../core/dr-catalog/src/walk.rs#L435), [`core/dr-sync-folder/src/lib.rs:242`](../core/dr-sync-folder/src/lib.rs#L242), [`core/dr-sync-folder/src/tests.rs:51`](../core/dr-sync-folder/src/tests.rs#L51), [`core/dr-sync/src/error.rs:113`](../core/dr-sync/src/error.rs#L113), [`core/dr-sync/src/error.rs:195`](../core/dr-sync/src/error.rs#L195), [`core/dr-sync/src/scan.rs:200`](../core/dr-sync/src/scan.rs#L200), [`core/dr-sync/src/scan.rs:505`](../core/dr-sync/src/scan.rs#L505), [`core/dr-sync/src/scan.rs:531`](../core/dr-sync/src/scan.rs#L531), [`core/dr-sync/src/scan.rs:560`](../core/dr-sync/src/scan.rs#L560), [`ui/dr-ui/src/library.rs:1276`](../ui/dr-ui/src/library.rs#L1276), [`ui/dr-ui/src/library.rs:1329`](../ui/dr-ui/src/library.rs#L1329), [`ui/dr-ui/src/library.rs:1360`](../ui/dr-ui/src/library.rs#L1360), [`ui/dr-ui/src/library.rs:1727`](../ui/dr-ui/src/library.rs#L1727), [`ui/dr-ui/src/library_ui.rs:1241`](../ui/dr-ui/src/library_ui.rs#L1241), [`ui/dr-ui/src/library_ui.rs:1332`](../ui/dr-ui/src/library_ui.rs#L1332), [`ui/dr-ui/src/library_ui.rs:1931`](../ui/dr-ui/src/library_ui.rs#L1931), [`ui/dr-ui/src/library_ui.rs:325`](../ui/dr-ui/src/library_ui.rs#L325), [`ui/dr-ui/src/library_ui.rs:523`](../ui/dr-ui/src/library_ui.rs#L523) | | FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/runner.rs:1`](../core/dr-catalog/src/runner.rs#L1) | | FR-PLAT-AND-4 | [`core/dr-catalog/src/runner.rs:1`](../core/dr-catalog/src/runner.rs#L1) | @@ -133,8 +134,8 @@ _None._ | NFR-A11Y-2 | [`ui/dr-ui/tests/ui_controls_are_accessible.rs:1`](../ui/dr-ui/tests/ui_controls_are_accessible.rs#L1) | | NFR-A11Y-3 | [`ui/dr-ui/src/histogram.rs:356`](../ui/dr-ui/src/histogram.rs#L356), [`ui/dr-ui/ui/histogram.slint:137`](../ui/dr-ui/ui/histogram.slint#L137), [`ui/dr-ui/ui/library.slint:756`](../ui/dr-ui/ui/library.slint#L756), [`ui/dr-ui/ui/library.slint:904`](../ui/dr-ui/ui/library.slint#L904), [`ui/dr-ui/ui/peaking.slint:1`](../ui/dr-ui/ui/peaking.slint#L1) | | NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/library.rs:3385`](../ui/dr-ui/src/library.rs#L3385) | -| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1809`](../ui/dr-ui/src/export.rs#L1809), [`ui/dr-ui/src/export.rs:1836`](../ui/dr-ui/src/export.rs#L1836), [`ui/dr-ui/src/export.rs:412`](../ui/dr-ui/src/export.rs#L412), [`ui/dr-ui/src/export.rs:438`](../ui/dr-ui/src/export.rs#L438), [`ui/dr-ui/src/lib.rs:2468`](../ui/dr-ui/src/lib.rs#L2468), [`ui/dr-ui/ui/app.slint:1161`](../ui/dr-ui/ui/app.slint#L1161), [`ui/dr-ui/ui/library.slint:4157`](../ui/dr-ui/ui/library.slint#L4157) | -| NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-export/src/error.rs:1`](../core/dr-export/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1), [`platform/dr-plat/src/storage.rs:148`](../platform/dr-plat/src/storage.rs#L148), [`ui/dr-ui/src/export.rs:514`](../ui/dr-ui/src/export.rs#L514) | +| NFR-ARCH-3 | [`ui/dr-ui/src/export.rs:1809`](../ui/dr-ui/src/export.rs#L1809), [`ui/dr-ui/src/export.rs:1836`](../ui/dr-ui/src/export.rs#L1836), [`ui/dr-ui/src/export.rs:412`](../ui/dr-ui/src/export.rs#L412), [`ui/dr-ui/src/export.rs:438`](../ui/dr-ui/src/export.rs#L438), [`ui/dr-ui/src/lib.rs:2468`](../ui/dr-ui/src/lib.rs#L2468), [`ui/dr-ui/ui/app.slint:1158`](../ui/dr-ui/ui/app.slint#L1158), [`ui/dr-ui/ui/library.slint:4157`](../ui/dr-ui/ui/library.slint#L4157) | +| NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-export/src/error.rs:1`](../core/dr-export/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1), [`core/dr-xmp/src/lib.rs:798`](../core/dr-xmp/src/lib.rs#L798), [`platform/dr-plat/src/storage.rs:148`](../platform/dr-plat/src/storage.rs#L148), [`ui/dr-ui/src/export.rs:514`](../ui/dr-ui/src/export.rs#L514) | | NFR-OPS-1 | [`platform/dr-plat/src/diagnostics.rs:1`](../platform/dr-plat/src/diagnostics.rs#L1), [`platform/dr-plat/src/state.rs:1`](../platform/dr-plat/src/state.rs#L1) | | NFR-OPS-2 | [`platform/dr-plat/src/crash.rs:1`](../platform/dr-plat/src/crash.rs#L1) | | NFR-OPS-3 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) | @@ -168,10 +169,10 @@ _None._ 53 of 182 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built. 53 of 181 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built. +52 of 180 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
Show untagged requirements -- FR-CAT-13 - FR-CAT-14 - FR-CULL-6 - FR-CULL-7