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.
206 lines
7.8 KiB
Rust
206 lines
7.8 KiB
Rust
//! TRACES: FR-DEV-6 | FR-PLAT-LIN-1
|
|
//! Reads and writes the named preset library beside the other config.
|
|
//!
|
|
//! A near-twin of [`SettingsStore`](crate::settings_store::SettingsStore), and
|
|
//! separate from it for the reason that one is: two files, two lifetimes.
|
|
//! Resetting preferences must not destroy a photographer's presets, and a
|
|
//! preset library is the one file here that represents work rather than
|
|
//! configuration — it is what someone would carry to another machine.
|
|
//!
|
|
//! # Why the library is loaded whole and saved whole
|
|
//!
|
|
//! There is no incremental path. The document is kilobytes (see
|
|
//! [`PresetLibrary`]), the caller is holding the copy the user just edited,
|
|
//! and a merge would let a preset the window has not drawn yet resurrect
|
|
//! after a delete. The same argument the settings store makes about fields,
|
|
//! made about entries.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use dr_pipeline::PresetLibrary;
|
|
|
|
/// Loads and saves the named preset library.
|
|
pub struct PresetStore {
|
|
path: PathBuf,
|
|
}
|
|
|
|
impl PresetStore {
|
|
/// Open the store at the platform config location.
|
|
///
|
|
/// Beside `settings.json`, in [`dr_sync::account::config_dir`], so a user
|
|
/// backing up one takes all of them. Never from `HOME` directly: Android
|
|
/// declares its data directory rather than setting `HOME`, and a path
|
|
/// built from an empty one is `/.config`, which is read-only — every
|
|
/// preset saved on the tablet failed. Windows sets no `HOME` either, and
|
|
/// got a directory relative to wherever the app was started.
|
|
pub fn open() -> Self {
|
|
Self::open_at(dr_sync::account::config_dir().join(format!(
|
|
"presets.{}",
|
|
dr_pipeline::preset::LIBRARY_EXTENSION
|
|
)))
|
|
}
|
|
|
|
/// Open at an explicit path — for tests, and for a non-default location.
|
|
pub fn open_at(path: PathBuf) -> Self {
|
|
Self { path }
|
|
}
|
|
|
|
pub fn path(&self) -> &Path {
|
|
&self.path
|
|
}
|
|
|
|
/// The stored library, or an empty one.
|
|
///
|
|
/// A missing file is a first run. An unparseable one is answered with an
|
|
/// empty library rather than an error, on the same reasoning the settings
|
|
/// store gives — the alternative is an app that will not start until the
|
|
/// user hand-edits a file.
|
|
///
|
|
/// The difference worth stating: settings are regenerated on the next
|
|
/// save, where a preset library is *work*, and rewriting it whole would
|
|
/// destroy whatever was in there. So a library that failed to parse is
|
|
/// held empty in memory and **not** written back over until the user saves
|
|
/// 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 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",
|
|
self.path.display()
|
|
);
|
|
PresetLibrary::default()
|
|
}
|
|
Err(e) => {
|
|
log::warn!("reading {}: {e}; starting empty", self.path.display());
|
|
PresetLibrary::default()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
|
|
// Write and rename, so an interrupted save cannot truncate the
|
|
// existing file — the same discipline the settings and session stores
|
|
// use, and it matters more here because what would be truncated is
|
|
// every preset the user has ever made rather than a set of
|
|
// preferences that rebuild themselves.
|
|
let tmp = self.path.with_extension("tmp");
|
|
std::fs::write(&tmp, library.to_text())?;
|
|
std::fs::rename(&tmp, &self.path)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum PresetStoreError {
|
|
#[error("preset library io: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
#[error("{0}")]
|
|
Unreadable(String),
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use dr_pipeline::Preset;
|
|
|
|
/// The same hand-rolled temp directory the settings store's tests use —
|
|
/// unique per process and thread, so a parallel run cannot collide.
|
|
fn tempdir(name: &str) -> PathBuf {
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"dr-presets-test-{name}-{}-{:?}",
|
|
std::process::id(),
|
|
std::thread::current().id()
|
|
));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
dir
|
|
}
|
|
|
|
fn store(name: &str) -> (PresetStore, PathBuf) {
|
|
let dir = tempdir(name);
|
|
(
|
|
PresetStore::open_at(dir.join("nested").join("presets.drpl")),
|
|
dir,
|
|
)
|
|
}
|
|
|
|
fn library() -> PresetLibrary {
|
|
let mut lib = PresetLibrary::default();
|
|
let mut params = std::collections::BTreeMap::new();
|
|
params.insert(("exposure".to_string(), "exposure".to_string()), 0.75);
|
|
lib.insert("Warm", Preset::from_params(params)).unwrap();
|
|
lib.insert("Neutral", Preset::default()).unwrap();
|
|
lib
|
|
}
|
|
|
|
#[test]
|
|
fn a_library_round_trips_through_the_store() {
|
|
let (store, _dir) = store("a-library-round-trips-through-the-store");
|
|
store.save(&library()).unwrap();
|
|
assert_eq!(store.load(), library());
|
|
}
|
|
|
|
#[test]
|
|
fn a_first_run_loads_an_empty_library() {
|
|
let (store, _dir) = store("a-first-run-loads-an-empty-library");
|
|
assert!(!store.path().exists());
|
|
assert!(store.load().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn saving_creates_the_config_directory() {
|
|
let (store, _dir) = store("saving-creates-the-config-directory");
|
|
store.save(&library()).unwrap();
|
|
assert!(store.path().exists());
|
|
}
|
|
|
|
#[test]
|
|
fn an_unreadable_file_is_left_alone_rather_than_overwritten() {
|
|
// The difference from the settings store, and the reason this test
|
|
// exists: what is on disk is work, so a parse failure must not be the
|
|
// moment it is destroyed.
|
|
let (store, _dir) = store("an-unreadable-file-is-left-alone-rather-than-overwritten");
|
|
std::fs::create_dir_all(store.path().parent().unwrap()).unwrap();
|
|
std::fs::write(store.path(), "this is not a preset library").unwrap();
|
|
|
|
assert!(store.load().is_empty());
|
|
assert_eq!(
|
|
std::fs::read_to_string(store.path()).unwrap(),
|
|
"this is not a preset library"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_neutral_preset_survives_a_save_and_load() {
|
|
// It is a real entry, not an absence — see `PresetLibrary::insert`.
|
|
let (store, _dir) = store("a-neutral-preset-survives-a-save-and-load");
|
|
store.save(&library()).unwrap();
|
|
assert_eq!(store.load().get("Neutral"), Some(&Preset::default()));
|
|
}
|
|
}
|