Export took a path typed into the settings page, or a folder inside the library on the server. The first is how exports end up somewhere nobody looks; the second put JPEGs into the tree a scan catalogues, where they came back as photographs beside the RAWs they were made from. The destination is now an album (FR-EXP-10), chosen by name in the export sheet. Albums are listed under the collections in the sidebar; "+" there, or "New album…" in the sheet, opens a sheet for its name and its folder — on this device through the platform's dialogue, or on the server through the browser with "New folder". A server folder inside the library is refused, and the sheet says why. Selecting an album narrows the grid to the photographs behind its files: library::Scope is Collection or Album, and scope_clause is the one place the two are spelled, which also retires the two copies of the collection predicate total_images_scoped and read_cells_scoped had inlined. A batch resolves the album when it starts, and refuses in words when none is chosen, it has gone, or its folder is local to another device. Each item reports the image it came from, and the files written are recorded against the album in one transaction when the batch ends. A server album lives outside the library, so its queued uploads are relative to the account root. That is a third line in the outbox's .dest record rather than a leading slash, because a record written before albums may carry a stray slash and must keep the meaning it was written with. An export folder set before albums becomes an album called "Exports" on first open, so upgrading does not lose where exports were going. The old destination fields stay in ExportSettings so older settings files still read.
1077 lines
41 KiB
Rust
1077 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);
|
|
}
|
|
}
|