//! TRACES: FR-UI-8 //! Reading and writing the place — where the photographer was. //! //! The record itself is [`dr_types::Place`], which is where the reasoning about //! what a place *is* lives: why it is not a setting, why it names photographs by //! remote path and collections by UUID rather than by any local integer, and why //! the newer of two records simply wins. This module is the half that touches a //! disk, split off for the reason `dr-types`' manifest gives about //! `settings.json`: a JSON serialiser in `core/` would be paid for by every //! crate there. //! //! A near-twin of [`crate::settings_store`] in shape, and deliberately not an //! extension of it. Two files, two lifetimes — signing out forgets a session and //! must not discard a cache budget; resetting preferences must not forget where //! you were. use std::path::PathBuf; use dr_types::{Place, StoredFilter}; use crate::library::{PeopleMode, RatingFilter}; impl From<&RatingFilter> for StoredFilter { fn from(f: &RatingFilter) -> Self { Self { min_rating: f.min_rating, max_rating: f.max_rating, unjudged: f.unjudged, flag: f.flag, local_only: f.local_only, captured_from: f.captured_from, captured_to: f.captured_to, people: f.people.clone(), people_all: matches!(f.people_mode, PeopleMode::All), eyes_open: f.eyes_open, label: f.label, } } } impl From<&StoredFilter> for RatingFilter { fn from(s: &StoredFilter) -> Self { Self { min_rating: s.min_rating, max_rating: s.max_rating.map(|m| m.min(5)), unjudged: s.unjudged, flag: s.flag, local_only: s.local_only, captured_from: s.captured_from, captured_to: s.captured_to, people: s.people.clone(), people_mode: if s.people_all { PeopleMode::All } else { PeopleMode::Any }, eyes_open: s.eyes_open, label: s.label, } } } /// Parse a record as it travels — the body of the file, local or remote. /// /// `None` rather than an error for anything unreadable. A place is advisory: /// the only thing lost by discarding one is that the grid opens at the top, and /// an application that refused to start because a position file was truncated /// would be trading a certainty for an inconvenience. The failure is logged so /// it is not silent. pub fn from_bytes(bytes: &[u8], whence: &str) -> Option { match serde_json::from_slice::(bytes) { Ok(p) => Some(p), Err(e) => { log::warn!("{whence} is not a readable place ({e}); ignoring it"); None } } } /// The bytes that travel, local file and server copy alike. pub fn to_bytes(place: &Place) -> Result, serde_json::Error> { serde_json::to_vec_pretty(place) } /// Loads and saves the place, beside the catalog it belongs to. /// /// One file per library rather than one file holding every library's place, /// because that is how everything else per-account is stored — the catalog, the /// thumbnail shards, the un-uploaded sidecars all hang off /// `Account::namespace()` — and because the file that travels to the server has /// to be exactly one library's anyway. A map would need splitting on the way /// out and merging on the way in, for no gain. /// /// In the *data* directory, not the cache one. `library::place_path` explains /// why that distinction is load-bearing on Android; here it matters less — /// losing a place costs a scroll — but a file that sat in a directory the /// system deletes at will would be one that never survived to be useful. pub struct PlaceStore { path: PathBuf, } impl PlaceStore { pub fn open_at(path: PathBuf) -> Self { Self { path } } /// The stored place, or `None` if there is not one. /// /// A missing file is a first run, not a failure, and neither is an /// unparseable one — see [`from_bytes`]. pub fn load(&self) -> Option { match std::fs::read(&self.path) { Ok(bytes) => from_bytes(&bytes, &self.path.display().to_string()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, Err(e) => { log::warn!("reading {}: {e}", self.path.display()); None } } } /// Write it, creating the directory if the account is new. /// /// Errors are logged and swallowed. This is called from a scroll settling, /// and a photographer cannot act on "the place could not be written" — the /// next scroll writes it again, and if the disk is genuinely gone there are /// louder failures already on screen. pub fn save(&self, place: &Place) { if let Some(dir) = self.path.parent() { if let Err(e) = std::fs::create_dir_all(dir) { log::warn!("creating {}: {e}", dir.display()); return; } } let bytes = match to_bytes(place) { Ok(b) => b, Err(e) => { log::warn!("serialising the place: {e}"); return; } }; if let Err(e) = std::fs::write(&self.path, bytes) { log::warn!("writing {}: {e}", self.path.display()); } } /// Take a record only if it is newer than what is here. /// /// The one operation the sync needs on the way in, and it is written here /// rather than at the call site so that "newer wins" has exactly one /// spelling. Returns whether the stored record changed. pub fn adopt(&self, incoming: &Place) -> bool { if !incoming.supersedes(self.load().as_ref()) { return false; } self.save(incoming); true } } #[cfg(test)] mod tests { use super::*; use dr_types::{PlaceScope, Screen}; fn dir(name: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("darkroom-place-test-{name}")); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); d } fn a_place(at: i64) -> Place { Place { at, device: "desktop".into(), screen: Screen::Develop, scope: PlaceScope::Collection { uuid: "0d1f-…-9c".into(), }, path: "2019/2019-04-03/DSC_1234.NEF".into(), captured_at: Some(1_554_300_000), filter: StoredFilter { min_rating: 3, max_rating: None, unjudged: false, flag: Some(dr_types::FlagState::Pick), local_only: true, captured_from: Some(1), captured_to: Some(2), people: vec![7, 9], people_all: true, eyes_open: true, label: Some(dr_types::ColourLabel::Green), }, } } #[test] fn round_trips_through_the_file() { let store = PlaceStore::open_at(dir("round-trip").join("place.json")); let place = a_place(1000); store.save(&place); assert_eq!(store.load(), Some(place)); } #[test] fn a_missing_file_is_a_first_run() { let store = PlaceStore::open_at(dir("missing").join("place.json")); assert_eq!(store.load(), None); } #[test] fn the_directory_is_created_on_the_way_out() { // A first launch against a new account has no data directory yet, and // the place is written before anything else in it has been. let store = PlaceStore::open_at(dir("mkdir").join("deep").join("er").join("place.json")); store.save(&a_place(1)); assert!(store.load().is_some()); } #[test] fn a_torn_file_is_ignored_rather_than_fatal() { let path = dir("torn").join("place.json"); std::fs::write(&path, b"{\"at\": 12, \"screen\":").unwrap(); let store = PlaceStore::open_at(path); assert_eq!(store.load(), None, "and the app opens at the top instead"); } #[test] fn a_file_from_a_newer_build_keeps_what_it_can() { // The additive-change contract: a field this build does not know must // not cost it the fields it does. let path = dir("newer").join("place.json"); std::fs::write( &path, br#"{"at": 42, "path": "a/b.NEF", "zoom_level": 3, "screen": "develop"}"#, ) .unwrap(); let got = PlaceStore::open_at(path).load().expect("still readable"); assert_eq!(got.at, 42); assert_eq!(got.path, "a/b.NEF"); assert_eq!(got.screen, Screen::Develop); } #[test] fn a_file_from_an_older_build_defaults_what_is_missing() { let path = dir("older").join("place.json"); std::fs::write(&path, br#"{"at": 42, "path": "a/b.NEF"}"#).unwrap(); let got = PlaceStore::open_at(path).load().expect("still readable"); assert_eq!(got.screen, Screen::Library, "the safe view to arrive in"); assert_eq!(got.scope, PlaceScope::Library); assert_eq!(got.filter, StoredFilter::default()); } #[test] fn the_newer_record_wins_in_both_directions() { let older = a_place(100); let newer = a_place(200); assert!(newer.supersedes(Some(&older))); assert!(!older.supersedes(Some(&newer))); assert!(older.supersedes(None), "anything beats nothing"); } #[test] fn an_equal_timestamp_settles_rather_than_ping_pongs() { // Two idle devices re-reading the same record must not each decide they // have something to say. let place = a_place(100); assert!(!place.supersedes(Some(&place.clone()))); } #[test] fn adopt_takes_only_what_is_newer() { let store = PlaceStore::open_at(dir("adopt").join("place.json")); store.save(&a_place(200)); let mut stale = a_place(100); stale.path = "somewhere/else.NEF".into(); assert!(!store.adopt(&stale)); assert_eq!(store.load().unwrap().at, 200, "ours is untouched"); let mut fresh = a_place(300); fresh.path = "somewhere/else.NEF".into(); assert!(store.adopt(&fresh)); assert_eq!(store.load().unwrap().path, "somewhere/else.NEF"); } #[test] fn a_record_from_another_device_is_taken_on_its_timestamp_alone() { // `device` is for reading the file, not for resolving a conflict: a // handover from the tablet is exactly the case this feature exists for, // and preferring our own record would defeat it. let store = PlaceStore::open_at(dir("device").join("place.json")); store.save(&a_place(100)); let mut theirs = a_place(101); theirs.device = "tablet".into(); assert!(store.adopt(&theirs)); assert_eq!(store.load().unwrap().device, "tablet"); } #[test] fn the_filter_survives_the_projection() { // The record and the query must narrow the grid by the same thing, or a // restored place shows a different set than the one that was left. let original = RatingFilter { min_rating: 4, max_rating: Some(4), unjudged: true, flag: Some(dr_types::FlagState::Reject), local_only: true, captured_from: Some(10), captured_to: Some(20), people: vec![3, 5, 8], people_mode: PeopleMode::All, eyes_open: true, label: Some(dr_types::ColourLabel::Purple), }; let back: RatingFilter = (&StoredFilter::from(&original)).into(); assert_eq!(back, original); let empty = RatingFilter::default(); let back: RatingFilter = (&StoredFilter::from(&empty)).into(); assert_eq!(back, empty); assert!(back.is_unfiltered()); } #[test] fn a_record_from_before_star_ranges_has_no_ceiling() { // TRACES: FR-UI-5 // Another device on an older build writes records without the field. let old: StoredFilter = serde_json::from_str(r#"{"min_rating":2}"#).unwrap(); let back: RatingFilter = (&old).into(); assert_eq!((back.min_rating, back.max_rating), (2, None)); } #[test] fn a_scope_travels_as_a_uuid() { // The guard against someone reaching for `collections.id`, which the // schema calls local and colliding across devices. let json = serde_json::to_string(&PlaceScope::Collection { uuid: "abc-123".into(), }) .unwrap(); assert!(json.contains("abc-123"), "{json}"); assert_eq!( serde_json::from_str::(&json).unwrap(), PlaceScope::Collection { uuid: "abc-123".into() } ); } #[test] fn the_trash_is_its_own_scope() { let json = serde_json::to_string(&PlaceScope::Trash).unwrap(); assert_eq!( serde_json::from_str::(&json).unwrap(), PlaceScope::Trash ); } }