Benchmarks / CPU and I/O (per commit) (push) Successful in 1m53s
Benchmarks / Frame budget (on demand) (push) Skipped
Build and test / Desktop (Linux) (push) Failing after 2m50s
Build and test / Layer separation (push) Successful in 33s
Traceability / Requirement traces (push) Successful in 40s
🐳 Android image / Build and push (push) Successful in 4s
🐳 Windows image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 4s
Build and test / windows-image (push) Successful in 1s
Build and test / Android (aarch64) (push) Successful in 14m19s
Build and test / Windows (x86_64, cross) (push) Successful in 17m50s
Build and test / Publish the release (push) Skipped
Rating keys in the grid follow darktable's rule: with the pointer over a photograph outside the selection, 0-5, P, X and U judge that photograph alone; over one inside it, the whole selection, as before; off the grid, the selection. The hover is cleared when the grid scrolls, so a key after a wheel turn cannot judge whatever used to be under the pointer. Holding F and tapping digits filters by stars: one digit for exactly that many, two for everything between them, F alone to show every rating again. The filter gains a ceiling to do it (`max_rating`, one BETWEEN in the query). The place record carries it, and a record from an older build reads as having none. The star chips light across a capped range and the bar says "2-3★ only" beside them. The grid also takes Ctrl+E and Ctrl+Shift+E for the selection, Ctrl+V to paste onto it and Ctrl+A to select all. The "Gestures" button is now "Help", its sheet "Controls and shortcuts", and F1 opens it.
360 lines
13 KiB
Rust
360 lines
13 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,
|
|
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,
|
|
}
|
|
}
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
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,
|
|
},
|
|
}
|
|
}
|
|
|
|
#[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,
|
|
};
|
|
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::<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
|
|
);
|
|
}
|
|
}
|