Sync develop presets through the library
Presets were the one piece of the photographer's work that never left the device: faces, sidecars, albums, collections, keywords and camera profiles all travel with the sync pass, the preset library did not. It now goes to <derived>/presets/library.drpl. PresetLibrary::merge decides each name against the base the last exchange left (kept per library beside place.json), so presets added on two devices both survive, a deletion reaches the other device instead of being restored by it, and an edit outlives a deletion made elsewhere. The upload is If-Match / If-None-Match on the server's copy, and a 412 reads and merges again, so two devices exchanging at once cannot save over each other. A server copy that will not parse (a newer build's) is left alone, and a local file that will not read stops the exchange rather than being taken for an empty library. The develop view's save merges with the file when the sync changed it since the view read it, and a sync that brought presets reloads and redraws the list. Also corrects the register, which still said camera profiles do not sync.
This commit is contained in:
@@ -675,6 +675,56 @@ impl PresetLibrary {
|
||||
self.unknown.values().map(Vec::len).sum()
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-6
|
||||
/// Merge two copies of a library that both descend from `base`.
|
||||
///
|
||||
/// For keeping one library on several devices: `base` is what the last
|
||||
/// exchange left both sides holding, `ours` this device's copy now and
|
||||
/// `theirs` the server's. Each name is decided on its own:
|
||||
///
|
||||
/// - Changed on one side only — added, edited or deleted — that side's
|
||||
/// answer stands. This is why the base is needed at all: without it a
|
||||
/// preset deleted here and one added there look the same, and a
|
||||
/// deletion would come back on every exchange.
|
||||
/// - Changed on both sides to the same thing, nothing to decide.
|
||||
/// - Deleted on one side and edited on the other, the edit stands. A
|
||||
/// preset is work, and an absence is not.
|
||||
/// - Edited on both sides differently, ours stands. Either answer loses
|
||||
/// one edit; this one at least converges, since the other device takes
|
||||
/// ours on its next exchange as an edit made on one side only.
|
||||
///
|
||||
/// A missing `base` is an empty one, which can only add: a device's first
|
||||
/// exchange is a union of the two libraries, never a deletion.
|
||||
pub fn merge(base: &Self, ours: &Self, theirs: &Self) -> Self {
|
||||
let mut out = Self::default();
|
||||
let names: std::collections::BTreeSet<&str> = ours.names().chain(theirs.names()).collect();
|
||||
for name in names {
|
||||
let (b, o, t) = (base.entry(name), ours.entry(name), theirs.entry(name));
|
||||
let chosen = if o == t || t == b {
|
||||
o
|
||||
} else if o == b {
|
||||
t
|
||||
} else {
|
||||
o.or(t)
|
||||
};
|
||||
if let Some((preset, unknown)) = chosen {
|
||||
out.presets.insert(name.to_string(), preset.clone());
|
||||
if let Some(lines) = unknown {
|
||||
out.unknown.insert(name.to_string(), lines.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One name's preset together with the lines kept beside it, which are
|
||||
/// part of what that preset is when two copies are compared.
|
||||
fn entry(&self, name: &str) -> Option<(&Preset, Option<&Vec<String>>)> {
|
||||
self.presets
|
||||
.get(name)
|
||||
.map(|preset| (preset, self.unknown.get(name)))
|
||||
}
|
||||
|
||||
/// Serialise to the on-disk form.
|
||||
///
|
||||
/// Deterministic, like the sidecar's: the same library always produces the
|
||||
@@ -1479,6 +1529,97 @@ mod tests {
|
||||
assert_eq!(other.to_text(), named().to_text());
|
||||
}
|
||||
|
||||
/// A one-parameter preset, so two of them differ by their value.
|
||||
fn exposure(ev: f32) -> Preset {
|
||||
let mut params = BTreeMap::new();
|
||||
params.insert(("exposure".to_string(), "exposure".to_string()), ev);
|
||||
Preset::from_params(params)
|
||||
}
|
||||
|
||||
fn library_of(entries: &[(&str, f32)]) -> PresetLibrary {
|
||||
let mut lib = PresetLibrary::default();
|
||||
for (name, ev) in entries {
|
||||
lib.insert(name, exposure(*ev)).unwrap();
|
||||
}
|
||||
lib
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_first_merge_is_the_union_of_both_libraries() {
|
||||
let ours = library_of(&[("Mine", 1.0), ("Both", 0.5)]);
|
||||
let theirs = library_of(&[("Theirs", 2.0), ("Both", 0.5)]);
|
||||
let merged = PresetLibrary::merge(&PresetLibrary::default(), &ours, &theirs);
|
||||
assert_eq!(
|
||||
merged.names().collect::<Vec<_>>(),
|
||||
vec!["Both", "Mine", "Theirs"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_deletion_on_either_side_is_kept_rather_than_undone() {
|
||||
// The case the base exists for: without it, the deleted preset is
|
||||
// indistinguishable from one the other side has just added.
|
||||
let base = library_of(&[("Gone here", 1.0), ("Gone there", 2.0)]);
|
||||
let ours = library_of(&[("Gone there", 2.0)]);
|
||||
let theirs = library_of(&[("Gone here", 1.0)]);
|
||||
assert!(PresetLibrary::merge(&base, &ours, &theirs).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_on_one_side_reaches_the_other() {
|
||||
let base = library_of(&[("Warm", 1.0)]);
|
||||
let ours = library_of(&[("Warm", 1.0)]);
|
||||
let theirs = library_of(&[("Warm", 1.5)]);
|
||||
let merged = PresetLibrary::merge(&base, &ours, &theirs);
|
||||
assert_eq!(merged.get("Warm"), Some(&exposure(1.5)));
|
||||
// And the other way round.
|
||||
let merged = PresetLibrary::merge(&base, &theirs, &ours);
|
||||
assert_eq!(merged.get("Warm"), Some(&exposure(1.5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_edit_outlives_a_deletion_made_elsewhere() {
|
||||
let base = library_of(&[("Warm", 1.0)]);
|
||||
let edited = library_of(&[("Warm", 1.5)]);
|
||||
let deleted = PresetLibrary::default();
|
||||
assert_eq!(
|
||||
PresetLibrary::merge(&base, &edited, &deleted).get("Warm"),
|
||||
Some(&exposure(1.5))
|
||||
);
|
||||
assert_eq!(
|
||||
PresetLibrary::merge(&base, &deleted, &edited).get("Warm"),
|
||||
Some(&exposure(1.5))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_different_edits_keep_ours_and_then_converge() {
|
||||
let base = library_of(&[("Warm", 1.0)]);
|
||||
let here = library_of(&[("Warm", 1.5)]);
|
||||
let there = library_of(&[("Warm", 0.5)]);
|
||||
let pushed = PresetLibrary::merge(&base, &here, &there);
|
||||
assert_eq!(pushed.get("Warm"), Some(&exposure(1.5)));
|
||||
// The other device's next exchange: its base is what it last pushed,
|
||||
// its own copy is unchanged since, and the server holds ours.
|
||||
let settled = PresetLibrary::merge(&there, &there, &pushed);
|
||||
assert_eq!(settled, pushed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_this_build_cannot_read_travel_with_their_preset() {
|
||||
let text = format!(
|
||||
"drpl {LIBRARY_FORMAT_VERSION}\n\n[preset Future]\nexposure.exposure = 0.5\n\
|
||||
something_new_entirely\n"
|
||||
);
|
||||
let theirs = PresetLibrary::parse(&text).unwrap();
|
||||
let merged = PresetLibrary::merge(
|
||||
&PresetLibrary::default(),
|
||||
&PresetLibrary::default(),
|
||||
&theirs,
|
||||
);
|
||||
assert!(merged.to_text().contains("something_new_entirely"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_neutral_preset_is_storable_and_survives_the_round_trip() {
|
||||
// The empty preset is the "clear these forty frames" action, so it has
|
||||
|
||||
Reference in New Issue
Block a user