Read the sidecars other editors write, and write them back on request
Benchmarks / CPU and I/O (per commit) (push) Successful in 10m59s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Successful in 1h33m36s
Build and test / Layer separation (push) Successful in 1m2s
Traceability / Requirement traces (push) Successful in 1m25s
🐳 Android image / Build and push (push) Successful in 9s
Build and test / android-image (push) Successful in 9s
Build and test / Android (aarch64) (push) Successful in 56m59s

FR-CAT-13 asked for standard XMP and `core/dr-xmp` answered the file: it
has read and written `dc:subject`, `xmp:Rating`, `xmp:Label` and the IPTC
core since 5fa4c07, under an ownership rule that leaves everything else in
the document untouched. What nothing did was call it. No scan found an
`.xmp` beside a raw, no catalog row was filled from one, no judgement
wrote one back, and the "external modification detected, reload offered"
clause had no mechanism. A library imported from Lightroom came in and
could not go back out.

The scan collects `.xmp` beside `.drsc` from the listings it was already
paying for, and the pull reads each one whose ETag has moved. Both
namings resolve: darktable's `IMG_0001.CR3.xmp` names its file exactly,
Lightroom's `IMG_0001.xmp` names the stem, and under the stem the JPEG
beside a RAW is the same photograph and takes the same document, as
DarkRoom's own sidecar already does. Each is reconciled with the catalog
winning — keywords union, a rating or label taken only where the catalog
has none — because a standard XMP carries nothing that could say whether
its value is newer. A genuine disagreement is not resolved; it is written
to a table, and the settings page offers the sidecars' values against it.
That button is the reload the requirement asks to be offered, and the
ETag that moved is the detection it asks for: an `.xmp` edited elsewhere
is exactly a file the pull's ordinary incrementality re-reads.

Writing goes the other way behind a setting that starts off, since NFR-R4
makes writes beside somebody's originals theirs to switch on. With it on,
a judgement or a keyword rewrites the sidecar of whichever spelling
exists, or creates Lightroom's. The record is read from the catalog
whole at that moment rather than carried from the gesture, so a rating
and a keyword a second apart are two writes of one file that agree. And
the file's own title, caption, copyright and hierarchy come through the
rewrite: the catalog has no columns for them, `rewrite` replaces the
owned set wholesale, and a record that said nothing about them would have
deleted them from a Lightroom sidecar on every star.

The rating's two axes cross the format's one field both ways: a
rejection is Adobe's `-1` and stars are stars, and stars arriving on a
rejected frame lift the rejection, since the file said it was worth a
number. An unrated file says nothing and clears nothing, on the rule the
`.drsc` merge keeps. `versions.label` finally has a reader and a writer,
with the code table moved out of the query so the two cannot drift.
This commit is contained in:
2026-09-12 01:08:11 +02:00
parent d3b6127db6
commit 896188a489
17 changed files with 1316 additions and 118 deletions
+1 -7
View File
@@ -301,13 +301,7 @@ fn like_prefix(path: &str) -> String {
}
fn label_code(l: ColourLabel) -> i64 {
match l {
ColourLabel::Red => 1,
ColourLabel::Yellow => 2,
ColourLabel::Green => 3,
ColourLabel::Blue => 4,
ColourLabel::Purple => 5,
}
crate::rating::label_code(l)
}
fn flag_code(f: FlagState) -> i64 {
+30 -1
View File
@@ -30,7 +30,7 @@
use rusqlite::{Connection, OptionalExtension};
use dr_types::{FlagState, ImageId};
use dr_types::{ColourLabel, FlagState, ImageId};
use crate::error::CatalogError;
@@ -275,6 +275,35 @@ pub fn align_default_version_uuids(conn: &Connection) -> Result<usize, CatalogEr
/// version pass was interrupted between the image insert and the commit.
/// Failing a rating because of either would be the wrong answer — the user
/// pressed a key and expects a star.
/// TRACES: FR-CAT-13
/// How `versions.label` encodes a colour label, and back.
///
/// One place for both directions, so a label written by the XMP pull and a
/// label queried by the selector cannot drift apart: the query used to hold
/// its own copy of the forward mapping and nothing held the reverse.
pub fn label_code(l: ColourLabel) -> i64 {
match l {
ColourLabel::Red => 1,
ColourLabel::Yellow => 2,
ColourLabel::Green => 3,
ColourLabel::Blue => 4,
ColourLabel::Purple => 5,
}
}
/// The colour a `versions.label` value names, or `None` for NULL and for a
/// code this build does not know.
pub fn label_from_code(code: Option<i64>) -> Option<ColourLabel> {
Some(match code? {
1 => ColourLabel::Red,
2 => ColourLabel::Yellow,
3 => ColourLabel::Green,
4 => ColourLabel::Blue,
5 => ColourLabel::Purple,
_ => return None,
})
}
pub fn default_version_id(conn: &Connection, image: ImageId) -> Result<i64, CatalogError> {
let existing: Option<i64> = conn
.query_row(
+38 -1
View File
@@ -15,7 +15,7 @@ use rusqlite::Connection;
use crate::error::CatalogError;
/// Schema version this build writes and understands.
pub const SCHEMA_VERSION: i64 = 14;
pub const SCHEMA_VERSION: i64 = 15;
/// Apply migrations up to [`SCHEMA_VERSION`].
///
@@ -136,6 +136,13 @@ pub fn migrate(conn: &Connection) -> Result<i64, CatalogError> {
tx.commit()?;
}
if from < 15 {
let tx = conn.unchecked_transaction()?;
tx.execute_batch(V15)?;
tx.pragma_update(None, "user_version", 15)?;
tx.commit()?;
}
Ok(from)
}
@@ -642,6 +649,36 @@ DELETE FROM face_index
AND f.model_id = face_index.model_id);
"#;
const V15: &str = r#"
-- TRACES: FR-CAT-13
-- Where a standard XMP sidecar and the catalog disagree.
--
-- An `.xmp` beside a photograph is read on the same pull as DarkRoom's own
-- sidecar, and reconciled field by field (`dr_xmp::reconcile`): keywords
-- union, and a rating, label or caption is taken only where the catalog holds
-- none. That rule is the safe one and it is not always the right one -- a
-- rating changed in Lightroom after it was changed here is a genuine
-- disagreement, and a standard XMP carries no revision to settle it by. So
-- the disagreement is written here instead of being resolved, and the
-- requirement's "a metadata reload offered" is a row in this table with a
-- button in front of it: the reload re-reads the file with the sidecar
-- winning, and deletes the row.
--
-- Keyed on the sidecar's path like `sidecars` is, and for the same reason: a
-- path is what the scan reports, what a fetch addresses, and what the ETag
-- that noticed the change belongs to. `fields` is the disagreeing fields as
-- `dr_xmp` names them, space-separated, for the line the settings page shows.
--
-- Rebuildable: the next pull that sees a changed ETag writes the row again.
CREATE TABLE IF NOT EXISTS xmp_conflicts (
root_id INTEGER NOT NULL REFERENCES roots(id) ON DELETE CASCADE,
path TEXT NOT NULL,
fields TEXT NOT NULL,
seen_at INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(root_id, path)
);
"#;
const V9: &str = r#"
-- TRACES: FR-CULL-8
-- A record that face detection has *run* on an image, distinct from what it
+31 -5
View File
@@ -37,6 +37,17 @@ pub struct ScanProgress {
/// on the whole edit format to recognise four characters in a filename.
pub const SIDECAR_EXTENSION: &str = "drsc";
/// TRACES: FR-CAT-13
/// Extension of a standard XMP sidecar — Lightroom's `IMG_0001.xmp`,
/// darktable's `IMG_0001.CR3.xmp`, and every other editor's.
///
/// Collected alongside DarkRoom's own for the same reason and at the same
/// cost: it is in the listing already, and it is the file the ratings and
/// keywords of a library edited elsewhere are in. Which images a given
/// `.xmp` describes is the catalog's question, since the two naming
/// conventions resolve differently and only the catalog knows the images.
pub const XMP_EXTENSION: &str = "xmp";
/// The result of a scan.
#[derive(Debug, Clone, Default)]
pub struct ScanResult {
@@ -275,15 +286,18 @@ where
Ok(result)
}
/// Whether a filename is a DarkRoom sidecar.
/// Whether a filename is a sidecar — DarkRoom's own, or a standard XMP one.
///
/// Case-insensitive on the extension alone. A server that upper-cased the
/// suffix — or a file copied through a filesystem that did — still describes a
/// photograph, and failing to recognise it would silently lose the edit rather
/// than fail visibly.
fn is_sidecar(name: &str) -> bool {
name.rsplit_once('.')
.is_some_and(|(stem, ext)| !stem.is_empty() && ext.eq_ignore_ascii_case(SIDECAR_EXTENSION))
name.rsplit_once('.').is_some_and(|(stem, ext)| {
!stem.is_empty()
&& (ext.eq_ignore_ascii_case(SIDECAR_EXTENSION)
|| ext.eq_ignore_ascii_case(XMP_EXTENSION))
})
}
/// Whether pruning is worth attempting against this backend.
@@ -927,6 +941,10 @@ mod tests {
// Upper-cased by a filesystem somewhere along the way; still a
// sidecar, and losing it would lose the edit silently.
file("Photos/2025/b.DRSC"),
// TRACES: FR-CAT-13
// And the standard kind, in both of its spellings.
file("Photos/2025/a.xmp"),
file("Photos/2025/b.jpg.xmp"),
],
);
let before = *b.lists.borrow();
@@ -946,14 +964,22 @@ mod tests {
.iter()
.map(|e| e.path.as_str().to_string())
.collect::<Vec<_>>(),
vec!["Photos/2025/a.drsc", "Photos/2025/b.DRSC"]
vec![
"Photos/2025/a.drsc",
"Photos/2025/a.xmp",
"Photos/2025/b.DRSC",
"Photos/2025/b.jpg.xmp",
]
);
assert_eq!(*b.lists.borrow() - before, 3, "no extra requests");
// And they are not photographs: the count the user is shown must not
// double because a library has been edited.
assert_eq!(r.progress.images_found, 3);
assert!(r.images.iter().all(|e| !e.path.name().contains("drsc")));
assert!(r
.images
.iter()
.all(|e| !e.path.name().contains("drsc") && !e.path.name().contains("xmp")));
}
/// A file whose *name* is only an extension is not a sidecar for anything.
+13 -1
View File
@@ -89,6 +89,15 @@ pub struct LibrarySettings {
/// 64 bars on a phone held in the hand is finer than a finger can aim at;
/// 32 on a desktop monitor wastes most of a tall sidebar.
pub timeline_bars: u32,
/// TRACES: FR-CAT-13 | NFR-R4
/// Whether a judgement is also written to the standard XMP sidecar beside
/// the original — `IMG_0001.xmp`, or `IMG_0001.CR3.xmp` where one exists.
///
/// Off by default, because NFR-R4 says writes beside somebody's originals
/// are theirs to switch on. Reading is not gated: a sidecar another
/// editor wrote is taken in regardless, since reading changes nothing in
/// the folder.
pub write_xmp_sidecars: bool,
}
impl LibrarySettings {
@@ -106,7 +115,10 @@ impl Default for LibrarySettings {
// The coarser of the two. A bar has to be wide enough to hit with a
// finger before it has to be narrow enough to be precise, and the
// smallest screen is the one where getting this wrong hurts most.
Self { timeline_bars: 32 }
Self {
timeline_bars: 32,
write_xmp_sidecars: false,
}
}
}