rustfmt over the files the albums work touched, and the album merge's incoming row as a named struct rather than an eight-field tuple, which clippy's type_complexity refused.
1092 lines
41 KiB
Rust
1092 lines
41 KiB
Rust
//! Reading the grid's cells: the windowed read a scroll position asks
|
|
//! for, trash listing, and the ordinal a single photograph sits at.
|
|
|
|
use dr_catalog::Catalog;
|
|
|
|
use super::Scope;
|
|
#[cfg(test)]
|
|
use dr_sync::RemotePath;
|
|
#[cfg(test)]
|
|
use dr_thumbs::ThumbStore;
|
|
|
|
use super::filters::{RatingFilter, GRID_ORDER, TRASHED, TRASH_ORDER, VISIBLE, VISIBLE_UNALIASED};
|
|
#[cfg(test)]
|
|
use super::scan::persist;
|
|
#[cfg(test)]
|
|
use super::spans::total_images_filtered;
|
|
use super::spans::{grid_order_for, scope_clause};
|
|
#[cfg(test)]
|
|
use super::thumbnails_gen::ThumbnailReady;
|
|
|
|
/// TRACES: FR-CULL-5
|
|
/// The clause that hides the frames a collapsed burst is standing in for.
|
|
///
|
|
/// Subject to exactly the discipline [`VISIBLE`] is under, and for the same
|
|
/// reason: the header's count, the scrollbar's size, the run a shift-click
|
|
/// resolves and the ordinal a scrub lands on are four answers about one list.
|
|
/// A burst folded away in the cells but still counted in the total would leave
|
|
/// the grid ending in rows that draw nothing, with no clue why.
|
|
///
|
|
/// The predicate itself is `dr_catalog::bursts`'s, not this file's, so the
|
|
/// interface and the pass that writes the table cannot come to disagree about
|
|
/// what collapsed means.
|
|
///
|
|
/// A function rather than a constant because it has to name the image table,
|
|
/// and the grid aliases it as `i` where the timeline's queries do not. `image`
|
|
/// is a table name from this file and never anything a user supplied.
|
|
pub(super) fn uncollapsed(image: &str) -> String {
|
|
format!(" AND {}", dr_catalog::bursts::not_collapsed_away(image))
|
|
}
|
|
|
|
/// One grid cell's data, read from the catalog.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct LibraryCell {
|
|
pub image_id: i64,
|
|
pub name: String,
|
|
pub remote_path: String,
|
|
/// `oc:fileid`, the key the shared thumbnail store uses. `None` for an
|
|
/// image the scan found without a stable id.
|
|
pub file_id: Option<u64>,
|
|
/// File length, for bounds-checking a located preview range.
|
|
pub size: u64,
|
|
/// 0 = nothing, 1 = stat-only, 2 = full EXIF.
|
|
pub metadata_state: u8,
|
|
/// UTC seconds, once EXIF has been read.
|
|
pub captured_at: Option<i64>,
|
|
}
|
|
|
|
/// Read a window of cells out of the catalog.
|
|
///
|
|
/// Windowed rather than wholesale: a 17k-image library must not become 17k
|
|
/// rows in a Slint model (FR-CAT-4).
|
|
/// The unscoped form, kept as the name the tests and any future caller reach
|
|
/// for. The UI goes through [`read_cells_scoped`], because a collection may be
|
|
/// selected.
|
|
#[cfg(test)]
|
|
pub fn read_cells(
|
|
catalog: &Catalog,
|
|
offset: usize,
|
|
limit: usize,
|
|
) -> Result<Vec<LibraryCell>, dr_catalog::CatalogError> {
|
|
read_cells_scoped(catalog, None, &RatingFilter::default(), offset, limit)
|
|
}
|
|
|
|
/// Read a window of cells, optionally narrowed to one collection.
|
|
///
|
|
/// A collection *set* shows its descendants' images too — a parent whose
|
|
/// children hold everything would otherwise read as empty, which makes nesting
|
|
/// look broken. The id list comes from
|
|
/// [`dr_catalog::collections::descendants`], which is depth-guarded.
|
|
///
|
|
/// Ordering comes from [`grid_order_for`]: manual position for a single manual
|
|
/// collection, capture time for a set — because position is only meaningful
|
|
/// inside one collection, and this query also serves sets, where two children's
|
|
/// positions are unrelated integers.
|
|
pub fn read_cells_scoped(
|
|
catalog: &Catalog,
|
|
scope: Option<Scope>,
|
|
filter: &RatingFilter,
|
|
offset: usize,
|
|
limit: usize,
|
|
) -> Result<Vec<LibraryCell>, dr_catalog::CatalogError> {
|
|
if scope.is_none() {
|
|
return read_cells_all(catalog, filter, offset, limit);
|
|
}
|
|
|
|
let (clause, mut params) = scope_clause(catalog, scope)?;
|
|
let rated = filter.sql();
|
|
let folded = uncollapsed("i");
|
|
let (order, order_params) = grid_order_for(catalog, scope);
|
|
let sql = format!(
|
|
"SELECT {CELL_COLUMNS}
|
|
FROM images i
|
|
WHERE {VISIBLE}{rated}{folded}{clause}
|
|
{order}
|
|
LIMIT ? OFFSET ?"
|
|
);
|
|
|
|
// Bound in the order the `?`s appear: the scope's in the WHERE, then
|
|
// whatever the ORDER BY needs, then the window.
|
|
params.extend(order_params);
|
|
params.push(rusqlite::types::Value::Integer(limit as i64));
|
|
params.push(rusqlite::types::Value::Integer(offset as i64));
|
|
|
|
let mut rows = {
|
|
let mut stmt = catalog.connection().prepare(&sql)?;
|
|
let read = stmt
|
|
.query_map(rusqlite::params_from_iter(params.iter()), row_to_cell)?
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
read
|
|
};
|
|
attach_file_ids(catalog, &mut rows);
|
|
Ok(rows)
|
|
}
|
|
|
|
pub(super) fn read_cells_all(
|
|
catalog: &Catalog,
|
|
filter: &RatingFilter,
|
|
offset: usize,
|
|
limit: usize,
|
|
) -> Result<Vec<LibraryCell>, dr_catalog::CatalogError> {
|
|
let rated = filter.sql();
|
|
let folded = uncollapsed("i");
|
|
let mut rows = {
|
|
let mut stmt = catalog.connection().prepare(&format!(
|
|
"SELECT {CELL_COLUMNS}
|
|
FROM images i
|
|
WHERE {VISIBLE}{rated}{folded}
|
|
{GRID_ORDER}
|
|
LIMIT ?1 OFFSET ?2"
|
|
))?;
|
|
let read = stmt
|
|
.query_map(rusqlite::params![limit as i64, offset as i64], row_to_cell)?
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
read
|
|
};
|
|
attach_file_ids(catalog, &mut rows);
|
|
Ok(rows)
|
|
}
|
|
|
|
/// TRACES: FR-CAT-15
|
|
/// Read a window of *trashed* cells, newest deletion first.
|
|
///
|
|
/// Ordered by when it was trashed rather than by capture time, which is what
|
|
/// every other view sorts by. The question in the trash is "what did I just
|
|
/// delete?", not "when was this taken" — a mistaken delete is corrected within
|
|
/// seconds, and burying it among photographs from the same afternoon would make
|
|
/// the one row the user is looking for the hardest one to find.
|
|
///
|
|
/// The rating filter is deliberately not applied. It narrows a *culling* pass,
|
|
/// and a trash that hid rows because of a filter set elsewhere would look like
|
|
/// it had lost them.
|
|
pub fn read_trashed_cells(
|
|
catalog: &Catalog,
|
|
offset: usize,
|
|
limit: usize,
|
|
) -> Result<Vec<LibraryCell>, dr_catalog::CatalogError> {
|
|
let mut rows = {
|
|
let mut stmt = catalog.connection().prepare(&format!(
|
|
"SELECT {CELL_COLUMNS}
|
|
FROM images i
|
|
WHERE {TRASHED}
|
|
{TRASH_ORDER}
|
|
LIMIT ?1 OFFSET ?2"
|
|
))?;
|
|
let read = stmt
|
|
.query_map(rusqlite::params![limit as i64, offset as i64], row_to_cell)?
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
read
|
|
};
|
|
attach_file_ids(catalog, &mut rows);
|
|
Ok(rows)
|
|
}
|
|
|
|
/// TRACES: FR-CAT-15
|
|
/// How many images the trash view would list.
|
|
///
|
|
/// Counts exactly what [`read_trashed_cells`] lists — same predicate, no filter
|
|
/// — so the scrollbar and the header cannot disagree with the cells.
|
|
pub fn total_trashed(catalog: &Catalog) -> Result<usize, dr_catalog::CatalogError> {
|
|
let n: i64 = catalog.connection().query_row(
|
|
&format!("SELECT count(*) FROM images i WHERE {TRASHED}"),
|
|
[],
|
|
|r| r.get(0),
|
|
)?;
|
|
Ok(n as usize)
|
|
}
|
|
|
|
/// TRACES: FR-CAT-5
|
|
/// The ids of grid rows `first..=last`, in the order the grid lists them.
|
|
///
|
|
/// What a shift-click actually means. The gesture names two *ordinals* and the
|
|
/// photographs between them are mostly not loaded — the grid is a window of a
|
|
/// hundred or so over a library of twenty thousand — so a range answered from
|
|
/// the window selected the handful on screen and silently dropped the rest.
|
|
/// The catalog knows the whole run, and with the ordering indexed it is one
|
|
/// seek rather than a scan.
|
|
///
|
|
/// Bounded by the same predicates, the same filter and the same [`GRID_ORDER`]
|
|
/// the window itself is read with. An ordinal only names a photograph relative
|
|
/// to an ordering, so a range taken through any other one is a range through a
|
|
/// different library.
|
|
///
|
|
/// `trash` picks the trash view's list, which is the other thing the grid can
|
|
/// be showing and is ordered by deletion time rather than capture time.
|
|
///
|
|
/// An empty result means the run is empty or the query failed; callers treat
|
|
/// the two the same, because both leave the selection where it was.
|
|
pub fn read_ids_span(
|
|
catalog: &Catalog,
|
|
scope: Option<Scope>,
|
|
filter: &RatingFilter,
|
|
trash: bool,
|
|
first: usize,
|
|
last: usize,
|
|
) -> Result<Vec<dr_types::ImageId>, dr_catalog::CatalogError> {
|
|
let Some(count) = (last + 1).checked_sub(first) else {
|
|
return Ok(Vec::new());
|
|
};
|
|
|
|
let (sql, mut params) = if trash {
|
|
(
|
|
format!("SELECT i.id FROM images i WHERE {TRASHED} {TRASH_ORDER} LIMIT ? OFFSET ?"),
|
|
Vec::new(),
|
|
)
|
|
} else {
|
|
let (clause, mut params) = scope_clause(catalog, scope)?;
|
|
let rated = filter.sql();
|
|
let folded = uncollapsed("i");
|
|
// The same ordering the cells were drawn with, from the same place.
|
|
// A range is a pair of ordinals, and an ordinal read through a
|
|
// different ORDER BY names a different photograph.
|
|
let (order, order_params) = grid_order_for(catalog, scope);
|
|
params.extend(order_params);
|
|
(
|
|
format!(
|
|
"SELECT i.id FROM images i
|
|
WHERE {VISIBLE}{rated}{folded}{clause}
|
|
{order}
|
|
LIMIT ? OFFSET ?"
|
|
),
|
|
params,
|
|
)
|
|
};
|
|
params.push(rusqlite::types::Value::Integer(count as i64));
|
|
params.push(rusqlite::types::Value::Integer(first as i64));
|
|
|
|
let mut stmt = catalog.connection().prepare(&sql)?;
|
|
let ids = stmt
|
|
.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
|
Ok(dr_types::ImageId(r.get::<_, i64>(0)? as u64))
|
|
})?
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
Ok(ids)
|
|
}
|
|
|
|
/// TRACES: FR-UI-8
|
|
/// Where one photograph sits in the grid, by its remote path.
|
|
///
|
|
/// The inverse of [`read_ids_span`], and it exists for the same reason that one
|
|
/// does: an ordinal only names a photograph relative to an ordering, so it has
|
|
/// to be *computed* through the ordering the cells are drawn with rather than
|
|
/// guessed at. A restored place that landed on a row read through a different
|
|
/// `ORDER BY` would open the library at a photograph the user has never seen,
|
|
/// which looks exactly like the position having been forgotten.
|
|
///
|
|
/// # Why a window function rather than a count
|
|
///
|
|
/// The obvious implementation is "count the rows that sort before this one",
|
|
/// and it would need this file to spell the ordering out a second time — as an
|
|
/// inequality, with its own handling of the `captured_at IS NULL` term and its
|
|
/// own tie-break. [`grid_order_for`] already warns what a second spelling
|
|
/// costs, and a manually ordered collection makes it worse: that ordering is a
|
|
/// correlated subquery, and an inequality over it is not something anyone
|
|
/// should have to read.
|
|
///
|
|
/// `row_number() OVER ({order})` takes the ordering *verbatim* from the same
|
|
/// function the window read uses, so the two cannot disagree by construction.
|
|
/// It is a full pass over the scope rather than an index seek, which is the
|
|
/// cost of that guarantee — and it is paid once, at launch, against a query the
|
|
/// grid runs several times per screenful of scrolling.
|
|
///
|
|
/// `Ok(None)` means the path is not in this grid: deleted, trashed, filtered
|
|
/// out, or in a collection the place did not name. The caller falls back to
|
|
/// when the photograph was taken, which is what makes a place survive its
|
|
/// subject.
|
|
///
|
|
/// # Binding order
|
|
///
|
|
/// The window's parameters come first, unlike in [`read_ids_span`]. SQLite
|
|
/// binds anonymous `?` by their position **in the SQL text**, and here the
|
|
/// `OVER (...)` clause is in the select list — ahead of the `WHERE` the scope
|
|
/// narrows. Swapping the two silently looks up a collection by an image id.
|
|
pub fn ordinal_of_path(
|
|
catalog: &Catalog,
|
|
scope: Option<Scope>,
|
|
filter: &RatingFilter,
|
|
trash: bool,
|
|
path: &str,
|
|
) -> Result<Option<usize>, dr_catalog::CatalogError> {
|
|
let (inner, mut params) = if trash {
|
|
(
|
|
format!(
|
|
"SELECT i.source_ref AS sref,
|
|
row_number() OVER ({TRASH_ORDER}) - 1 AS ord
|
|
FROM images i
|
|
WHERE {TRASHED}"
|
|
),
|
|
Vec::new(),
|
|
)
|
|
} else {
|
|
let (clause, scope_params) = scope_clause(catalog, scope)?;
|
|
let rated = filter.sql();
|
|
let folded = uncollapsed("i");
|
|
let (order, mut params) = grid_order_for(catalog, scope);
|
|
// The window first, then the scope — see the note above.
|
|
params.extend(scope_params);
|
|
(
|
|
format!(
|
|
"SELECT i.source_ref AS sref,
|
|
row_number() OVER ({order}) - 1 AS ord
|
|
FROM images i
|
|
WHERE {VISIBLE}{rated}{folded}{clause}"
|
|
),
|
|
params,
|
|
)
|
|
};
|
|
params.push(rusqlite::types::Value::Text(path.to_string()));
|
|
|
|
let mut stmt = catalog
|
|
.connection()
|
|
.prepare(&format!("SELECT ord FROM ({inner}) WHERE sref = ?"))?;
|
|
let mut rows = stmt.query(rusqlite::params_from_iter(params.iter()))?;
|
|
match rows.next()? {
|
|
Some(r) => Ok(Some(r.get::<_, i64>(0)?.max(0) as usize)),
|
|
None => Ok(None),
|
|
}
|
|
}
|
|
|
|
/// The columns every windowed read selects, in the order [`row_to_cell`] reads
|
|
/// them.
|
|
///
|
|
/// Named rather than repeated so the three readers cannot drift — and so that
|
|
/// the one column that is *not* here stays conspicuous. See
|
|
/// [`attach_file_ids`] for why the server's file id is fetched separately.
|
|
pub(super) const CELL_COLUMNS: &str =
|
|
"i.id, i.source_ref, i.file_size, i.metadata_state, i.captured_at";
|
|
|
|
/// Shared row mapping, so the scoped and unscoped queries cannot drift.
|
|
///
|
|
/// `file_id` is left empty here and filled by [`attach_file_ids`].
|
|
pub(super) fn row_to_cell(r: &rusqlite::Row) -> rusqlite::Result<LibraryCell> {
|
|
let path: String = r.get(1)?;
|
|
Ok(LibraryCell {
|
|
image_id: r.get(0)?,
|
|
name: path.rsplit(['/', ':']).next().unwrap_or(&path).to_string(),
|
|
remote_path: path,
|
|
file_id: None,
|
|
size: r.get::<_, Option<i64>>(2)?.unwrap_or(0) as u64,
|
|
metadata_state: r.get::<_, i64>(3)? as u8,
|
|
captured_at: r.get(4)?,
|
|
})
|
|
}
|
|
|
|
/// TRACES: NFR-P5
|
|
/// Fill in each cell's server file id, in one query for the whole window.
|
|
///
|
|
/// # Why this is not a `LEFT JOIN` any more
|
|
///
|
|
/// It was, and it was the single most expensive thing the grid did while a
|
|
/// finger was on it. A window is `ORDER BY ... LIMIT n OFFSET k`, and SQLite
|
|
/// answers a join like that by joining *first* and paging after — so reading
|
|
/// 280 cells at offset 20,000 meant an index seek into `remote` for all 24,000
|
|
/// rows, 23,720 of which were then discarded. Measured at 15.2 ms, inside the
|
|
/// scroll handler, against a 16.7 ms frame.
|
|
///
|
|
/// Paging over `images` alone is 0.36 ms with `images_grid_order` (schema V7),
|
|
/// and this fetches the ids for the 280 rows that survived. The same shape the
|
|
/// badge and rating reads already use: one query for the window, never one per
|
|
/// cell.
|
|
///
|
|
/// Silent on failure, and cells keep `file_id: None`: that is the same state a
|
|
/// photograph the scan has not reached the server for is in, and the callers
|
|
/// already treat it as "no cached thumbnail to key on" rather than an error.
|
|
pub(super) fn attach_file_ids(catalog: &Catalog, cells: &mut [LibraryCell]) {
|
|
if cells.is_empty() {
|
|
return;
|
|
}
|
|
let placeholders = std::iter::repeat_n("?", cells.len())
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
let sql = format!("SELECT image_id, file_id FROM remote WHERE image_id IN ({placeholders})");
|
|
let params: Vec<rusqlite::types::Value> = cells
|
|
.iter()
|
|
.map(|c| rusqlite::types::Value::Integer(c.image_id))
|
|
.collect();
|
|
|
|
let mut stmt = match catalog.connection().prepare(&sql) {
|
|
Ok(s) => s,
|
|
Err(e) => {
|
|
log::debug!("reading file ids for the window: {e}");
|
|
return;
|
|
}
|
|
};
|
|
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
|
Ok((r.get::<_, i64>(0)?, r.get::<_, Option<i64>>(1)?))
|
|
});
|
|
let found: std::collections::HashMap<i64, Option<i64>> = match rows {
|
|
Ok(rows) => rows.flatten().collect(),
|
|
Err(e) => {
|
|
log::debug!("reading file ids for the window: {e}");
|
|
return;
|
|
}
|
|
};
|
|
for cell in cells {
|
|
cell.file_id = found
|
|
.get(&cell.image_id)
|
|
.copied()
|
|
.flatten()
|
|
.map(|v| v as u64);
|
|
}
|
|
}
|
|
|
|
/// Total images in the catalog, unfiltered.
|
|
///
|
|
/// What the scan reports and what the sidebar's "all images" row shows — the
|
|
/// size of the library itself, not of the current view.
|
|
pub fn total_images(catalog: &Catalog) -> Result<usize, dr_catalog::CatalogError> {
|
|
let n: i64 = catalog.connection().query_row(
|
|
&format!("SELECT count(*) FROM images WHERE {VISIBLE_UNALIASED}"),
|
|
[],
|
|
|r| r.get(0),
|
|
)?;
|
|
Ok(n as usize)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::library::test_support::*;
|
|
use dr_sync::RemoteEntry;
|
|
|
|
#[test]
|
|
fn a_scrub_ordinal_matches_the_grid_position() {
|
|
// The scrub's count and the grid's window must use *identical*
|
|
// predicates and ordering, or the view lands somewhere else. Counting
|
|
// only dated images against a grid that also shows undated ones put a
|
|
// click near the end of the axis near the top of the library.
|
|
let catalog = Catalog::in_memory().unwrap();
|
|
let c = catalog.connection();
|
|
c.execute(
|
|
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
// A mix: dated, undated, and one shadowed by a RAW sibling.
|
|
for (id, name, captured, shadow) in [
|
|
(1i64, "a.CR2", Some(100i64), None),
|
|
(2, "b.CR2", Some(200), None),
|
|
(3, "b.JPG", Some(200), Some(2i64)),
|
|
(4, "c.CR2", Some(300), None),
|
|
(5, "d.CR2", None, None),
|
|
] {
|
|
c.execute(
|
|
"INSERT INTO images(id, root_id, source_ref, captured_at, shadowed_by, added_at)
|
|
VALUES (?1, 1, ?2, ?3, ?4, 0)",
|
|
rusqlite::params![id, name, captured, shadow],
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
// The grid's own window, in its own order.
|
|
let cells = read_cells(&catalog, 0, 100).unwrap();
|
|
let names: Vec<&str> = cells.iter().map(|c| c.name.as_str()).collect();
|
|
assert_eq!(
|
|
names,
|
|
vec!["a.CR2", "b.CR2", "c.CR2", "d.CR2"],
|
|
"shadowed hidden, undated last"
|
|
);
|
|
|
|
// Scrubbing to each image's instant must give its index in that list.
|
|
for (when, expected) in [(100i64, 0usize), (200, 1), (300, 2)] {
|
|
let ordinal: i64 = c
|
|
.query_row(
|
|
"SELECT count(*) FROM images
|
|
WHERE shadowed_by IS NULL
|
|
AND captured_at IS NOT NULL
|
|
AND captured_at < ?1",
|
|
[when],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
ordinal as usize, expected,
|
|
"scrubbing to {when} must land on grid row {expected}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The fixture the ordinal tests share: a mix of dated, undated, shadowed
|
|
/// and trashed rows, which is what makes the grid's ordering non-obvious.
|
|
fn a_small_library() -> Catalog {
|
|
let catalog = Catalog::in_memory().unwrap();
|
|
let c = catalog.connection();
|
|
c.execute(
|
|
"INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
for (id, name, captured, shadow, trashed) in [
|
|
(1i64, "2019/a.CR2", Some(100i64), None, None),
|
|
(2, "2019/b.CR2", Some(200), None, None),
|
|
// Shadowed by its RAW sibling: never a row of the grid.
|
|
(3, "2019/b.JPG", Some(200), Some(2i64), None),
|
|
(4, "2020/c.CR2", Some(300), None, None),
|
|
// Undated sorts last, whatever its name.
|
|
(5, "2018/d.CR2", None, None, None),
|
|
// Trashed: out of the library, and the only row of the trash.
|
|
(6, "2020/e.CR2", Some(400), None, Some(9i64)),
|
|
] {
|
|
c.execute(
|
|
"INSERT INTO images(id, root_id, source_ref, captured_at, shadowed_by,
|
|
trashed_at, added_at)
|
|
VALUES (?1, 1, ?2, ?3, ?4, ?5, 0)",
|
|
rusqlite::params![id, name, captured, shadow, trashed],
|
|
)
|
|
.unwrap();
|
|
}
|
|
catalog
|
|
}
|
|
|
|
#[test]
|
|
fn every_cell_reports_the_ordinal_it_is_drawn_at() {
|
|
// TRACES: FR-UI-8
|
|
// The invariant the whole restore rests on, stated the strongest way
|
|
// there is: walk the window the grid actually draws and ask for each
|
|
// cell's ordinal by path. Anything less — a spot check, or a hand-built
|
|
// expectation — would pass while `ordinal_of_path` and `read_cells`
|
|
// disagreed about undated rows, shadowed rows or the tie-break, which
|
|
// is precisely where an ordering drifts.
|
|
let catalog = a_small_library();
|
|
let filter = RatingFilter::default();
|
|
let cells = read_cells(&catalog, 0, 100).unwrap();
|
|
assert_eq!(cells.len(), 4, "shadowed and trashed are not rows");
|
|
|
|
for (i, cell) in cells.iter().enumerate() {
|
|
assert_eq!(
|
|
ordinal_of_path(&catalog, None, &filter, false, &cell.remote_path).unwrap(),
|
|
Some(i),
|
|
"{} is drawn at row {i}",
|
|
cell.remote_path
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_photograph_that_is_not_in_the_grid_reports_nothing() {
|
|
// Deleted, never scanned, or hidden behind its RAW. All three are the
|
|
// same answer, and the caller falls back to the capture time — which is
|
|
// only reachable if this says `None` rather than guessing.
|
|
let catalog = a_small_library();
|
|
let filter = RatingFilter::default();
|
|
assert_eq!(
|
|
ordinal_of_path(&catalog, None, &filter, false, "2019/gone.CR2").unwrap(),
|
|
None,
|
|
"never heard of it"
|
|
);
|
|
assert_eq!(
|
|
ordinal_of_path(&catalog, None, &filter, false, "2019/b.JPG").unwrap(),
|
|
None,
|
|
"shadowed by its RAW"
|
|
);
|
|
assert_eq!(
|
|
ordinal_of_path(&catalog, None, &filter, false, "2020/e.CR2").unwrap(),
|
|
None,
|
|
"trashed"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_trash_is_ordinalled_through_its_own_list() {
|
|
// The trash orders by deletion time and lists exactly what the library
|
|
// excludes, so an ordinal taken through the library's ordering would
|
|
// name a different photograph — or, here, nothing at all.
|
|
let catalog = a_small_library();
|
|
let filter = RatingFilter::default();
|
|
assert_eq!(
|
|
ordinal_of_path(&catalog, None, &filter, true, "2020/e.CR2").unwrap(),
|
|
Some(0)
|
|
);
|
|
assert_eq!(
|
|
ordinal_of_path(&catalog, None, &filter, true, "2019/a.CR2").unwrap(),
|
|
None,
|
|
"a live photograph is not in the trash"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_filter_moves_the_ordinal_with_the_grid() {
|
|
// TRACES: FR-UI-8
|
|
// A place carries the filter it was recorded under *and* the position,
|
|
// and the second is only meaningful under the first. Restoring them in
|
|
// the wrong order — position, then filter — would land on a row of a
|
|
// list that no longer exists, which is the bug this pairing exists to
|
|
// rule out.
|
|
let catalog = a_small_library();
|
|
let c = catalog.connection();
|
|
// Three stars on the third photograph only.
|
|
c.execute(
|
|
"INSERT INTO versions(id, image_id, uuid, name, is_default, rating)
|
|
VALUES (1, 4, 'u4', 'default', 1, 3)",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
|
|
let unfiltered = RatingFilter::default();
|
|
assert_eq!(
|
|
ordinal_of_path(&catalog, None, &unfiltered, false, "2020/c.CR2").unwrap(),
|
|
Some(2)
|
|
);
|
|
|
|
let starred = RatingFilter {
|
|
min_rating: 3,
|
|
..RatingFilter::default()
|
|
};
|
|
assert_eq!(
|
|
ordinal_of_path(&catalog, None, &starred, false, "2020/c.CR2").unwrap(),
|
|
Some(0),
|
|
"the only survivor of the filter is the first row of it"
|
|
);
|
|
assert_eq!(
|
|
ordinal_of_path(&catalog, None, &starred, false, "2019/a.CR2").unwrap(),
|
|
None,
|
|
"filtered out, so it has no position in this grid"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_collection_is_ordinalled_through_its_own_scope() {
|
|
// The window's parameters are bound ahead of the scope's — see the note
|
|
// on `ordinal_of_path`. Getting that order wrong looks up a collection
|
|
// by an image id, which fails silently as "not in this grid".
|
|
let catalog = a_small_library();
|
|
let c = catalog.connection();
|
|
let coll =
|
|
dr_catalog::collections::create(c, "Trip", None, dr_catalog::CollectionKind::Manual)
|
|
.unwrap();
|
|
// The second and third photographs, out of order, so position matters.
|
|
dr_catalog::collections::add_images(c, coll, &[dr_types::ImageId(4), dr_types::ImageId(2)])
|
|
.unwrap();
|
|
|
|
let filter = RatingFilter::default();
|
|
let cells =
|
|
read_cells_scoped(&catalog, Some(Scope::Collection(coll)), &filter, 0, 100).unwrap();
|
|
assert_eq!(cells.len(), 2);
|
|
for (i, cell) in cells.iter().enumerate() {
|
|
assert_eq!(
|
|
ordinal_of_path(
|
|
&catalog,
|
|
Some(Scope::Collection(coll)),
|
|
&filter,
|
|
false,
|
|
&cell.remote_path
|
|
)
|
|
.unwrap(),
|
|
Some(i),
|
|
"{} is drawn at row {i} of the collection",
|
|
cell.remote_path
|
|
);
|
|
}
|
|
assert_eq!(
|
|
ordinal_of_path(
|
|
&catalog,
|
|
Some(Scope::Collection(coll)),
|
|
&filter,
|
|
false,
|
|
"2019/a.CR2"
|
|
)
|
|
.unwrap(),
|
|
None,
|
|
"not a member"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cells_are_windowed_not_wholesale() {
|
|
let catalog = Catalog::in_memory().unwrap();
|
|
let images: Vec<RemoteEntry> = (0..50)
|
|
.map(|i| entry(&format!("PhotosRaw/img{i:03}.CR2"), i as u64, 1000))
|
|
.collect();
|
|
let result = dr_sync::ScanResult {
|
|
images,
|
|
directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e"))],
|
|
progress: Default::default(),
|
|
sidecars: Vec::new(),
|
|
};
|
|
persist(&catalog, "PhotosRaw", &result).unwrap();
|
|
|
|
let page = read_cells(&catalog, 10, 5).unwrap();
|
|
assert_eq!(page.len(), 5);
|
|
assert_eq!(page[0].name, "img010.CR2");
|
|
}
|
|
|
|
/// TRACES: NFR-P5
|
|
/// The grid's window read must be answered by walking `images_grid_order`,
|
|
/// never by sorting the library into a temp b-tree.
|
|
///
|
|
/// This asserts on the *query plan* rather than on a duration, because the
|
|
/// failure has no other symptom: a `GRID_ORDER` edited out of step with the
|
|
/// index in schema V7, or a column added back into the paging query that
|
|
/// drags `remote` in again, both still return the right cells. They just
|
|
/// return them after sorting 24,000 rows, inside the scroll handler — which
|
|
/// is the jitter this pair was introduced to remove, and it would come back
|
|
/// silently.
|
|
#[test]
|
|
fn the_window_read_walks_the_ordering_index() {
|
|
let catalog = with_images(20);
|
|
// Including the burst clause, because the grid includes it: a
|
|
// predicate that quietly cost the ordering index would put the sort
|
|
// back and this is the only place that would notice.
|
|
let folded = uncollapsed("i");
|
|
let plan: Vec<String> = catalog
|
|
.connection()
|
|
.prepare(&format!(
|
|
"EXPLAIN QUERY PLAN
|
|
SELECT {CELL_COLUMNS} FROM images i
|
|
WHERE {VISIBLE}{folded}
|
|
{GRID_ORDER}
|
|
LIMIT 10 OFFSET 5"
|
|
))
|
|
.unwrap()
|
|
.query_map([], |r| r.get::<_, String>(3))
|
|
.unwrap()
|
|
.flatten()
|
|
.collect();
|
|
let plan = plan.join(" | ");
|
|
|
|
assert!(
|
|
plan.contains("images_grid_order"),
|
|
"the window read is not using the ordering index: {plan}"
|
|
);
|
|
assert!(
|
|
!plan.contains("TEMP B-TREE"),
|
|
"the window read is still sorting the whole library: {plan}"
|
|
);
|
|
assert!(
|
|
!plan.to_lowercase().contains("remote"),
|
|
"the window read is joining `remote` again, which pages the whole \
|
|
library before it discards it: {plan}"
|
|
);
|
|
}
|
|
|
|
/// The file ids still arrive, now that they come from a second query.
|
|
#[test]
|
|
fn a_window_still_carries_the_server_file_ids() {
|
|
let catalog = with_images(20);
|
|
let page = read_cells(&catalog, 5, 4).unwrap();
|
|
assert_eq!(page.len(), 4);
|
|
assert!(
|
|
page.iter().all(|c| c.file_id.is_some()),
|
|
"a cell lost its file id when the join was split out"
|
|
);
|
|
}
|
|
|
|
/// TRACES: FR-CULL-5
|
|
/// A folded burst takes rows out of the cells, the count and the range a
|
|
/// shift-click resolves — all three, together.
|
|
///
|
|
/// This is the test that would fail if the clause were added to four of
|
|
/// the five queries that need it. That failure has no other symptom: the
|
|
/// header claims images the grid will not draw, the scrollbar sizes itself
|
|
/// for rows that are not there, and neither number looks wrong on its own.
|
|
#[test]
|
|
fn folding_a_burst_takes_the_same_rows_out_of_every_answer() {
|
|
use dr_catalog::bursts::{self, Rules, Signature};
|
|
|
|
let catalog = with_images(4);
|
|
// Three of the four are one burst: a second apart, one signature.
|
|
let ids = image_ids(&catalog);
|
|
for (n, id) in ids.iter().enumerate() {
|
|
let hash = if n < 3 { 0xFF00 } else { 0x00FF };
|
|
catalog
|
|
.connection()
|
|
.execute(
|
|
"UPDATE images SET captured_at = ?2, camera = 'Canon EOS R5',
|
|
perceptual_hash = ?3
|
|
WHERE id = ?1",
|
|
rusqlite::params![id.0 as i64, 1_000 + n as i64, Signature(hash).to_stored()],
|
|
)
|
|
.unwrap();
|
|
}
|
|
bursts::regroup(catalog.connection(), Rules::default()).unwrap();
|
|
|
|
let filter = RatingFilter::default();
|
|
// Open, as a new burst is: nothing has been taken away yet.
|
|
assert_eq!(read_cells(&catalog, 0, 50).unwrap().len(), 4);
|
|
assert_eq!(total_images_filtered(&catalog, &filter).unwrap(), 4);
|
|
|
|
bursts::set_expanded(catalog.connection(), ids[0], false).unwrap();
|
|
|
|
let cells = read_cells(&catalog, 0, 50).unwrap();
|
|
assert_eq!(cells.len(), 2, "the folded frames are still in the cells");
|
|
assert_eq!(
|
|
total_images_filtered(&catalog, &filter).unwrap(),
|
|
cells.len(),
|
|
"the header's count and the cells disagree"
|
|
);
|
|
assert_eq!(
|
|
read_ids_span(&catalog, None, &filter, false, 0, 49)
|
|
.unwrap()
|
|
.len(),
|
|
cells.len(),
|
|
"a shift-click over the whole grid would select frames it cannot show"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cells_carry_the_file_id_the_thumbnail_store_keys_on() {
|
|
// Without this the store can never be hit: every launch would refetch
|
|
// every thumbnail over the network.
|
|
let catalog = Catalog::in_memory().unwrap();
|
|
let result = dr_sync::ScanResult {
|
|
images: vec![entry("PhotosRaw/a.CR2", 7777, 30_000_000)],
|
|
directories: vec![(RemotePath::new("PhotosRaw"), dr_sync::Validator::new("e"))],
|
|
progress: Default::default(),
|
|
sidecars: Vec::new(),
|
|
};
|
|
persist(&catalog, "PhotosRaw", &result).unwrap();
|
|
|
|
let cells = read_cells(&catalog, 0, 10).unwrap();
|
|
assert_eq!(cells[0].file_id, Some(7777));
|
|
}
|
|
|
|
#[test]
|
|
fn a_stored_thumbnail_survives_a_restart() {
|
|
// The end-to-end property the store exists for: encode, persist,
|
|
// reopen, decode. A second launch must not re-fetch.
|
|
let dir = std::env::temp_dir().join(format!("dr-ui-thumbs-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
|
|
let rgba: Vec<u8> = std::iter::repeat_n([90u8, 140, 200, 255], 32 * 32)
|
|
.flatten()
|
|
.collect();
|
|
{
|
|
let mut store = ThumbStore::open(&dir).unwrap();
|
|
let bytes = dr_thumbs::encode_rgba(32, 32, &rgba).unwrap();
|
|
store
|
|
.put(
|
|
4242,
|
|
dr_thumbs::ThumbSize::Grid,
|
|
&dr_thumbs::Thumbnail {
|
|
width: 32,
|
|
height: 32,
|
|
bytes,
|
|
},
|
|
)
|
|
.unwrap();
|
|
}
|
|
|
|
let store = ThumbStore::open(&dir).unwrap();
|
|
let stored = store
|
|
.get(4242, dr_thumbs::ThumbSize::Grid)
|
|
.unwrap()
|
|
.expect("persisted");
|
|
let (w, h, out) = dr_thumbs::decode_rgba(&stored.bytes).unwrap();
|
|
assert_eq!((w, h), (32, 32));
|
|
// Lossy, so compare approximately — a blue-ish pixel must stay blue.
|
|
assert!(out[2] > out[0], "channel order survived the round trip");
|
|
}
|
|
|
|
#[test]
|
|
fn a_store_hit_is_not_evidence_the_server_is_reachable() {
|
|
// The regression this guards: store hits were delivered as the same
|
|
// `Ready` the network path sends, and the UI took any `Ready` as proof
|
|
// of connectivity. A mostly-cached window then declared "back online"
|
|
// against a server that was down — clearing the banner and kicking off
|
|
// a sweep that immediately failed, on every scroll.
|
|
let dir = std::env::temp_dir().join(format!("dr-ui-provenance-{}", std::process::id()));
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
|
|
let rgba: Vec<u8> = std::iter::repeat_n([10u8, 20, 30, 255], 8 * 8)
|
|
.flatten()
|
|
.collect();
|
|
let mut store = ThumbStore::open(&dir).unwrap();
|
|
let bytes = dr_thumbs::encode_rgba(8, 8, &rgba).unwrap();
|
|
store
|
|
.put(
|
|
99,
|
|
dr_thumbs::ThumbSize::Grid,
|
|
&dr_thumbs::Thumbnail {
|
|
width: 8,
|
|
height: 8,
|
|
bytes,
|
|
},
|
|
)
|
|
.unwrap();
|
|
|
|
// The split in `spawn_thumbnails`: a hit decodes off local disk and is
|
|
// reported with `from_cache` set, which is what the reachability gate
|
|
// keys on.
|
|
let stored = store
|
|
.get(99, dr_thumbs::ThumbSize::Grid)
|
|
.unwrap()
|
|
.expect("stored");
|
|
let (width, height, rgba) = dr_thumbs::decode_rgba(&stored.bytes).unwrap();
|
|
let hit = ThumbnailReady {
|
|
row: 0,
|
|
width,
|
|
height,
|
|
rgba,
|
|
from_cache: true,
|
|
};
|
|
|
|
assert!(
|
|
hit.from_cache,
|
|
"a thumbnail read from the store must not be mistaken for a fetch"
|
|
);
|
|
|
|
// And the mechanism it feeds: an offline tracker must survive it.
|
|
let mut reach = dr_sync::Reachability::new();
|
|
let now = std::time::Instant::now();
|
|
reach.mark_unreachable("network error".into(), now);
|
|
|
|
if !hit.from_cache {
|
|
reach.mark_reachable(now);
|
|
}
|
|
assert!(
|
|
reach.is_offline(),
|
|
"replaying cached thumbnails must leave offline mode intact"
|
|
);
|
|
}
|
|
|
|
// --- the trash view (FR-CAT-15) ---------------------------------------
|
|
|
|
/// Mark one image trashed, at a given instant.
|
|
fn trash_at(catalog: &Catalog, path: &str, when: i64) {
|
|
let n = catalog
|
|
.connection()
|
|
.execute(
|
|
"UPDATE images SET trashed_at = ?1, trashed_from = source_ref
|
|
WHERE source_ref = ?2",
|
|
rusqlite::params![when, path],
|
|
)
|
|
.unwrap();
|
|
assert_eq!(n, 1, "fixture should have trashed exactly {path}");
|
|
}
|
|
|
|
#[test]
|
|
fn the_trash_lists_what_the_library_hides() {
|
|
// The whole point of the view: these rows exist and no other query in
|
|
// the application will show them.
|
|
let catalog = scanned(3);
|
|
trash_at(&catalog, "PhotosRaw/IMG_0002.CR2", 100);
|
|
|
|
let trashed = read_trashed_cells(&catalog, 0, 50).unwrap();
|
|
assert_eq!(trashed.len(), 1);
|
|
assert_eq!(trashed[0].remote_path, "PhotosRaw/IMG_0002.CR2");
|
|
|
|
// And it has left the library in the same move.
|
|
let live = read_cells_all(&catalog, &RatingFilter::default(), 0, 50).unwrap();
|
|
assert_eq!(live.len(), 2);
|
|
assert!(!live.iter().any(|c| c.remote_path.contains("IMG_0002")));
|
|
}
|
|
|
|
#[test]
|
|
fn an_untrashed_library_has_an_empty_trash() {
|
|
let catalog = scanned(3);
|
|
assert!(read_trashed_cells(&catalog, 0, 50).unwrap().is_empty());
|
|
assert_eq!(total_trashed(&catalog).unwrap(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn the_trash_count_agrees_with_the_cells_it_lists() {
|
|
// The header and the scrollbar are sized from the count while the grid
|
|
// draws the cells. Two predicates that drift leave the user scrolling
|
|
// through rows that are not there.
|
|
let catalog = scanned(5);
|
|
for (i, when) in [(1, 100), (3, 200), (5, 300)] {
|
|
trash_at(&catalog, &format!("PhotosRaw/IMG_{i:04}.CR2"), when);
|
|
}
|
|
|
|
assert_eq!(total_trashed(&catalog).unwrap(), 3);
|
|
assert_eq!(read_trashed_cells(&catalog, 0, 50).unwrap().len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn the_most_recently_trashed_image_is_listed_first() {
|
|
// A mistaken delete is corrected within seconds, so the row the user
|
|
// wants is the one they just made — not the oldest photograph.
|
|
let catalog = scanned(3);
|
|
trash_at(&catalog, "PhotosRaw/IMG_0001.CR2", 100);
|
|
trash_at(&catalog, "PhotosRaw/IMG_0002.CR2", 300);
|
|
trash_at(&catalog, "PhotosRaw/IMG_0003.CR2", 200);
|
|
|
|
let order: Vec<String> = read_trashed_cells(&catalog, 0, 50)
|
|
.unwrap()
|
|
.into_iter()
|
|
.map(|c| c.remote_path)
|
|
.collect();
|
|
assert_eq!(
|
|
order,
|
|
vec![
|
|
"PhotosRaw/IMG_0002.CR2".to_string(),
|
|
"PhotosRaw/IMG_0003.CR2".to_string(),
|
|
"PhotosRaw/IMG_0001.CR2".to_string(),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_shadowed_jpeg_is_not_listed_beside_the_raw_it_belongs_to() {
|
|
// The inversion applies to `trashed_at` only. Trashing a RAW takes its
|
|
// sibling JPEG with it, and listing both would offer to restore the
|
|
// same frame twice.
|
|
let catalog = scanned(2);
|
|
let c = catalog.connection();
|
|
let raw: i64 = c
|
|
.query_row(
|
|
"SELECT id FROM images WHERE source_ref = 'PhotosRaw/IMG_0001.CR2'",
|
|
[],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
c.execute(
|
|
"UPDATE images SET shadowed_by = ?1 WHERE source_ref = 'PhotosRaw/IMG_0002.CR2'",
|
|
[raw],
|
|
)
|
|
.unwrap();
|
|
|
|
trash_at(&catalog, "PhotosRaw/IMG_0001.CR2", 100);
|
|
trash_at(&catalog, "PhotosRaw/IMG_0002.CR2", 100);
|
|
|
|
let trashed = read_trashed_cells(&catalog, 0, 50).unwrap();
|
|
assert_eq!(trashed.len(), 1, "one photograph, not two");
|
|
assert_eq!(trashed[0].remote_path, "PhotosRaw/IMG_0001.CR2");
|
|
assert_eq!(total_trashed(&catalog).unwrap(), 1, "and the count agrees");
|
|
}
|
|
|
|
#[test]
|
|
fn the_trash_window_pages_like_the_grid_does() {
|
|
// The trash uses the same windowed read as the library, so a large one
|
|
// must not try to draw itself in a single query.
|
|
let catalog = scanned(6);
|
|
for i in 1..=6 {
|
|
trash_at(
|
|
&catalog,
|
|
&format!("PhotosRaw/IMG_{i:04}.CR2"),
|
|
100 + i as i64,
|
|
);
|
|
}
|
|
|
|
let first = read_trashed_cells(&catalog, 0, 2).unwrap();
|
|
let second = read_trashed_cells(&catalog, 2, 2).unwrap();
|
|
assert_eq!(first.len(), 2);
|
|
assert_eq!(second.len(), 2);
|
|
assert!(
|
|
first
|
|
.iter()
|
|
.all(|a| !second.iter().any(|b| b.image_id == a.image_id)),
|
|
"pages must not overlap"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_rating_filter_does_not_hide_anything_in_the_trash() {
|
|
// The filter narrows a culling pass. A trash that dropped rows because
|
|
// of a filter set elsewhere would look like it had lost them.
|
|
let catalog = scanned(3);
|
|
trash_at(&catalog, "PhotosRaw/IMG_0001.CR2", 100);
|
|
|
|
// Nothing here is rated, so a four-star library filter would empty any
|
|
// view that honoured it.
|
|
let strict = RatingFilter {
|
|
min_rating: 4,
|
|
..Default::default()
|
|
};
|
|
assert!(read_cells_all(&catalog, &strict, 0, 50).unwrap().is_empty());
|
|
assert_eq!(read_trashed_cells(&catalog, 0, 50).unwrap().len(), 1);
|
|
}
|
|
}
|