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:
2026-10-04 00:50:09 -04:00
parent 26e50ae723
commit 83f0461ce7
9 changed files with 597 additions and 55 deletions
+86 -7
View File
@@ -723,6 +723,14 @@ pub fn wire(
pub struct NamedPresets {
store: PresetStore,
library: RefCell<PresetLibrary>,
/// The file as this window last read or wrote it.
///
/// The sync rewrites the file from another thread when presets arrive
/// from another device. Saving the in-memory copy over that would delete
/// them here, and the next exchange would carry the deletion everywhere.
/// So a save first checks whether the file still holds this, and merges
/// with it as the base if not (`PresetLibrary::merge`).
on_disk: RefCell<PresetLibrary>,
/// The category folders the photographer has opened, by key (see
/// [`PresetTree`]). Every folder starts closed, so a list of seventy
/// presets arrives as six lines; for the session, not saved, as the
@@ -752,16 +760,35 @@ impl NamedPresets {
// writes the file without them, and until then nothing is lost,
// because each one is identical to the preset now shipped in its
// place.
let mut library = store.load();
let on_disk = store.load();
let library = Self::shown(&on_disk);
Rc::new(Self {
store,
library: RefCell::new(library),
on_disk: RefCell::new(on_disk),
open: RefCell::default(),
})
}
/// What the window lists from what the file holds.
fn shown(on_disk: &PresetLibrary) -> PresetLibrary {
let mut library = on_disk.clone();
let forgotten = dr_pipeline::bundled::forget_unchanged_copies(&mut library);
if forgotten > 0 {
log::info!("{forgotten} seeded preset copies are now shipped presets");
}
Rc::new(Self {
store,
library: RefCell::new(library),
open: RefCell::default(),
})
library
}
/// TRACES: FR-DEV-6
/// Read the file again, after a sync brought presets from another device.
///
/// Every change is saved as it is made, so there is nothing in memory
/// this could discard.
pub fn reload(&self) {
let on_disk = self.store.load();
*self.library.borrow_mut() = Self::shown(&on_disk);
*self.on_disk.borrow_mut() = on_disk;
}
/// Read presets from a file or a folder and store them.
@@ -930,9 +957,22 @@ impl NamedPresets {
/// the write failed and leave what is on screen matching what is in
/// memory until the next successful save.
fn persist(&self, rollback: impl FnOnce(&mut PresetLibrary)) -> Result<(), SaveError> {
// A file that changed since it was read here was the sync; take what
// it brought rather than saving over it. One that will not read is
// left to the save, which says so.
if let Ok(Some(now)) = self.store.try_load() {
if now != *self.on_disk.borrow() {
let merged =
PresetLibrary::merge(&self.on_disk.borrow(), &self.library.borrow(), &now);
*self.library.borrow_mut() = merged;
}
}
let result = self.store.save(&self.library.borrow());
match result {
Ok(()) => Ok(()),
Ok(()) => {
*self.on_disk.borrow_mut() = self.library.borrow().clone();
Ok(())
}
Err(e) => {
rollback(&mut self.library.borrow_mut());
// Named, the way the settings page names its file: "could not
@@ -1188,6 +1228,7 @@ pub fn wire_named(
open,
} = develop;
*library.named_presets.borrow_mut() = Some(Rc::downgrade(&named));
render_named(window, &named);
// --- open and close a category folder ---------------------------------
@@ -1974,6 +2015,44 @@ mod tests {
assert_eq!(reloaded.names(), vec!["Warm".to_string()]);
}
#[test]
fn a_save_keeps_what_the_sync_wrote_since_the_window_read_the_file() {
// TRACES: FR-DEV-6
// The sync rewrites the file from its own thread. Saving the window's
// copy over it would delete what arrived, and the next exchange would
// carry that deletion to the device that sent it.
let (presets, dir) = named("sync-wrote-meanwhile");
let path = dir.join("presets.drpl");
let mut arrived = PresetLibrary::default();
arrived
.insert("From the tablet", Preset::default())
.unwrap();
PresetStore::open_at(path.clone()).save(&arrived).unwrap();
presets.insert("Warm", Preset::capture(&edited())).unwrap();
let reloaded = NamedPresets::open_at(path);
assert_eq!(
reloaded.names(),
vec!["From the tablet".to_string(), "Warm".to_string()]
);
}
#[test]
fn a_reload_lists_what_the_sync_brought() {
// TRACES: FR-DEV-6
let (presets, dir) = named("reload-after-sync");
let mut arrived = PresetLibrary::default();
arrived
.insert("From the tablet", Preset::default())
.unwrap();
PresetStore::open_at(dir.join("presets.drpl"))
.save(&arrived)
.unwrap();
presets.reload();
assert_eq!(presets.names(), vec!["From the tablet".to_string()]);
}
#[test]
fn a_saved_preset_carries_the_edit_it_captured() {
let (presets, _dir) = named("carries-the-edit");