Remember where the photographer was

Opening the application was always a fresh arrival at the beginning of
the library, whatever you had been doing when you closed it.

What is written down is the view, the scope, the rating filter and the
photograph on screen -- the open one in develop, the first visible one in
the grid. Not just a scroll position: a position without the filter that
produced it names a row of a list that no longer exists. Restoring them
has an order for the same reason -- scope, then filter, then position,
then the view -- because each step changes what an ordinal *means*.

Addressed by remote path and collection UUID, never by an ordinal or a
row id. `images.id` and `collections.id` are local to one catalog, and a
grid ordinal is local to one ordering; a record naming either would land
somewhere arbitrary on a second device and after any filter change on
this one. Where the ordinal is needed, `library::ordinal_of_path`
computes it through the grid's own `ORDER BY`, taken verbatim by a window
function rather than spelled a second time as an inequality -- which is
the mistake `grid_order_for` already warns about, and which a manually
ordered collection would make unreadable.

Every failure degrades rather than reports. A collection this device has
not merged leaves the scope at the whole library; a photograph that has
since been deleted falls back to when it was taken, which puts the grid
in the right week; a torn file yields no place and the library opens at
the top. Reopening develop is the one thing that requires an exact match,
because a canvas on a path that no longer resolves is a filename over an
empty frame.

The record lives in `dr-types` beside `Settings` and the store lives here
beside `SettingsStore`, for the reason `dr-types`' manifest gives: a JSON
serialiser in `core/` would be paid for by every crate there. Two files
and two lifetimes, though -- resetting preferences must not forget where
you were.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 20:39:50 +02:00
co-authored by Claude Opus 5
parent 8bf5e13faf
commit d44bffa4a8
10 changed files with 1380 additions and 26 deletions
+38
View File
@@ -552,6 +552,44 @@ pub fn kind(conn: &Connection, id: CollectionId) -> Result<Option<CollectionKind
Ok(found.map(CollectionKind::from_i64))
}
/// TRACES: FR-UI-8
/// The device-independent name of a collection, from its local id.
///
/// The pair to [`id_for_uuid`], and the reason both exist: `collections.id` is
/// an autoincrement local to one catalog, so anything that travels between
/// devices — a place, a merge — has to say which collection it means in the
/// only vocabulary they share.
///
/// `None` for a collection that is not there, or has been tombstoned. A caller
/// writing down a scope treats that as "the whole library", which is the
/// harmless direction: the alternative is recording a name nothing can resolve.
pub fn uuid_of(conn: &Connection, id: CollectionId) -> Result<Option<String>, CatalogError> {
Ok(conn
.query_row(
"SELECT uuid FROM collections WHERE id = ?1 AND deleted = 0",
[id.0 as i64],
|r| r.get::<_, String>(0),
)
.optional()?)
}
/// TRACES: FR-UI-8
/// The local id of a collection, from the name every device knows it by.
///
/// `None` where this device has never heard of it, or has deleted it — a place
/// recorded on the tablet inside a collection this machine has not yet merged.
/// The caller falls back to the whole library rather than to an empty grid.
pub fn id_for_uuid(conn: &Connection, uuid: &str) -> Result<Option<CollectionId>, CatalogError> {
Ok(conn
.query_row(
"SELECT id FROM collections WHERE uuid = ?1 AND deleted = 0",
[uuid],
|r| r.get::<_, i64>(0),
)
.optional()?
.map(|id| CollectionId(id as u64)))
}
/// A collection and everything beneath it, including itself.
///
/// Used for cycle checks and for scoping the grid to a parent: selecting a
+2
View File
@@ -9,11 +9,13 @@ use std::fmt;
use std::ops::Range;
pub mod colour;
pub mod place;
pub mod selector;
pub mod settings;
pub mod time;
pub use colour::{Chromaticities, Transfer};
pub use place::{Place, PlaceScope, Screen, StoredFilter};
pub use selector::{ColourLabel, DateSelector, FlagState, Selector, Tier};
pub use settings::{
CacheSettings, CollisionPolicy, ColourSpace, DevelopSettings, ExportFormat, ExportSettings,
+166
View File
@@ -0,0 +1,166 @@
//! TRACES: FR-UI-8 | FR-NC-3 | FR-CAT-6
//! Where the photographer was, so the next launch starts there.
//!
//! The record only. Reading and writing it is `dr_ui::place`'s job, for the
//! reason this crate's manifest gives about `settings.json`: a serialiser here
//! would be paid for by every crate in `core/`.
//!
//! # Why this is not a setting
//!
//! [`crate::settings`] holds preferences: how big a cache may grow, how
//! hard the grouping pass tries. Those are answers to questions about *this
//! device*, and its module doc says so. A place is not one of those. It is a
//! fact about a session — the collection that was open, the filter that was
//! narrowing it, the photograph on screen — and the whole point of recording it
//! is that it should follow the photographer to the tablet. Two records, two
//! lifetimes, and folding them together would give the sync one file holding
//! both a cache budget that must not travel and a position that must.
//!
//! # What a place is addressed by, and why it is not an ordinal
//!
//! A row number would be the obvious thing to store and it is worthless. The
//! grid is a window over a query whose ordering depends on the scope, the
//! rating filter and which bursts are folded up, so "row 8,412" names a
//! different photograph after any of those change — and on a second device,
//! which has its own catalog with its own `images.id` values, it names a
//! different photograph immediately. The same goes for `collections.id`, which
//! the schema itself calls local and colliding.
//!
//! So a place names things every device agrees on:
//!
//! - the photograph by its **remote path**, which is what the catalog's
//! `UNIQUE(root_id, source_ref)` is built on and what the sidecar, the
//! thumbnail store and the develop view all key on already;
//! - the collection by its **UUID**, which is what a catalog merge keys on;
//! - and, as a fallback, **when the photograph was taken**, so a place whose
//! photograph has since been moved or deleted still lands in the right week
//! instead of at the top of the library.
//!
//! # Last writer wins, and why that is enough
//!
//! Every record carries the instant it was made and the device that made it.
//! There is no merge to do: two devices cannot both be the place you are, and
//! the interesting question — "which of these did I do most recently" — is
//! answered by a timestamp. `device` is not used to resolve that. It is here so
//! a human reading the file, or a log line explaining why the grid moved, can
//! say *which* machine put it there.
//!
//! A clock-skewed device can therefore win an exchange it should have lost. The
//! cost is that the grid opens somewhere other than where you left it, which is
//! the state the application was in before any of this existed — so it is worth
//! a timestamp rather than the revision counter the collections merge needs,
//! where the same skew would silently discard an edit.
//!
//! # Every field is optional in practice
//!
//! `#[serde(default)]` throughout and no `version` field, the same shape and for
//! the same reason [`crate::settings`] gives: an older file is missing
//! fields rather than wrong about them, and a newer file read by an older build
//! ignores what it does not know. A place is also, unlike a setting, entirely
//! disposable — the worst a malformed one can do is open the library at the top.
use serde::{Deserialize, Serialize};
use crate::FlagState;
/// Which view the photographer was in.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Screen {
/// The grid. The default, and where an unreadable or partial record lands:
/// arriving in the library is never wrong, where arriving in develop on a
/// photograph that could not be resolved would be an empty canvas.
#[default]
Library,
Develop,
}
/// What the grid was narrowed to.
///
/// The trash is a variant rather than a collection with a reserved UUID, for
/// the reason `dr_ui::collections_ui` gives: it is not
/// a collection, and its query inverts the predicate every other view applies.
/// A sentinel id would put that inversion somewhere nothing reading a scope
/// expects it.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
pub enum PlaceScope {
/// The whole library.
#[default]
Library,
/// TRACES: FR-CAT-15
Trash,
/// One collection, by the UUID a merge keys on — never by `collections.id`,
/// which the schema calls local and colliding across devices.
Collection { uuid: String },
}
/// The rating filter, in a shape that can be written down.
///
/// A projection of [`RatingFilter`] rather than `Serialize` on the type itself.
/// That type is the one every query path threads and it gains terms as the grid
/// gains ways to be narrowed; deriving on it would make the on-disk format
/// hostage to a struct whose job is to describe a SQL predicate, and a term
/// added for a query would silently become part of a record two devices
/// exchange. Here the two are converted explicitly, so adding a term is a
/// decision about whether it travels.
///
/// `people` travels as ids, which are local like every other integer key here —
/// a person on this device is a different row number on the tablet. It is kept
/// anyway because dropping it would restore a place into a grid narrowed to
/// nobody, showing far *more* than the user left rather than less; the ids are
/// simply ignored where they do not resolve. Making people travel properly
/// needs a stable person identity, which the catalog does not yet have.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct StoredFilter {
pub min_rating: u8,
pub unjudged: bool,
pub flag: Option<FlagState>,
pub local_only: bool,
pub captured_from: Option<i64>,
pub captured_to: Option<i64>,
pub people: Vec<u64>,
/// Whether `people` is an intersection. A bool rather than the enum,
/// because `PeopleMode` lives in `library.rs` beside the query it shapes and
/// does not derive serde — and "all of them" is the only thing the second
/// variant means.
pub people_all: bool,
}
/// Where the photographer was, at the moment they were there.
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Place {
/// When this was recorded, in UTC seconds. The only thing that decides
/// which of two records wins — see the module doc.
pub at: i64,
/// Which device recorded it, for logs and for a human reading the file.
/// Never consulted to resolve a conflict.
pub device: String,
pub screen: Screen,
pub scope: PlaceScope,
/// The remote path of the photograph that was open, or — in the grid — the
/// first one visible. Empty means the top of whatever the scope is, which
/// is what a record made before anything was on screen says.
pub path: String,
/// When that photograph was taken, so a place survives its subject being
/// moved, renamed or deleted. `None` where the catalog had no date for it
/// yet, which is common early in a scan.
pub captured_at: Option<i64>,
pub filter: StoredFilter,
}
impl Place {
/// Whether this record should replace `other`.
///
/// Strictly newer, so a re-read of the same record does not count as a
/// change and an exchange between two idle devices settles instead of
/// ping-ponging. A record with no counterpart always wins.
pub fn supersedes(&self, other: Option<&Place>) -> bool {
match other {
Some(o) => self.at > o.at,
None => true,
}
}
}