Files
DarkRoom/ui/dr-ui/src/place.rs
T
dtourolle 00c028c8c8
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
Rate under the pointer, filter a star range, and name Help as help
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.
2026-09-24 05:11:36 +02:00

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
);
}
}