Fold a photograph's rival default versions back into one
Picking the newer of two default versions stopped the wrong edit being shown, but it did not close the split: the losing version stayed in the file, and a device holding disjoint work — a crop made here, an exposure change made there — still contributed only one of the two. Worse, the next write made it larger. `amend` looks its version up by uuid, neither of the two was ours, so the miss minted a *third* `default = 1` block and the file grew one rival per device per photograph. `Sidecar::fuse_default_versions` folds them down. The version with the highest `(revision, modified)` is the accumulator and every other default is merged into it as the remote, which is what makes the fold order-independent — `Version::merge` raises its own revision to `max + 1` as it goes, so merging a chain in ascending order stops being ascending after the first step and a third device would be dropped. Contested values resolve to the winner, disjoint keys survive from both sides because the merge is key-wise, and ratings come across under `merge_judgement`, so a device that never judged the frame cannot erase one that did. The result is a function of the file's bytes alone, so two devices that fuse independently reach the same document and converge instead of overwriting each other. Called wherever a sidecar is parsed: - `amend`, with the write's own uuid, so the fold lands on the identity this device is about to use and the lookup below it hits instead of missing. - `spawn_sidecar_fetch`, so opening a photograph shows everything done to it rather than whichever half won. - `drain_one`, because `merge_into` reconciles by uuid and would otherwise publish the split rather than resolve it. - `presets::load_local` and `save_local` — a local sidecar's folder may be synced by something else entirely, and gets the same split. A file with one default under the expected uuid comes back byte-identical, so this costs nothing on the ordinary write and no sidecar is uploaded merely for having been read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+142
-3
@@ -744,9 +744,23 @@ pub enum SidecarMessage {
|
||||
fn amend(base: dr_pipeline::Sidecar, w: &SidecarWrite) -> dr_pipeline::Sidecar {
|
||||
let mut sidecar = base;
|
||||
|
||||
// TRACES: FR-NC-8 | FR-NC-9
|
||||
// Close any split this file already carries, *before* looking for our own
|
||||
// version, and onto the uuid this write is about to use.
|
||||
//
|
||||
// Every device used to mint its own uuid for the same photograph, so a
|
||||
// frame edited on two of them holds two `default = 1` blocks and the
|
||||
// lookup below misses both — adding a third rather than amending either.
|
||||
// Fusing first folds them into one under `w.version_uuid`, which turns the
|
||||
// miss into a hit and makes this an amendment of the other device's work
|
||||
// instead of a rival to it.
|
||||
//
|
||||
// Idempotent: a file with one default and the right uuid is returned
|
||||
// byte-identical, so this costs nothing on the ordinary write.
|
||||
sidecar.fuse_default_versions(Some(&w.version_uuid));
|
||||
|
||||
// Amend the version this write belongs to, creating it if the file did
|
||||
// not have one. The uuid comes from the catalog, so the same photograph
|
||||
// keeps one identity across devices (FR-NC-8).
|
||||
// not have one — a photograph nobody has edited anywhere.
|
||||
let mut version = sidecar
|
||||
.versions
|
||||
.get(&w.version_uuid)
|
||||
@@ -1020,6 +1034,18 @@ async fn drain_one(
|
||||
}
|
||||
}
|
||||
|
||||
// TRACES: FR-NC-8 | FR-NC-9
|
||||
// `merge_into` reconciles version by version *by uuid*, so two devices'
|
||||
// independently minted defaults pass straight through it and both land in
|
||||
// what is about to be uploaded. Fusing here is what stops the outbox from
|
||||
// publishing the split rather than resolving it.
|
||||
//
|
||||
// No canonical uuid: this entry may have been queued by a build that had
|
||||
// not derived one yet, and the smallest uuid is device-independent, which
|
||||
// is all convergence needs. The next write from either device moves it
|
||||
// onto the derived identity.
|
||||
local.fuse_default_versions(None);
|
||||
|
||||
backend
|
||||
.put(&path, local.to_text().into_bytes(), None)
|
||||
.await
|
||||
@@ -2010,7 +2036,19 @@ pub fn spawn_sidecar_fetch(
|
||||
|
||||
let text = String::from_utf8_lossy(&bytes).into_owned();
|
||||
let parsed = match dr_pipeline::Sidecar::parse(&text) {
|
||||
Ok(s) => Some(s),
|
||||
Ok(mut s) => {
|
||||
// TRACES: FR-NC-8 | FR-NC-9
|
||||
// Opening a photograph must show everything that has been
|
||||
// done to it, not whichever of two split default versions
|
||||
// happens to win. Fused in memory with no canonical uuid
|
||||
// to impose — this is a read, and the write path is where
|
||||
// the identity is decided.
|
||||
//
|
||||
// The fused document is what gets cached below, so the
|
||||
// next offline open sees the union too.
|
||||
s.fuse_default_versions(None);
|
||||
Some(s)
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("sidecar at {} is unreadable ({e})", path.as_str());
|
||||
None
|
||||
@@ -6821,3 +6859,104 @@ mod tests {
|
||||
assert_eq!(total_images_scoped(&catalog, None, &f).unwrap(), 3);
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-NC-8 | FR-NC-9
|
||||
/// What a write does to a sidecar another device has already edited.
|
||||
#[cfg(test)]
|
||||
mod amending_across_devices {
|
||||
use super::*;
|
||||
|
||||
fn judgement(uuid: &str, rating: u8) -> SidecarWrite {
|
||||
SidecarWrite {
|
||||
image_path: "PhotosRaw/incoming/a.CR2".to_string(),
|
||||
version_uuid: uuid.to_string(),
|
||||
amendment: Amendment::Judgement { rating, flag: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
fn version(uuid: &str, revision: u64, modified: i64, rating: u8) -> dr_pipeline::Version {
|
||||
dr_pipeline::sidecar::Version {
|
||||
uuid: uuid.to_string(),
|
||||
name: "Default".to_string(),
|
||||
is_default: true,
|
||||
revision,
|
||||
modified,
|
||||
rating,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// The regression. A sidecar already holding two independently minted
|
||||
/// defaults used to gain a *third* on the next write, because the lookup
|
||||
/// is by uuid and neither of the two was ours.
|
||||
#[test]
|
||||
fn a_write_onto_a_split_sidecar_does_not_add_a_third_version() {
|
||||
let mut base = dr_pipeline::Sidecar::new();
|
||||
base.put(version("tablet-uuid", 1, 100, 4));
|
||||
base.put(version("laptop-uuid", 2, 200, 1));
|
||||
|
||||
let out = amend(base, &judgement("derived-uuid", 5));
|
||||
|
||||
assert_eq!(out.versions.len(), 1, "the split must be closed, not grown");
|
||||
assert!(out.versions.contains_key("derived-uuid"));
|
||||
assert_eq!(out.versions["derived-uuid"].rating, 5);
|
||||
}
|
||||
|
||||
/// The other device's work has to survive the fold, or closing the split
|
||||
/// would be the same data loss by a different route.
|
||||
#[test]
|
||||
fn the_other_devices_edit_survives_the_write() {
|
||||
let mut theirs = version("tablet-uuid", 3, 300, 4);
|
||||
theirs
|
||||
.params
|
||||
.insert(("exposure".into(), "exposure".into()), 0.75);
|
||||
let mut base = dr_pipeline::Sidecar::new();
|
||||
base.put(theirs);
|
||||
|
||||
// Ours is a rating, which touches no parameter at all.
|
||||
let out = amend(base, &judgement("derived-uuid", 2));
|
||||
let v = &out.versions["derived-uuid"];
|
||||
|
||||
assert_eq!(
|
||||
v.params.get(&("exposure".into(), "exposure".into())),
|
||||
Some(&0.75),
|
||||
"the tablet's exposure was dropped by our rating"
|
||||
);
|
||||
assert_eq!(v.rating, 2, "and our own judgement did not land");
|
||||
}
|
||||
|
||||
/// A sidecar this device has already written must not be disturbed: the
|
||||
/// ordinary case is one default under the right uuid, and fusing it has to
|
||||
/// be a no-op beyond the amendment itself.
|
||||
#[test]
|
||||
fn the_ordinary_write_is_unaffected() {
|
||||
let mut base = dr_pipeline::Sidecar::new();
|
||||
base.put(version("derived-uuid", 7, 700, 3));
|
||||
|
||||
let out = amend(base, &judgement("derived-uuid", 5));
|
||||
|
||||
assert_eq!(out.versions.len(), 1);
|
||||
assert_eq!(out.versions["derived-uuid"].rating, 5);
|
||||
assert_eq!(
|
||||
out.versions["derived-uuid"].revision, 8,
|
||||
"one bump for one edit"
|
||||
);
|
||||
}
|
||||
|
||||
/// A virtual copy is not a rival default and must be left where it is.
|
||||
#[test]
|
||||
fn a_named_version_is_not_folded_into_the_default() {
|
||||
let mut copy = version("for-print", 4, 400, 5);
|
||||
copy.is_default = false;
|
||||
copy.name = "For print".to_string();
|
||||
let mut base = dr_pipeline::Sidecar::new();
|
||||
base.put(version("tablet-uuid", 1, 100, 4));
|
||||
base.put(copy);
|
||||
|
||||
let out = amend(base, &judgement("derived-uuid", 2));
|
||||
|
||||
assert_eq!(out.versions.len(), 2);
|
||||
assert_eq!(out.versions["for-print"].name, "For print");
|
||||
assert_eq!(out.versions["for-print"].rating, 5);
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -284,7 +284,16 @@ pub fn load_local(image: &Path) -> Option<Sidecar> {
|
||||
let path = local_sidecar_path(image);
|
||||
let text = std::fs::read_to_string(&path).ok()?;
|
||||
match Sidecar::parse(&text) {
|
||||
Ok(s) => Some(s),
|
||||
Ok(mut s) => {
|
||||
// TRACES: FR-NC-8 | FR-NC-9
|
||||
// A local sidecar is only local to *this* app. The folder it sits
|
||||
// in may well be synced by something else — a Nextcloud desktop
|
||||
// client, a git-annex, a memory card carried between machines — so
|
||||
// it can hold the same two-default split a library sidecar does,
|
||||
// and opening the photograph must show both halves.
|
||||
s.fuse_default_versions(None);
|
||||
Some(s)
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("sidecar at {} is unreadable ({e})", path.display());
|
||||
None
|
||||
@@ -323,6 +332,12 @@ pub fn save_local(
|
||||
})?,
|
||||
};
|
||||
|
||||
// TRACES: FR-NC-8 | FR-NC-9
|
||||
// Fold any split down before choosing which version to amend, so a save
|
||||
// resolves the two rather than writing into one of them and leaving the
|
||||
// other to be picked next time.
|
||||
sidecar.fuse_default_versions(None);
|
||||
|
||||
// A local file has no catalog behind it to supply a version identity, so
|
||||
// the file's own default version is used and one is created if absent.
|
||||
// Deterministic rather than random: reopening the same photograph must
|
||||
|
||||
Reference in New Issue
Block a user