Speak the sidecar format every other editor already reads

FR-CAT-13 asked for standard XMP and nothing in the tree parsed or wrote a
byte of it. `keywords.rs` mentioned `dc:subject` in a comment about what a
keyword's text is for, `dr-export`'s metadata module said "neither is read by
`dr-decode` today" about its own half, and `dr-preset-xmp` reads a different
file for a different requirement. So a library imported from Lightroom could
come in and never go back out: a one-way door, which is not a thing a
photographer walks their archive through.

`core/dr-xmp` reads and writes the properties the requirement names —
`dc:subject`, `lr:hierarchicalSubject`, `xmp:Rating`, `xmp:Label` and the IPTC
core fields — from whichever shape the file happens to use. A property may
arrive as an attribute or as an element, inside a Bag, a Seq, an Alt or no
container at all, because the specification is not what wrote the file; so one
collector takes whatever is in a property and the declared shape decides only
how many values survive. `xmp:Rating="-1"` is modelled as Adobe's rejection
rather than folded into zero stars, since DarkRoom keeps those on two axes and
the mapping belongs where both are visible.

Writing is a rewrite rather than a serialisation, and that is the whole design.
An XMP sidecar is a shared document: the file beside a raw carries somebody
else's `crs:` settings and comments and namespaces, and rendering our record
over it would be data loss on every photograph but the first. The rule is
stated once, in the crate documentation and in `PROPERTIES`: DarkRoom owns
exactly those properties, identified by namespace URI and never by prefix, and
nothing else in the document. Everything unowned is copied through byte for
byte. A `Description` left empty once our properties come out of it is
withdrawn, which is what keeps a rewrite idempotent instead of adding a husk to
the file on every save.

Precedence is settled conservatively, because a standard XMP carries no
revision and no device and there is nothing in it to order two edits by.
Keywords union, following the rule `dr_catalog::merge` already makes for
assignments; every other field is taken only where DarkRoom holds none,
following `Version::merge`'s judgement rule, and a genuine disagreement is
reported rather than resolved so a caller can offer the reload the requirement
asks for. What is deliberately left open — when a reload may happen without
asking — is written down in the module rather than picked silently.

No new dependency: quick-xml was already in the tree for WebDAV and for
Lightroom presets. Nothing above the crate calls it yet, and `outstanding.md`
now says so along with the two smaller gaps, GPS and the filename convention.
This commit is contained in:
2026-09-06 19:01:48 +02:00
parent 68ebf5d78b
commit 5fa4c0772b
9 changed files with 2703 additions and 20 deletions
Generated
+10
View File
@@ -1684,6 +1684,16 @@ dependencies = [
"wgpu", "wgpu",
] ]
[[package]]
name = "dr-xmp"
version = "0.10.1"
dependencies = [
"dr-types",
"log",
"quick-xml",
"thiserror 2.0.20",
]
[[package]] [[package]]
name = "drm" name = "drm"
version = "0.14.1" version = "0.14.1"
+2
View File
@@ -17,6 +17,7 @@ members = [
"core/dr-sync", "core/dr-sync",
"core/dr-sync-folder", "core/dr-sync-folder",
"core/dr-sync-nextcloud", "core/dr-sync-nextcloud",
"core/dr-xmp",
"platform/dr-plat", "platform/dr-plat",
"ui/dr-ui", "ui/dr-ui",
"apps/darkroom-desktop", "apps/darkroom-desktop",
@@ -59,6 +60,7 @@ dr-plat = { path = "platform/dr-plat" }
dr-sync = { path = "core/dr-sync" } dr-sync = { path = "core/dr-sync" }
dr-sync-folder = { path = "core/dr-sync-folder" } dr-sync-folder = { path = "core/dr-sync-folder" }
dr-sync-nextcloud = { path = "core/dr-sync-nextcloud" } dr-sync-nextcloud = { path = "core/dr-sync-nextcloud" }
dr-xmp = { path = "core/dr-xmp" }
dr-ui = { path = "ui/dr-ui" } dr-ui = { path = "ui/dr-ui" }
# GPU + UI # GPU + UI
+26
View File
@@ -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
File diff suppressed because it is too large Load Diff
+571
View File
@@ -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<Item>,
/// The `rdf:li` currently open, if one is.
li: Option<Item>,
/// 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<Item> {
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<Xmp, XmpError> {
let mut reader = NsReader::from_str(text);
let mut found: Found = Found::new();
let mut capture: Option<Capture> = 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 `<?xpacket?>` 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 {
// `<dc:title/>`. 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<String> {
if shape.is_list() {
let mut out: Vec<String> = 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<Vec<u8>> {
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<Vec<u8>>, 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<String> {
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#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 5.6">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:lr="http://ns.adobe.com/lightroom/1.0/"
xmlns:photoshop="http://ns.adobe.com/photoshop/1.0/"
xmlns:xmpRights="http://ns.adobe.com/xap/1.0/rights/"
xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
xmp:Rating="4"
xmp:Label="Yellow"
photoshop:Credit="Puffin Pictures"
crs:Exposure2012="+0.75">
<dc:subject>
<rdf:Bag>
<rdf:li>puffin</rdf:li>
<rdf:li>Iceland</rdf:li>
</rdf:Bag>
</dc:subject>
<lr:hierarchicalSubject>
<rdf:Bag>
<rdf:li>Places|Iceland</rdf:li>
</rdf:Bag>
</lr:hierarchicalSubject>
<dc:title>
<rdf:Alt>
<rdf:li xml:lang="x-default">Puffin on a cliff</rdf:li>
</rdf:Alt>
</dc:title>
<dc:description>
<rdf:Alt>
<rdf:li xml:lang="x-default">Látrabjarg, late evening.</rdf:li>
</rdf:Alt>
</dc:description>
<dc:creator>
<rdf:Seq>
<rdf:li>A. Photographer</rdf:li>
</rdf:Seq>
</dc:creator>
<dc:rights>
<rdf:Alt>
<rdf:li xml:lang="x-default">© 2026 A. Photographer</rdf:li>
</rdf:Alt>
</dc:rights>
<xmpRights:UsageTerms>
<rdf:Alt>
<rdf:li xml:lang="x-default">Editorial use only.</rdf:li>
</rdf:Alt>
</xmpRights:UsageTerms>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>"#;
#[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#"<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/">
<xmp:Rating>3</xmp:Rating>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>"#;
let attribute = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="3"/>
</rdf:RDF>
</x:xmpmeta>"#;
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#"<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:dc="http://example.invalid/not-dublin-core/"
xmlns:whatever="http://purl.org/dc/elements/1.1/">
<dc:subject>
<rdf:Bag><rdf:li>impostor</rdf:li></rdf:Bag>
</dc:subject>
<whatever:subject>
<rdf:Bag><rdf:li>puffin</rdf:li></rdf:Bag>
</whatever:subject>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>"#;
#[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 `&amp;` as its own event, so taking the first run
// of text would store "Bells " and lose the rest for ever.
let text = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:subject><rdf:Bag><rdf:li>Bells &amp; whistles</rdf:li></rdf:Bag></dc:subject>
</rdf:Description>
</rdf:RDF>"#;
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#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:subject>puffin</dc:subject>
<dc:title>Straight to the point</dc:title>
</rdf:Description>
</rdf:RDF>"#;
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#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title>
<rdf:Alt>
<rdf:li xml:lang="fr-FR">Macareux</rdf:li>
<rdf:li xml:lang="x-default">Puffin</rdf:li>
</rdf:Alt>
</dc:title>
</rdf:Description>
</rdf:RDF>"#;
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#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:title><rdf:Alt><rdf:li xml:lang="fr-FR">Macareux</rdf:li></rdf:Alt></dc:title>
</rdf:Description>
</rdf:RDF>"#;
assert_eq!(Xmp::parse(text).unwrap().title.as_deref(), Some("Macareux"));
}
#[test]
fn a_word_listed_twice_is_one_keyword() {
let text = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:subject><rdf:Bag>
<rdf:li>puffin</rdf:li><rdf:li>puffin</rdf:li>
</rdf:Bag></dc:subject>
</rdf:Description>
</rdf:RDF>"#;
assert_eq!(Xmp::parse(text).unwrap().keywords, ["puffin"]);
}
#[test]
fn a_rejection_survives_the_read() {
let text = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="-1"/>
</rdf:RDF>"#;
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#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmp:Rating="later">
<dc:subject><rdf:Bag><rdf:li>puffin</rdf:li></rdf:Bag></dc:subject>
</rdf:Description>
</rdf:RDF>"#;
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("<preset><name>Warm</name></preset>").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("<rdf:RDF></rdf:Description>"),
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#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
crs:Exposure2012="+0.75"/>
</rdf:RDF>"#;
assert!(Xmp::parse(text).unwrap().is_empty());
}
}
+768
View File
@@ -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 `</rdf:RDF>`,
//! 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, "<?xpacket begin=\"\u{feff}\" id=\"{PACKET_ID}\"?>");
let _ = writeln!(
out,
"<x:xmpmeta xmlns:x=\"adobe:ns:meta/\" x:xmptk=\"{TOOLKIT}\">"
);
let _ = writeln!(out, " <rdf:RDF xmlns:rdf=\"{NS_RDF}\">");
if let Some(block) = description_block(xmp, " ") {
let _ = writeln!(out, "{block}");
}
let _ = writeln!(out, " </rdf:RDF>");
let _ = writeln!(out, "</x:xmpmeta>");
// 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, "<?xpacket end=\"w\"?>");
out
}
pub(crate) fn rewrite(xmp: &Xmp, existing: &str) -> Result<String, XmpError> {
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<usize> = None;
// An `rdf:Description` whose fate is not yet decided. See [`Pending`].
let mut pending: Option<Pending> = 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<u8> = 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 {
// `<rdf:Description rdf:about=""/>` 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 {
// `<rdf:RDF/>`. 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 `</rdf:RDF>` 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<Vec<u8>>,
/// 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<Vec<u8>>) {
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<Vec<u8>>, held: &mut Vec<u8>) {
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<Vec<u8>>, 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<u8>` 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<BytesStart<'static>> {
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<String> {
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}<rdf:Description rdf:about=\"\"");
for (prefix, namespace) in namespaces {
let _ = write!(out, "\n{indent} xmlns:{prefix}=\"{namespace}\"");
}
let _ = write!(out, ">\n{body}{indent}</rdf:Description>");
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}>{}</{prefix}:{local}>",
escape(first.as_str())
);
}
}
Some(container) => {
let _ = writeln!(out, "{indent}<{prefix}:{local}>");
let _ = writeln!(out, "{indent} <rdf:{container}>");
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} <rdf:li{lang}>{}</rdf:li>",
escape(value.as_str())
);
}
let _ = writeln!(out, "{indent} </rdf:{container}>");
let _ = writeln!(out, "{indent}</{prefix}:{local}>");
}
}
}
/// 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<Vec<u8>> {
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#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 5.6">
<!-- written by somebody else -->
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
crs:Version="15.0"
crs:Exposure2012="+0.75"
xmp:Rating="2">
<crs:ToneCurvePV2012>
<rdf:Seq><rdf:li>0, 0</rdf:li><rdf:li>255, 255</rdf:li></rdf:Seq>
</crs:ToneCurvePV2012>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>"#;
#[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("<!-- written by somebody else -->"), "{out}");
assert!(out.contains("<?xpacket end=\"w\"?>"), "{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("<rdf:Description").count(),
once.matches("<rdf:Description").count(),
"a Description was added by a rewrite that changed nothing:\n{twice}"
);
}
#[test]
fn a_description_that_held_only_ours_goes_with_it() {
// Somebody else's file whose one `Description` carries nothing but a
// rating. With the rating cleared it asserts nothing, and an element
// asserting nothing is not something to preserve.
let theirs = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="2"/>
</rdf:RDF>
</x:xmpmeta>"#;
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#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:foo="http://ns.adobe.com/xap/1.0/">
<foo:Rating>1</foo:Rating>
</rdf:Description>
</rdf:RDF>"#;
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#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about="" xmlns:dc="http://example.invalid/other/">
<dc:subject>not ours to delete</dc:subject>
</rdf:Description>
</rdf:RDF>"#;
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#"<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"/>
</x:xmpmeta>"#;
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("<preset><name>Warm</name></preset>"),
Err(XmpError::NotXmp)
);
}
#[test]
fn a_value_needing_escaping_survives_the_round_trip() {
let awkward = Xmp {
keywords: vec!["Bells & whistles".into(), "<angle>".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);
}
}
+224
View File
@@ -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 <derivatives>.".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#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/" x:xmptk="Adobe XMP Core 5.6-c140">
<!-- do not lose me -->
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:tiff="http://ns.adobe.com/tiff/1.0/"
crs:Version="15.0"
crs:Exposure2012="+0.75"
crs:ProcessVersion="11.0"
tiff:Orientation="6"
xmp:Rating="2"
xmp:Label="Red">
<crs:ToneCurvePV2012>
<rdf:Seq>
<rdf:li>0, 0</rdf:li>
<rdf:li>255, 255</rdf:li>
</rdf:Seq>
</crs:ToneCurvePV2012>
<dc:subject>
<rdf:Bag>
<rdf:li>gannet</rdf:li>
</rdf:Bag>
</dc:subject>
</rdf:Description>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="w"?>"#;
#[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",
"<!-- do not lose me -->",
"x:xmptk=\"Adobe XMP Core 5.6-c140\"",
"<?xpacket end=\"w\"?>",
] {
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("<lrtemplate>not xmp at all</lrtemplate>"),
Err(XmpError::NotXmp)
);
assert!(matches!(
Xmp::parse("<x:xmpmeta></x:different>"),
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<String> = 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"
);
}
}
+25 -8
View File
@@ -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 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. that is the moment a new user meets.
**FR-CAT-13 — XMP interoperability, untagged and not met.** Read and write standard XMP sidecars. **FR-CAT-13 — XMP interoperability, built but not yet wired.** `core/dr-xmp` now reads and writes
Its single tag sat on `keywords.rs`, which stores keywords in the catalog and mentions `dc:subject` standard XMP sidecars: `dc:subject` and `lr:hierarchicalSubject`, `xmp:Rating` and `xmp:Label`, and
in a comment about what a keyword's text is *for*; no XMP is parsed or written anywhere in the tree, the IPTC core fields, in both the attribute and the element form and whatever RDF container a file
and `dr-export`'s metadata module says so about its own half ("neither is read by `dr-decode` happened to use. It states the ownership rule in one place — DarkRoom owns the properties in
today"). The tag has been removed — `dr-preset-xmp` is not the counter-example it looks like, being `PROPERTIES` and nothing else in the document, identified by namespace URI rather than by prefix —
a reader of Lightroom *presets* under FR-DEV-6, which is a different file and a different purpose. and enforces it by rewriting a packet event by event rather than serialising over it, so another
Listed here rather than silently, because a tag makes a gap invisible and this one is load-bearing application's `crs:` settings, comments and processing instructions survive a write byte for byte.
for interoperating with the editors FR-CAT-14 imports from.
**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.
--- ---
+13 -12
View File
@@ -9,17 +9,17 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n
| Metric | Value | | Metric | Value |
|---|---| |---|---|
| Source files scanned | 337 | | Source files scanned | 340 |
| TRACES tags found | 1175 | | TRACES tags found | 1135 |
| Requirements defined | 181 | | Requirements defined | 180 |
| Requirements covered | 128 | | Requirements covered | 128 |
| **Coverage** | **70.7%** (128/181) | | **Coverage** | **71.1%** (128/180) |
### By type ### By type
| Type | Covered | Defined | | Type | Covered | Defined |
|---|---|---| |---|---|---|
| FR | 95 | 126 | | FR | 95 | 125 |
| NFR | 30 | 49 | | NFR | 30 | 49 |
| R | 3 | 6 | | 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-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-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-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-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-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) | | 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-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-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-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-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), [`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-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-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-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) | | 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-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-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-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-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: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-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-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-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) | | 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-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-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-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-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), [`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-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-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-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) | | 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 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. 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.
<details><summary>Show untagged requirements</summary> <details><summary>Show untagged requirements</summary>
- FR-CAT-13
- FR-CAT-14 - FR-CAT-14
- FR-CULL-6 - FR-CULL-6
- FR-CULL-7 - FR-CULL-7