A place recorded on the tablet should be where the desktop opens. Exchanged through `.darkroom-derived/place.json`, beside the thumbnail shards and the catalog snapshot. Newest timestamp wins outright: unlike the catalog this is replaced rather than merged, because two devices cannot both be where the photographer is and so there is nothing of theirs inside ours to preserve. It still refuses to upload over a copy it could not read, for a smaller version of the reason `sync_catalog` does: a record we have not compared against may be the newer one, and overwriting it would move the other device's photographer without ever having seen where they were. Last in the pass, and its failures are logged rather than reported. Everything else in that folder is *derived* -- a faster way to learn what the device could work out for itself -- so losing it costs time. A place is a fact only the other device knew, and losing it costs a scroll. A sync that ran out of connectivity should spend what it had on the shards. The full pass runs after a thumbnail sweep or when Sync is pressed, neither of which happens on an ordinary launch -- so a handover would arrive one launch late, which is one too many for a feature whose whole claim is picking up where you stopped. `spawn_place_fetch` is the small half: one GET of a few hundred bytes, started beside the scan. And it can still be refused. A handover is welcome on the way in and unwelcome once the photographer has started: a grid that jumped elsewhere mid-scroll because a round trip finally landed would have lost their place to the feature meant to keep it. Any scroll, scrub, scope change, filter or opened photograph closes the latch, and a record arriving after that is written to disk and takes effect next launch. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
343 lines
12 KiB
Rust
343 lines
12 KiB
Rust
//! 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,
|
|
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),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<&StoredFilter> for RatingFilter {
|
|
fn from(s: &StoredFilter) -> Self {
|
|
Self {
|
|
min_rating: s.min_rating,
|
|
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
|
|
},
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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<Place> {
|
|
match serde_json::from_slice::<Place>(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<Vec<u8>, 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<Place> {
|
|
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,
|
|
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,
|
|
},
|
|
}
|
|
}
|
|
|
|
#[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,
|
|
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,
|
|
};
|
|
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_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::<PlaceScope>(&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::<PlaceScope>(&json).unwrap(),
|
|
PlaceScope::Trash
|
|
);
|
|
}
|
|
}
|