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
|
||||
|
||||
@@ -605,6 +605,12 @@ until deleted or renamed. Shipped and imported presets change only the operation
|
||||
name, so a look applied to a corrected photograph keeps the correction; a copy or a saved
|
||||
edit replaces everything in scope.
|
||||
|
||||
The user's presets sync between devices through each library they open, as one file beside the
|
||||
camera profiles. Each name merges on its own against what the last exchange left both sides
|
||||
holding, so presets added on two devices both survive, a deletion on one reaches the other rather
|
||||
than being restored by it, and an edit outlives a deletion made elsewhere. The write is
|
||||
conditional on the server's copy, so two devices exchanging at once cannot save over each other.
|
||||
|
||||
**FR-DEV-7 — Before/after.** Compare current edit state against the unedited original or against
|
||||
a chosen history state.
|
||||
|
||||
@@ -2771,7 +2777,8 @@ every render path would have to remember to call it. They travel with the decode
|
||||
matrix does.
|
||||
|
||||
*What it costs.* Every DNG with an embedded profile renders differently; previews refresh only when
|
||||
rendered again; tablet and desktop release together. The profiles directory does not sync yet.
|
||||
rendered again; tablet and desktop release together. The profiles directory syncs through the
|
||||
library's derived folder (camera-profiles.md §13).
|
||||
|
||||
### D21 — DNG reference tone for raws · **DECIDED 2026-10-03**
|
||||
|
||||
|
||||
+33
-33
File diff suppressed because one or more lines are too long
@@ -96,6 +96,11 @@ pub struct SyncReport {
|
||||
/// (camera-profiles.md §13).
|
||||
pub profiles_uploaded: usize,
|
||||
pub profiles_downloaded: usize,
|
||||
/// TRACES: FR-DEV-6
|
||||
/// The develop presets: whether another device's changes reached this
|
||||
/// one's library, and whether this one's reached the server.
|
||||
pub presets_adopted: bool,
|
||||
pub presets_uploaded: bool,
|
||||
}
|
||||
|
||||
impl SyncReport {
|
||||
@@ -109,6 +114,7 @@ impl SyncReport {
|
||||
|| self.face_shards_downloaded > 0
|
||||
|| self.place_adopted
|
||||
|| self.profiles_downloaded > 0
|
||||
|| self.presets_adopted
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,12 +130,15 @@ pub enum SyncMessage {
|
||||
///
|
||||
/// Runs on its own thread with its own runtime, like every other network path
|
||||
/// here — the Slint loop must never block (NFR-P9).
|
||||
// One argument per thing the pass touches; see `run` below.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn spawn_sync(
|
||||
conn: Connection,
|
||||
root: String,
|
||||
thumbs_dir: PathBuf,
|
||||
catalog_path: PathBuf,
|
||||
place_path: PathBuf,
|
||||
presets: PresetFiles,
|
||||
scratch: PathBuf,
|
||||
// TRACES: FR-CULL-8
|
||||
// Which face pipeline's shards to export and adopt. From the settings
|
||||
@@ -165,6 +174,7 @@ pub fn spawn_sync(
|
||||
&thumbs_dir,
|
||||
&catalog_path,
|
||||
&place_path,
|
||||
&presets,
|
||||
&scratch,
|
||||
&face_model_id,
|
||||
&tx,
|
||||
@@ -184,7 +194,7 @@ pub fn spawn_sync(
|
||||
rx
|
||||
}
|
||||
|
||||
// Eight, because a sync touches eight distinct things — the same reason
|
||||
// Nine, because a sync touches nine distinct things — the same reason
|
||||
// `repairs::spawn` carries the allow: bundling them into a struct would name
|
||||
// nothing that exists.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -194,6 +204,7 @@ async fn run(
|
||||
thumbs_dir: &Path,
|
||||
catalog_path: &Path,
|
||||
place_path: &Path,
|
||||
presets: &PresetFiles,
|
||||
scratch: &Path,
|
||||
face_model_id: &str,
|
||||
tx: &std::sync::mpsc::Sender<SyncMessage>,
|
||||
@@ -240,6 +251,12 @@ async fn run(
|
||||
sync_profiles(backend, &base, &dir, &mut report).await;
|
||||
}
|
||||
|
||||
// TRACES: FR-DEV-6
|
||||
// The develop presets, a few kilobytes. Like the profiles, never fails
|
||||
// the pass.
|
||||
let _ = tx.send(SyncMessage::Status("checking presets…".into()));
|
||||
sync_presets(backend, &base, presets, &mut report).await;
|
||||
|
||||
// TRACES: FR-UI-8
|
||||
// Last, and it costs one small GET plus at most one small PUT. Last because
|
||||
// it is the only thing here that is not derived state and so the only thing
|
||||
@@ -1122,6 +1139,156 @@ async fn sync_profiles(
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-6
|
||||
/// Where this device keeps the preset library, and what the last exchange
|
||||
/// of it with this library left both sides holding.
|
||||
///
|
||||
/// The library is the device's, shared by every library it opens; the base
|
||||
/// is per library, because each library's server holds its own copy and has
|
||||
/// its own history of exchanges with this device.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PresetFiles {
|
||||
pub library: PathBuf,
|
||||
pub base: PathBuf,
|
||||
}
|
||||
|
||||
/// The preset library on the server, in its own folder so finding it is a
|
||||
/// listing of one file rather than of every shard beside it.
|
||||
const PRESETS_DIR: &str = "presets";
|
||||
const PRESETS_NAME: &str = "library.drpl";
|
||||
|
||||
/// TRACES: FR-DEV-6
|
||||
/// Exchange the develop presets with `<derived>/presets/library.drpl`.
|
||||
///
|
||||
/// Unlike the place, this is merged rather than replaced: a preset saved on
|
||||
/// the tablet and another saved on the desktop between two passes must both
|
||||
/// survive, and a preset deleted on one must not come back from the other.
|
||||
/// [`PresetLibrary::merge`](dr_pipeline::PresetLibrary::merge) decides each
|
||||
/// name against the base the last exchange left, which is what tells a
|
||||
/// deletion here from an addition there.
|
||||
///
|
||||
/// The write is conditional on the server still holding what was read, so
|
||||
/// two devices exchanging at once cannot each save over the other's
|
||||
/// additions; the one that loses the race reads again and merges again.
|
||||
/// Never fails the pass.
|
||||
async fn sync_presets(
|
||||
backend: &dyn RemoteBackend,
|
||||
base: &RemotePath,
|
||||
files: &PresetFiles,
|
||||
report: &mut SyncReport,
|
||||
) {
|
||||
for _ in 0..3 {
|
||||
match exchange_presets(backend, base, files, report).await {
|
||||
Err(RemoteError::PreconditionFailed) => {
|
||||
log::debug!("presets: another device wrote first; reading again");
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("not exchanging presets: {e}");
|
||||
return;
|
||||
}
|
||||
Ok(()) => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn exchange_presets(
|
||||
backend: &dyn RemoteBackend,
|
||||
base: &RemotePath,
|
||||
files: &PresetFiles,
|
||||
report: &mut SyncReport,
|
||||
) -> Result<(), RemoteError> {
|
||||
use crate::preset_store::PresetStore;
|
||||
use dr_pipeline::PresetLibrary;
|
||||
|
||||
let dir = RemotePath::new(format!("{}/{PRESETS_DIR}", base.as_str()));
|
||||
let target = RemotePath::new(format!("{}/{PRESETS_NAME}", dir.as_str()));
|
||||
|
||||
// A listing that fails reads as "not there". That is safe because the
|
||||
// write below is then `IfAbsent`, which a server holding one refuses.
|
||||
let listed = backend.list(&dir, None).await.ok().and_then(|entries| {
|
||||
entries
|
||||
.into_iter()
|
||||
.find(|e| e.kind == dr_sync::EntryKind::File && e.path.name() == PRESETS_NAME)
|
||||
});
|
||||
|
||||
let mut theirs = match &listed {
|
||||
None => PresetLibrary::default(),
|
||||
Some(_) => {
|
||||
let bytes = read_derived(backend, &target).await?;
|
||||
match std::str::from_utf8(&bytes)
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|t| PresetLibrary::parse(t).map_err(|e| e.to_string()))
|
||||
{
|
||||
Ok(library) => library,
|
||||
Err(e) => {
|
||||
// A newer build's format, or damage. Either way not ours
|
||||
// to write over.
|
||||
log::warn!("the preset library on the server will not read ({e}); left alone");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let store = PresetStore::open_at(files.library.clone());
|
||||
let read = match store.try_load() {
|
||||
Ok(library) => library.unwrap_or_default(),
|
||||
Err(e) => {
|
||||
// An empty library here would read as every preset deleted.
|
||||
log::warn!("not exchanging presets: {}: {e}", store.path().display());
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let base_store = PresetStore::open_at(files.base.clone());
|
||||
let last = base_store.try_load().ok().flatten().unwrap_or_default();
|
||||
|
||||
// Copies an older build seeded of the shipped presets are not the
|
||||
// photographer's, and are dropped on both sides before anything travels.
|
||||
let mut ours = read.clone();
|
||||
dr_pipeline::bundled::forget_unchanged_copies(&mut ours);
|
||||
dr_pipeline::bundled::forget_unchanged_copies(&mut theirs);
|
||||
|
||||
let merged = PresetLibrary::merge(&last, &ours, &theirs);
|
||||
|
||||
if listed.is_none() && merged.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if listed.is_none() || merged != theirs {
|
||||
let _ = backend.create_dir(&dir).await;
|
||||
let precondition = match &listed {
|
||||
Some(entry) => dr_sync::Precondition::IfMatch(entry.validator.clone()),
|
||||
None => dr_sync::Precondition::IfAbsent,
|
||||
};
|
||||
backend
|
||||
.put(&target, merged.to_text().into_bytes(), Some(precondition))
|
||||
.await?;
|
||||
report.presets_uploaded = true;
|
||||
}
|
||||
|
||||
if merged != ours {
|
||||
// The develop view saves this file too. A preset it saved since the
|
||||
// read above is merged in rather than written over; it reaches the
|
||||
// server on the next pass, as an addition against the base below.
|
||||
let local = match store.try_load() {
|
||||
Ok(Some(now)) if now != read => PresetLibrary::merge(&read, &merged, &now),
|
||||
_ => merged.clone(),
|
||||
};
|
||||
match store.save(&local) {
|
||||
Ok(()) => report.presets_adopted = true,
|
||||
Err(e) => {
|
||||
log::warn!("saving presets from the library: {e}");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
if merged != last {
|
||||
if let Err(e) = base_store.save(&merged) {
|
||||
log::debug!("recording the preset exchange: {e}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// TRACES: FR-UI-8
|
||||
/// Fetch just the place, for the handover at launch.
|
||||
///
|
||||
@@ -1934,6 +2101,99 @@ mod derived_guard_tests {
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
/// One device's preset files, under `root`.
|
||||
fn preset_device(root: &Path, name: &str) -> PresetFiles {
|
||||
PresetFiles {
|
||||
library: root.join(name).join("presets.drpl"),
|
||||
base: root.join(name).join("presets.base.drpl"),
|
||||
}
|
||||
}
|
||||
|
||||
fn preset_names(files: &PresetFiles) -> Vec<String> {
|
||||
crate::preset_store::PresetStore::open_at(files.library.clone())
|
||||
.load()
|
||||
.names()
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn save_presets(files: &PresetFiles, names: &[&str]) {
|
||||
let mut library = dr_pipeline::PresetLibrary::default();
|
||||
for name in names {
|
||||
library
|
||||
.insert(name, dr_pipeline::Preset::default())
|
||||
.unwrap();
|
||||
}
|
||||
crate::preset_store::PresetStore::open_at(files.library.clone())
|
||||
.save(&library)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn presets_reach_every_device_and_so_do_their_deletions() {
|
||||
// TRACES: FR-DEV-6
|
||||
let root = std::env::temp_dir().join(format!("dr-preset-sync-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let server = root.join("server");
|
||||
std::fs::create_dir_all(server.join(".darkroom-derived")).unwrap();
|
||||
let backend = dr_sync_folder::FolderBackend::new(&server).unwrap();
|
||||
let base = RemotePath::new(".darkroom-derived");
|
||||
let (desk, tablet) = (preset_device(&root, "desk"), preset_device(&root, "tablet"));
|
||||
|
||||
save_presets(&desk, &["Warm"]);
|
||||
save_presets(&tablet, &["Mono"]);
|
||||
for files in [&desk, &tablet, &desk] {
|
||||
sync_presets(&backend, &base, files, &mut SyncReport::default()).await;
|
||||
}
|
||||
assert_eq!(preset_names(&desk), vec!["Mono", "Warm"]);
|
||||
assert_eq!(preset_names(&tablet), vec!["Mono", "Warm"]);
|
||||
|
||||
// Deleted on the desk: gone from the tablet, not back on the desk.
|
||||
save_presets(&desk, &["Mono"]);
|
||||
for files in [&desk, &tablet, &desk] {
|
||||
sync_presets(&backend, &base, files, &mut SyncReport::default()).await;
|
||||
}
|
||||
assert_eq!(preset_names(&desk), vec!["Mono"]);
|
||||
assert_eq!(preset_names(&tablet), vec!["Mono"]);
|
||||
|
||||
// Settled: a pass with nothing new writes nothing.
|
||||
let mut quiet = SyncReport::default();
|
||||
sync_presets(&backend, &base, &tablet, &mut quiet).await;
|
||||
assert!(!quiet.presets_uploaded && !quiet.presets_adopted);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_preset_library_the_server_cannot_read_is_left_alone() {
|
||||
// TRACES: FR-DEV-6
|
||||
// A newer build's format reads as unreadable here, and is that build's
|
||||
// presets: neither written over nor taken as an empty library.
|
||||
let root = std::env::temp_dir().join(format!("dr-preset-unread-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let server = root.join("server");
|
||||
let held = server.join(".darkroom-derived/presets/library.drpl");
|
||||
std::fs::create_dir_all(held.parent().unwrap()).unwrap();
|
||||
std::fs::write(&held, "drpl 9999\n").unwrap();
|
||||
let backend = dr_sync_folder::FolderBackend::new(&server).unwrap();
|
||||
let desk = preset_device(&root, "desk");
|
||||
save_presets(&desk, &["Warm"]);
|
||||
|
||||
let mut report = SyncReport::default();
|
||||
sync_presets(
|
||||
&backend,
|
||||
&RemotePath::new(".darkroom-derived"),
|
||||
&desk,
|
||||
&mut report,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(!report.presets_uploaded);
|
||||
assert_eq!(std::fs::read_to_string(&held).unwrap(), "drpl 9999\n");
|
||||
assert_eq!(preset_names(&desk), vec!["Warm"]);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_place_that_could_not_be_read_is_never_written_over() {
|
||||
// A dehydrated placeholder, and the record on the server may well be
|
||||
|
||||
@@ -39,6 +39,20 @@ pub fn place_path(account: &Account) -> PathBuf {
|
||||
data_root().join(account.namespace()).join("place.json")
|
||||
}
|
||||
|
||||
/// TRACES: FR-DEV-6
|
||||
/// The preset library as this device and this library's server last agreed
|
||||
/// on it — the base `PresetLibrary::merge` decides deletions against.
|
||||
///
|
||||
/// Per library, beside the place, because each library's server keeps its
|
||||
/// own copy. In the data directory for the reason the place is: a base the
|
||||
/// system deleted would turn the next exchange into a union, and every
|
||||
/// preset deleted since the last one would come back.
|
||||
pub fn presets_base_path(account: &Account) -> PathBuf {
|
||||
data_root()
|
||||
.join(account.namespace())
|
||||
.join("presets.base.drpl")
|
||||
}
|
||||
|
||||
/// The directory every account's data hangs off.
|
||||
///
|
||||
/// **Not the cache directory, and on Android that distinction is the whole
|
||||
|
||||
@@ -290,6 +290,10 @@ pub struct LibraryController {
|
||||
/// old behaviour rather than a crash.
|
||||
pub(super) coll_ctl:
|
||||
RefCell<Option<std::rc::Weak<crate::collections_ui::CollectionsController>>>,
|
||||
/// TRACES: FR-DEV-6
|
||||
/// The develop view's preset list, so a sync that brought presets from
|
||||
/// another device can redraw it. `Weak` for the reason `coll_ctl` is.
|
||||
pub(crate) named_presets: RefCell<Option<std::rc::Weak<crate::presets::NamedPresets>>>,
|
||||
/// Timeline view state: how far zoomed in, and around what instant.
|
||||
///
|
||||
/// Zoom is a level rather than a span so the axis halves and doubles in
|
||||
@@ -497,6 +501,7 @@ impl LibraryController {
|
||||
applying_place: std::cell::Cell::new(false),
|
||||
viewing_trash: std::cell::Cell::new(false),
|
||||
coll_ctl: RefCell::new(None),
|
||||
named_presets: RefCell::new(None),
|
||||
timeline_zoom: RefCell::new(0),
|
||||
timeline_centre: RefCell::new(None),
|
||||
pinch_accum: RefCell::new(1.0),
|
||||
|
||||
@@ -123,6 +123,12 @@ pub(super) fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>
|
||||
library::thumbs_dir(&conn.account),
|
||||
catalog_path,
|
||||
library::place_path(&conn.account),
|
||||
crate::derived_sync::PresetFiles {
|
||||
library: crate::preset_store::PresetStore::open()
|
||||
.path()
|
||||
.to_path_buf(),
|
||||
base: library::presets_base_path(&conn.account),
|
||||
},
|
||||
scratch,
|
||||
ctl.face_model_id.borrow().clone(),
|
||||
);
|
||||
@@ -237,6 +243,20 @@ pub(super) fn start_derived_sync(window: &AppWindow, ctl: &Rc<LibraryController>
|
||||
}
|
||||
}
|
||||
}
|
||||
// TRACES: FR-DEV-6
|
||||
// Presets saved on another device, into the list the
|
||||
// develop view is drawing.
|
||||
if report.presets_adopted {
|
||||
let named = ctl_cb
|
||||
.named_presets
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.and_then(|n| n.upgrade());
|
||||
if let Some(named) = named {
|
||||
named.reload();
|
||||
crate::presets::render_named(&w, &named);
|
||||
}
|
||||
}
|
||||
stop(&ctl_cb.sync_timer);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -63,10 +63,9 @@ impl PresetStore {
|
||||
/// a preset, at which point they have chosen to. Nothing here deletes the
|
||||
/// file, and the warning names the path so it can be recovered by hand.
|
||||
pub fn load(&self) -> PresetLibrary {
|
||||
match std::fs::read_to_string(&self.path) {
|
||||
Ok(text) => match PresetLibrary::parse(&text) {
|
||||
Ok(library) => library,
|
||||
Err(e) => {
|
||||
match self.try_load() {
|
||||
Ok(library) => library.unwrap_or_default(),
|
||||
Err(PresetStoreError::Unreadable(e)) => {
|
||||
log::warn!(
|
||||
"{} is not a readable preset library ({e}); \
|
||||
starting empty, the file is left alone",
|
||||
@@ -74,8 +73,6 @@ impl PresetStore {
|
||||
);
|
||||
PresetLibrary::default()
|
||||
}
|
||||
},
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => PresetLibrary::default(),
|
||||
Err(e) => {
|
||||
log::warn!("reading {}: {e}; starting empty", self.path.display());
|
||||
PresetLibrary::default()
|
||||
@@ -83,6 +80,23 @@ impl PresetStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// The stored library, `None` when there is no file, or why it could not
|
||||
/// be read.
|
||||
///
|
||||
/// For a caller that must not mistake a file it could not read for an
|
||||
/// empty library. The sync is one: to it an empty library next to a
|
||||
/// non-empty one from the last exchange means "every preset was deleted",
|
||||
/// and it would carry that to every other device.
|
||||
pub fn try_load(&self) -> Result<Option<PresetLibrary>, PresetStoreError> {
|
||||
match std::fs::read_to_string(&self.path) {
|
||||
Ok(text) => PresetLibrary::parse(&text)
|
||||
.map(Some)
|
||||
.map_err(|e| PresetStoreError::Unreadable(e.to_string())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist the library, replacing whatever was there.
|
||||
pub fn save(&self, library: &PresetLibrary) -> Result<(), PresetStoreError> {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
@@ -105,6 +119,8 @@ impl PresetStore {
|
||||
pub enum PresetStoreError {
|
||||
#[error("preset library io: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("{0}")]
|
||||
Unreadable(String),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+86
-7
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user