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
+215
View File
@@ -298,6 +298,9 @@ pub struct LibraryController {
/// Drains the sidecar writer. Held so a second judgement replaces the
/// timer rather than leaving two draining the same finished channel.
sidecar_timer: RefCell<Option<slint::Timer>>,
/// TRACES: FR-CAT-13
/// The same, for a batch of XMP writes or a reload.
xmp_timer: RefCell<Option<slint::Timer>>,
/// Which window load the model belongs to, bumped by [`load_window`].
///
/// A thumbnail worker addresses cells by *row index into the window that
@@ -381,6 +384,10 @@ pub struct LibraryController {
/// small budget still keeps a working set. A metered or small-disk device
/// wants the first.
keep_opened: std::cell::Cell<bool>,
/// TRACES: FR-CAT-13 | NFR-R4
/// Whether judgements and keywords also go to the `.xmp` beside the
/// original. Mirrors `LibrarySettings::write_xmp_sidecars`.
write_xmp: std::cell::Cell<bool>,
/// TRACES: FR-CAT-6
/// How many bars the capture-time axis is cut into, from the settings
/// page.
@@ -450,6 +457,7 @@ impl LibraryController {
current_bucket: RefCell::new(None),
filter: RefCell::new(library::RatingFilter::default()),
sidecar_timer: RefCell::new(None),
xmp_timer: RefCell::new(None),
generation: std::cell::Cell::new(0),
reachability: RefCell::new(dr_sync::Reachability::new()),
root_lost: RefCell::new(None),
@@ -466,6 +474,9 @@ impl LibraryController {
keep_opened: std::cell::Cell::new(
dr_types::CacheSettings::default().keep_opened_originals,
),
write_xmp: std::cell::Cell::new(
dr_types::LibrarySettings::default().write_xmp_sidecars,
),
timeline_bars: std::cell::Cell::new(dr_types::LibrarySettings::default().timeline_bars),
face_model_id: RefCell::new(dr_types::FaceDetector::default().model_id().to_string()),
})
@@ -505,6 +516,11 @@ impl LibraryController {
self.keep_opened.set(keep);
}
/// TRACES: FR-CAT-13 | NFR-R4
pub fn set_write_xmp_sidecars(&self, on: bool) {
self.write_xmp.set(on);
}
/// TRACES: FR-NC-6a
/// Set the ceiling on passively cached originals, and apply it now.
///
@@ -1266,6 +1282,11 @@ fn drain_scan(
log::info!("library folder is readable again");
}
refresh_offline(&w, ctl);
// TRACES: FR-CAT-13
// The pull may have found an `.xmp` that disagrees
// with the catalog; the settings page offers the
// reload, and this is what tells it how many.
refresh_xmp_conflicts(&w, ctl);
// An incremental rescan lists almost nothing, so
// reporting the listed count would read as "0 images"
@@ -3086,6 +3107,7 @@ fn apply_keyword(window: &AppWindow, ctl: &Rc<LibraryController>, word: &str, as
window.set_library_error(slint::SharedString::new());
window.set_library_status(keyword_summary(&word, n, images.len(), assigning).into());
refresh_keywords(window, ctl, &images);
start_xmp_writes(window, ctl, &images);
// A filtered grid may no longer hold what was just keyworded — taking
// "puffin" off an image while showing only puffins means it belongs
@@ -3194,6 +3216,7 @@ fn apply_judgement(
}
start_sidecar_writes(window, ctl, writes);
start_xmp_writes(window, ctl, images);
}
/// What the status line says about a judgement that just landed.
@@ -3385,6 +3408,186 @@ fn collect_sidecar_writes(
}
/// Push judgements out to sidecars on a worker, reporting once at the end.
/// TRACES: FR-CAT-13 | NFR-R4
/// Write these images' ratings, labels and keywords to the `.xmp` beside
/// each, where the user has switched that on.
///
/// The record is read from the catalog *now*, whole, rather than carried
/// from the gesture: a rating and a keyword typed a second apart are two
/// writes of the same file, and the second must not carry a copy of the
/// first taken before it landed.
pub(crate) fn start_xmp_writes(
window: &AppWindow,
ctl: &Rc<LibraryController>,
images: &[dr_types::ImageId],
) {
if !ctl.write_xmp.get() || images.is_empty() || ctl.is_offline() {
return;
}
let Some((conn, _)) = ctl.session.borrow().clone() else {
return;
};
let writes: Vec<library::XmpWrite> = {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let c = catalog.connection();
images
.iter()
.filter_map(|&image| {
let version = dr_catalog::rating::default_version_id(c, image).ok()?;
let image_path: String = c
.query_row(
"SELECT source_ref FROM images WHERE id = ?1",
[image.0 as i64],
|r| r.get(0),
)
.ok()?;
Some(library::XmpWrite {
image_path,
record: crate::xmp_sync::record_of(c, image, version),
})
})
.collect()
};
if writes.is_empty() {
return;
}
let count = writes.len();
let rx = library::spawn_xmp_writes(conn, writes);
let job = ctl.activity.begin(
crate::activity::Kind::Upload,
format!("Writing {count} XMP sidecar(s)"),
);
drain_xmp(window.as_weak(), ctl.clone(), rx, job, false);
}
/// TRACES: FR-CAT-13
/// The offered reload: take the sidecars' values for every photograph the
/// last pull found disagreeing with the catalog.
pub(crate) fn start_xmp_reload(window: &AppWindow, ctl: &Rc<LibraryController>) {
let Some((conn, _)) = ctl.session.borrow().clone() else {
return;
};
let paths: Vec<String> = {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let Some(root_id) = root_id_of(catalog, &conn.account.root) else {
return;
};
crate::xmp_sync::conflicts(catalog, root_id)
.into_iter()
.map(|c| c.path)
.collect()
};
if paths.is_empty() {
return;
}
let count = paths.len();
let rx = library::spawn_xmp_reload(
conn.clone(),
conn.account.root.clone(),
library::catalog_path(&conn.account),
paths,
);
let job = ctl.activity.begin(
crate::activity::Kind::Download,
format!("Reloading {count} XMP sidecar(s)"),
);
drain_xmp(window.as_weak(), ctl.clone(), rx, job, true);
}
/// Wait for a batch of XMP work to report, then say what it did.
fn drain_xmp(
weak: slint::Weak<AppWindow>,
ctl: Rc<LibraryController>,
rx: std::sync::mpsc::Receiver<library::XmpMessage>,
job: crate::activity::Activity,
reload: bool,
) {
let timer = slint::Timer::default();
let job = RefCell::new(Some(job));
let ctl_cb = ctl.clone();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(200),
move || {
let ctl = &ctl_cb;
let message = match rx.try_recv() {
Ok(m) => m,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
stop(&ctl.xmp_timer);
return;
}
};
stop(&ctl.xmp_timer);
let library::XmpMessage::Finished {
written,
failed,
last_error,
} = message;
let status = match (reload, failed, last_error) {
(false, 0, _) => format!("{written} XMP sidecar(s) written"),
(true, 0, _) => format!("{written} photograph(s) reloaded from XMP"),
(_, n, Some(e)) => format!("{n} XMP sidecar(s) failed: {e}"),
(_, n, None) => format!("{n} XMP sidecar(s) failed"),
};
if let Some(job) = job.borrow_mut().take() {
if failed > 0 {
job.fail(status.clone());
} else {
job.finish(status.clone());
}
}
if let Some(w) = weak.upgrade() {
w.set_library_status(status.into());
if reload {
// The grid draws what the reload changed, and the
// settings page stops offering what is settled.
let visible = ctl.visible_ids();
if let Some(catalog) = ctl.catalog.borrow().as_ref() {
sync_ratings(&w, catalog, &visible);
refresh_rating_counts(&w, catalog);
}
refresh_xmp_conflicts(&w, ctl);
}
}
},
);
*ctl.xmp_timer.borrow_mut() = Some(timer);
}
/// TRACES: FR-CAT-13
/// How many sidecars the last pull found disagreeing with the catalog, for
/// the settings page to offer the reload against.
pub(crate) fn refresh_xmp_conflicts(window: &AppWindow, ctl: &Rc<LibraryController>) {
let count = (|| {
let (conn, _) = ctl.session.borrow().clone()?;
let borrow = ctl.catalog.borrow();
let catalog = borrow.as_ref()?;
let root_id = root_id_of(catalog, &conn.account.root)?;
Some(crate::xmp_sync::conflicts(catalog, root_id).len())
})()
.unwrap_or(0);
window.set_settings_xmp_conflicts(count as i32);
}
fn root_id_of(catalog: &Catalog, root: &str) -> Option<i64> {
catalog
.connection()
.query_row(
"SELECT id FROM roots WHERE label = ?1 AND kind = 'remote'",
[root],
|r| r.get(0),
)
.ok()
}
pub(crate) fn start_sidecar_writes(
window: &AppWindow,
ctl: &Rc<LibraryController>,
@@ -5503,6 +5706,18 @@ pub fn wire<F>(
// controllers would hold each other alive for the life of the process.
*ctl.coll_ctl.borrow_mut() = Some(Rc::downgrade(&coll_ctl));
// TRACES: FR-CAT-13
// The reload the settings page offers when a sidecar disagrees.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_settings_xmp_reload(move || {
if let Some(w) = weak.upgrade() {
start_xmp_reload(&w, &ctl);
}
});
}
// TRACES: FR-UI-3 | FR-UI-4
// Whether the rating strip waits to be hovered or stands open.
//