Files
DarkRoom/ui/dr-ui/src/library/spans.rs
T
dtourolle 7cbcacc02e Export to an album instead of a folder in the settings
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.
2026-09-26 14:13:53 -04:00

979 lines
39 KiB
Rust

//! Scoping the grid to a collection, spanning and reordering a manual
//! collection's membership, and the capture-time histogram.
use dr_catalog::Catalog;
use super::Scope;
use super::cells::uncollapsed;
#[cfg(test)]
use super::cells::{read_cells_all, read_cells_scoped, read_ids_span};
use super::filters::{RatingFilter, GRID_ORDER, VISIBLE};
/// Total images in the catalog, or in one collection and its descendants.
///
/// Counts exactly what [`read_cells_scoped`] would list, filter included. The
/// two must agree: the header says "412 images" and the grid's scrollbar is
/// sized from the same number, so a count that ignored the filter would leave
/// the user scrolling through empty rows.
pub fn total_images_scoped(
catalog: &Catalog,
scope: Option<Scope>,
filter: &RatingFilter,
) -> Result<usize, dr_catalog::CatalogError> {
if scope.is_none() {
return total_images_filtered(catalog, filter);
}
let (clause, params) = scope_clause(catalog, scope)?;
let rated = filter.sql();
// DISTINCT: an image in both a parent and a child is one photograph, and a
// count that disagrees with the number of cells drawn is worse than either
// number alone.
//
// Counted through `images` rather than over the membership table alone, so
// `VISIBLE` applies — a trashed photograph is still a member row, and
// counting it made the header claim images the grid would not draw.
let folded = uncollapsed("i");
let sql = format!(
"SELECT count(DISTINCT i.id) FROM images i
WHERE {VISIBLE}{rated}{folded}{clause}"
);
let n: i64 =
catalog
.connection()
.query_row(&sql, rusqlite::params_from_iter(params.iter()), |r| {
r.get(0)
})?;
Ok(n as usize)
}
/// TRACES: FR-CAT-7
/// Every member of `scope`, in the order its positions put them.
///
/// The *whole* membership, not the window and not the filtered view. A reorder
/// rewrites positions, and [`dr_catalog::collections::set_order`] only touches
/// the rows it is given — so writing back a filtered subset would leave the
/// images the filter is hiding at their old positions, interleaved with the new
/// ones arbitrarily. The user reorders what they can see; the rows they cannot
/// keep their place relative to it.
pub fn read_member_order(
catalog: &Catalog,
scope: dr_types::CollectionId,
) -> Result<Vec<dr_types::ImageId>, dr_catalog::CatalogError> {
let mut stmt = catalog.connection().prepare(
"SELECT image_id FROM collection_members
WHERE collection_id = ?1
ORDER BY position ASC, image_id ASC",
)?;
let ids = stmt
.query_map([scope.0 as i64], |r| {
Ok(dr_types::ImageId(r.get::<_, i64>(0)? as u64))
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(ids)
}
/// TRACES: FR-CAT-7
/// `current` with `moving` lifted out and set down beside `target`.
///
/// `target` names a *photograph*, not an index, and that is the point: the grid
/// may be filtered, so the cell the user dropped on sits at one position in
/// what they can see and another in the membership being rewritten. An id
/// survives both. `after` puts the run on the far side of it, which is the only
/// way to name the last place in a collection — there is no cell beyond the
/// last one to drop in front of.
///
/// The run keeps the order `current` has it in rather than the order the
/// selection was built in: the user is looking at the grid, and a selection
/// gathered by tapping the last frame first should not reverse itself on being
/// moved.
///
/// A `target` that is itself being moved leaves the run at the end. There is no
/// gap between a run and itself to land in, so the caller refuses that drop
/// before it gets here; this is what the function does rather than panicking if
/// one ever arrives.
///
/// Pure, so the awkward half of a drag can be tested without a window.
pub fn reordered(
current: &[dr_types::ImageId],
moving: &[dr_types::ImageId],
target: dr_types::ImageId,
after: bool,
) -> Vec<dr_types::ImageId> {
let lifting: std::collections::BTreeSet<_> = moving.iter().copied().collect();
let rest: Vec<_> = current
.iter()
.copied()
.filter(|id| !lifting.contains(id))
.collect();
let run: Vec<_> = current
.iter()
.copied()
.filter(|id| lifting.contains(id))
.collect();
// Resolved against `rest`, not against `current`: the run has already been
// lifted, so an index into the original list would be off by however many
// of it sat ahead of the target.
let at = match rest.iter().position(|id| *id == target) {
Some(at) if after => at + 1,
Some(at) => at,
None => rest.len(),
};
let mut out = Vec::with_capacity(current.len());
out.extend_from_slice(&rest[..at]);
out.extend(run);
out.extend_from_slice(&rest[at..]);
out
}
/// TRACES: FR-CAT-7
/// The ORDER BY the grid reads `scope` with, and the parameters it binds.
///
/// Manual position where the grid is scoped to a single manual collection with
/// no children; [`GRID_ORDER`] — capture time, then filename — everywhere else.
///
/// **Why the narrowing.** `position` is a column of `collection_members`, so it
/// only exists relative to one collection. A collection *set* shows its
/// descendants' images too, and two children's positions are unrelated integers
/// that would interleave arbitrarily; a smart collection has no member rows to
/// carry a position at all. Outside those cases there is no manual order to
/// read, and falling back is the only honest answer.
///
/// **Why every reader must agree.** An ordinal only names a photograph relative
/// to an ordering. The window read and the span read are two halves of one
/// grid: a shift-click resolved through a different ORDER BY than the cells
/// were drawn with selects a different run than the one on screen, and the user
/// finds out when the export runs. That is the same invariant
/// [`read_ids_span`] already states about `GRID_ORDER`, widened to cover the
/// case where the ordering depends on the scope.
///
/// A correlated subquery rather than a join, so the FROM and WHERE the two
/// readers already share are untouched: position is looked up per row through
/// `collection_members`' primary key, which is `(collection_id, image_id)`.
pub(super) fn grid_order_for(
catalog: &Catalog,
scope: Option<Scope>,
) -> (String, Vec<rusqlite::types::Value>) {
// An album has no order of its own to keep: it is a record of what was
// exported, and capture time is how its photographs read best.
let Some(Scope::Collection(id)) = scope else {
return (GRID_ORDER.to_string(), Vec::new());
};
// A set orders by capture time. `descendants` includes the collection
// itself, so one entry means it has no children.
let alone = dr_catalog::collections::descendants(catalog.connection(), id)
.map(|d| d.len() == 1)
.unwrap_or(false);
let manual = matches!(
dr_catalog::collections::kind(catalog.connection(), id),
Ok(Some(dr_catalog::collections::CollectionKind::Manual))
);
if !alone || !manual {
return (GRID_ORDER.to_string(), Vec::new());
}
// `i.id` breaks the tie. Positions are dense after a `set_order`, but a
// collection that has never been reordered by hand has whatever
// `add_images` assigned, and two rows can share a position if a merge from
// another device brought one in — an ordering that is not total is an
// ordering the window read and the span read can disagree about.
(
"ORDER BY (SELECT cm.position FROM collection_members cm
WHERE cm.collection_id = ? AND cm.image_id = i.id) ASC,
i.id ASC"
.to_string(),
vec![rusqlite::types::Value::Integer(id.0 as i64)],
)
}
/// The SQL restricting a query to `scope`, with the bound parameters to go
/// with it: a collection and its descendants, or the photographs an album's
/// files were exported from.
///
/// Every scoped reader goes through this, so the count, the cells, the span
/// and the histogram cannot drift: an axis drawn over one set of images and
/// bars counted over another puts the bars in the wrong place, and a header
/// count that disagrees with the cells drawn leaves empty rows to scroll.
pub(super) fn scope_clause(
catalog: &Catalog,
scope: Option<Scope>,
) -> Result<(String, Vec<rusqlite::types::Value>), dr_catalog::CatalogError> {
match scope {
None => Ok((String::new(), Vec::new())),
Some(Scope::Collection(scope)) => {
let ids = dr_catalog::collections::descendants(catalog.connection(), scope)?;
// Placeholders are generated from the *count* of ids, never from
// user text.
let placeholders = std::iter::repeat_n("?", ids.len())
.collect::<Vec<_>>()
.join(",");
Ok((
format!(
" AND i.id IN (SELECT image_id FROM collection_members
WHERE collection_id IN ({placeholders}))"
),
ids.iter()
.map(|c| rusqlite::types::Value::Integer(c.0 as i64))
.collect(),
))
}
Some(Scope::Album(album)) => {
// The table exists only once an album has; an album scope with no
// table behind it has nothing in it, and the query must still run.
dr_catalog::albums::ensure_tables(catalog.connection())?;
Ok((
" AND i.id IN (SELECT image_id FROM album_exports WHERE album_id = ?)".to_string(),
vec![rusqlite::types::Value::Integer(album.0 as i64)],
))
}
}
}
/// Earliest and latest capture time within `scope`, honouring the filter.
///
/// The timeline's extent. Taken over the same images the histogram counts, so
/// opening a collection shows that collection's years rather than the whole
/// library's — the axis was previously spanning everything, which left a
/// collection's bars crushed into a sliver of it.
pub fn span_scoped(
catalog: &Catalog,
scope: Option<Scope>,
filter: &RatingFilter,
) -> Option<(i64, i64)> {
let (clause, params) = scope_clause(catalog, scope).ok()?;
// Full extent, not the chosen range — see `without_date_range`.
let rated = filter.without_date_range().sql();
let sql = format!(
"SELECT min(i.captured_at), max(i.captured_at) FROM images i
WHERE {VISIBLE}{rated} AND i.captured_at IS NOT NULL{clause}"
);
catalog
.connection()
.query_row(&sql, rusqlite::params_from_iter(params.iter()), |r| {
Ok((r.get::<_, Option<i64>>(0)?, r.get::<_, Option<i64>>(1)?))
})
.ok()
.and_then(|(lo, hi)| Some((lo?, hi?)))
}
/// TRACES: FR-CAT-6
/// The capture-time histogram as a fixed number of equal bins across
/// `from..=to`, empty ones included.
///
/// # Why not calendar buckets
///
/// [`timeline_scoped`] groups by year, month, day or hour, which has two
/// consequences the axis cannot live with.
///
/// It emits **only the buckets that hold photographs**, and the widget gives
/// every bar an equal slot — so a library with a gap in it drew a February
/// that was six months wide. The position marker, the range band and a click
/// are all linear in time, so on a sparse library they pointed at bars that
/// were somewhere else. Equal bins including the empty ones make a bar's
/// position on the track and the date under it the same quantity.
///
/// And the **count is free to jump by a factor of twelve** between one unit
/// and the next, so each zoom step halved the number of bars until a
/// threshold was crossed: zooming in made the picture coarser, twice out of
/// every three steps. A fixed count re-bins on every zoom instead, which is
/// what makes each step show finer structure rather than the same structure
/// drawn wider.
///
/// The date range is lifted from the filter, like the bars' other terms are
/// not: this histogram is *how a range is chosen*, and drawing through the
/// range would empty every bin outside it and leave nothing to widen into.
///
/// `bins` is clamped to at least one — a zero would be a division by zero in
/// SQL, and the caller's number comes from a hand-editable settings file.
pub fn timeline_uniform(
catalog: &Catalog,
scope: Option<Scope>,
filter: &RatingFilter,
from: i64,
to: i64,
bins: u32,
) -> Result<Vec<dr_catalog::TimeBucket>, dr_catalog::CatalogError> {
let bins = bins.max(1) as i64;
// At least one second, or every photograph lands in bin zero.
let span = (to - from).max(1);
let (clause, params) = scope_clause(catalog, scope)?;
let rated = filter.without_date_range().sql();
// The bin index is arithmetic on the stored UTC instant, not `strftime` on
// a local one. A bin is not a calendar unit — it has no local midnight to
// respect — and the axis it is drawn on is labelled from the same UTC
// instants, so bucketing the two differently is the one way they could
// disagree about which bar a photograph belongs to.
//
// `min` caps the last edge: an image captured at exactly `to` divides to
// `bins`, which would be a bin past the end of the axis.
//
// Integers this code owns, formatted straight in — the same rule the
// rating terms follow. They cannot be bound parameters here without
// ordering them against the scope clause's, which appears later in the
// text but is bound first.
let sql = format!(
"SELECT min({bins} - 1, (i.captured_at - {from}) * {bins} / {span}) AS b,
count(*) AS n
FROM images i
WHERE {VISIBLE}{rated} AND i.captured_at IS NOT NULL{clause}
AND i.captured_at >= {from} AND i.captured_at <= {to}
GROUP BY b
ORDER BY b ASC"
);
let conn = catalog.connection();
let mut stmt = conn.prepare(&sql)?;
let counted = stmt
.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)? as u32))
})?
.collect::<Result<Vec<_>, _>>()?;
// Every bin, in order, whether or not the query returned one for it. The
// start is the bin's own left edge rather than the earliest photograph in
// it: an empty bin has no photograph to take one from, and a bar drawn at
// its contents' position rather than its bin's would put the axis back
// where the calendar buckets left it.
let mut bars: Vec<dr_catalog::TimeBucket> = (0..bins)
.map(|i| dr_catalog::TimeBucket {
start: from + (i * span) / bins,
count: 0,
})
.collect();
for (i, n) in counted {
if let Some(bar) = bars.get_mut(i.clamp(0, bins - 1) as usize) {
bar.count = n;
}
}
Ok(bars)
}
/// Total images in the catalog, honouring the rating filter.
pub(super) fn total_images_filtered(
catalog: &Catalog,
filter: &RatingFilter,
) -> Result<usize, dr_catalog::CatalogError> {
let rated = filter.sql();
// Everything the filter keeps, less the frames a collapsed burst stands
// in for -- rather than `uncollapsed` asked of every image. Paid on
// every scroll reload: the predicate probed `burst_members` and
// `burst_expanded` once per visible image, 19,000 times on the reference
// library, to hide none. The subtraction walks only the burst members.
let hidden = dr_catalog::bursts::collapsed_away_frames("i");
let n: i64 = catalog.connection().query_row(
&format!(
"SELECT (SELECT count(*) FROM images i WHERE {VISIBLE}{rated})
- (SELECT count(*) FROM {hidden} AND {VISIBLE}{rated})"
),
[],
|r| r.get(0),
)?;
Ok(n as usize)
}
/// TRACES: FR-CAT-9
/// How many visible images have their original stored on this device.
///
/// Whole-library, like the star counts beside it: the chip says what narrowing
/// to it would show, so counting only the current window would make it
/// describe the view it exists to change.
///
/// Driven from `image_cache`, which holds a row only for what has been
/// fetched: an `IN` list the planner walks, probing `images` by id for each.
/// Spelled as a correlated `EXISTS` it walked every visible image and
/// probed the cache for each -- 19,000 probes to find 254 originals on the
/// reference library, 2 ms paid on every star keystroke beside the rating
/// counts. `image_id` is the cache's key, so the list holds each image once
/// and the count is the one the `EXISTS` gave.
pub fn local_original_count(catalog: &Catalog) -> Result<usize, dr_catalog::CatalogError> {
let n: i64 = catalog.connection().query_row(
&format!(
"SELECT count(*) FROM images i
WHERE {VISIBLE}
AND i.id IN (SELECT ic.image_id FROM image_cache ic
WHERE ic.tier_actual >= {})",
dr_types::Tier::Original.stored()
),
[],
|r| r.get(0),
)?;
Ok(n as usize)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::library::test_support::*;
#[test]
fn an_album_scopes_the_grid_to_what_was_exported_into_it() {
use dr_catalog::albums::{self, Place};
let catalog = with_images(10);
let ids = image_ids(&catalog);
let album = albums::create(catalog.connection(), "Web", &Place::Local("/out".into()))
.unwrap();
// Two files from one photograph, one from another: two cells.
albums::record_exports(
catalog.connection(),
album,
&[
(ids[1], "a.jpg".into()),
(ids[1], "a-crop.jpg".into()),
(ids[7], "b.jpg".into()),
],
)
.unwrap();
let scope = Some(Scope::Album(album));
let cells = read_cells_scoped(&catalog, scope, &RatingFilter::default(), 0, 120).unwrap();
assert_eq!(cells.len(), 2);
assert_eq!(
total_images_scoped(&catalog, scope, &RatingFilter::default()).unwrap(),
2
);
assert_eq!(
read_ids_span(&catalog, scope, &RatingFilter::default(), false, 0, 1)
.unwrap()
.len(),
2,
"a shift-click over the album selects what the grid drew"
);
}
#[test]
fn a_scoped_grid_shows_only_that_collections_images() {
use dr_catalog::collections::{self as coll, CollectionKind};
let catalog = with_images(10);
let ids = image_ids(&catalog);
let c = coll::create(
catalog.connection(),
"Selects",
None,
CollectionKind::Manual,
)
.unwrap();
coll::add_images(catalog.connection(), c, &ids[2..5]).unwrap();
let cells = read_cells_scoped(&catalog, Some(Scope::Collection(c)), &RatingFilter::default(), 0, 120).unwrap();
assert_eq!(cells.len(), 3);
assert_eq!(
total_images_scoped(&catalog, Some(Scope::Collection(c)), &RatingFilter::default()).unwrap(),
3
);
// Unscoped is still the whole library.
assert_eq!(
total_images_scoped(&catalog, None, &RatingFilter::default()).unwrap(),
10
);
}
#[test]
fn a_collection_set_shows_its_childrens_images() {
// A parent whose children hold everything must not read as empty —
// that is what makes nesting look broken.
use dr_catalog::collections::{self as coll, CollectionKind};
let catalog = with_images(10);
let ids = image_ids(&catalog);
let trips =
coll::create(catalog.connection(), "Trips", None, CollectionKind::Manual).unwrap();
let iceland = coll::create(
catalog.connection(),
"Iceland",
Some(trips),
CollectionKind::Manual,
)
.unwrap();
coll::add_images(catalog.connection(), iceland, &ids[0..4]).unwrap();
// The parent itself has no direct members at all.
let cells =
read_cells_scoped(&catalog, Some(Scope::Collection(trips)), &RatingFilter::default(), 0, 120).unwrap();
assert_eq!(cells.len(), 4, "the set shows what its children hold");
assert_eq!(
total_images_scoped(&catalog, Some(Scope::Collection(trips)), &RatingFilter::default()).unwrap(),
4
);
}
#[test]
fn an_image_in_both_a_parent_and_a_child_is_shown_once() {
// The count and the number of cells drawn must agree, or neither is
// believable.
use dr_catalog::collections::{self as coll, CollectionKind};
let catalog = with_images(10);
let ids = image_ids(&catalog);
let trips =
coll::create(catalog.connection(), "Trips", None, CollectionKind::Manual).unwrap();
let iceland = coll::create(
catalog.connection(),
"Iceland",
Some(trips),
CollectionKind::Manual,
)
.unwrap();
coll::add_images(catalog.connection(), trips, &ids[0..2]).unwrap();
coll::add_images(catalog.connection(), iceland, &ids[0..3]).unwrap();
let cells =
read_cells_scoped(&catalog, Some(Scope::Collection(trips)), &RatingFilter::default(), 0, 120).unwrap();
assert_eq!(cells.len(), 3, "images 0..3, each once");
assert_eq!(
total_images_scoped(&catalog, Some(Scope::Collection(trips)), &RatingFilter::default()).unwrap(),
3
);
}
#[test]
fn a_scoped_window_still_pages() {
// FR-CAT-4 applies inside a collection too: a 5,000-image collection
// must not become 5,000 rows.
use dr_catalog::collections::{self as coll, CollectionKind};
let catalog = with_images(30);
let ids = image_ids(&catalog);
let c = coll::create(catalog.connection(), "Big", None, CollectionKind::Manual).unwrap();
coll::add_images(catalog.connection(), c, &ids).unwrap();
let page = read_cells_scoped(&catalog, Some(Scope::Collection(c)), &RatingFilter::default(), 10, 5).unwrap();
assert_eq!(page.len(), 5);
assert_eq!(page[0].name, "img010.CR2");
}
#[test]
fn an_empty_collection_reads_as_empty_rather_than_as_the_whole_library() {
// The failure that would make scoping useless: an empty IN-list
// matching everything.
use dr_catalog::collections::{self as coll, CollectionKind};
let catalog = with_images(10);
let c = coll::create(catalog.connection(), "Empty", None, CollectionKind::Manual).unwrap();
assert!(
read_cells_scoped(&catalog, Some(Scope::Collection(c)), &RatingFilter::default(), 0, 120)
.unwrap()
.is_empty()
);
assert_eq!(
total_images_scoped(&catalog, Some(Scope::Collection(c)), &RatingFilter::default()).unwrap(),
0
);
}
// --- manual order within a collection (FR-CAT-7) ------------------------
fn ids(n: &[u64]) -> Vec<dr_types::ImageId> {
n.iter().copied().map(dr_types::ImageId).collect()
}
#[test]
fn a_run_moved_forward_lands_before_the_photograph_it_was_dropped_on() {
let current = ids(&[1, 2, 3, 4, 5]);
assert_eq!(
reordered(&current, &ids(&[4]), dr_types::ImageId(2), false),
ids(&[1, 4, 2, 3, 5])
);
}
#[test]
fn a_run_moved_backward_lands_before_it_too() {
// The direction of travel must not change what "before this one" means,
// or the same drop would land in two different places depending on
// where the photograph came from.
let current = ids(&[1, 2, 3, 4, 5]);
assert_eq!(
reordered(&current, &ids(&[2]), dr_types::ImageId(5), false),
ids(&[1, 3, 4, 2, 5])
);
}
#[test]
fn the_trailing_half_of_the_last_cell_is_how_the_end_is_reached() {
// There is no cell beyond the last one to drop in front of, so without
// `after` the final position is unreachable — which is exactly the
// place a "put this at the end" drag is aiming for.
let current = ids(&[1, 2, 3]);
assert_eq!(
reordered(&current, &ids(&[1]), dr_types::ImageId(3), true),
ids(&[2, 3, 1])
);
}
#[test]
fn a_moved_run_keeps_the_order_the_grid_shows_it_in() {
// Not the order the selection was built in. A user who tapped the last
// frame first has said nothing about how the run should be arranged —
// only about where it should go.
let current = ids(&[1, 2, 3, 4, 5]);
assert_eq!(
reordered(&current, &ids(&[5, 1]), dr_types::ImageId(3), false),
ids(&[2, 1, 5, 3, 4])
);
}
#[test]
fn a_run_dropped_on_one_of_its_own_members_stays_together() {
// The caller refuses this drop, so it is only reachable if that guard
// is ever lost. It must not lose photographs when it is.
let current = ids(&[1, 2, 3, 4]);
let moved = reordered(&current, &ids(&[2, 3]), dr_types::ImageId(3), false);
assert_eq!(moved.len(), current.len(), "nothing was dropped");
let mut sorted = moved.clone();
sorted.sort();
assert_eq!(sorted, ids(&[1, 2, 3, 4]), "and nothing was invented");
}
#[test]
fn a_reorder_never_loses_or_duplicates_a_member() {
// The property that matters most: this writes the whole membership
// back, so a run that dropped one image would delete it from the
// collection.
let current = ids(&[1, 2, 3, 4, 5, 6]);
for target in [1u64, 2, 3, 4, 5, 6] {
for after in [false, true] {
let moved = reordered(&current, &ids(&[2, 5]), dr_types::ImageId(target), after);
let mut sorted = moved.clone();
sorted.sort();
assert_eq!(
sorted,
ids(&[1, 2, 3, 4, 5, 6]),
"target {target}, after {after}"
);
}
}
}
/// The scoped grid and the range a shift-click resolves are two halves of
/// one ordering. This is the assertion that keeps them one: an ordinal read
/// through a different ORDER BY names a different photograph, and the user
/// finds out when the export runs.
#[test]
fn a_manual_collection_is_read_and_spanned_in_the_order_it_was_given() {
let catalog = with_images(5);
let all = image_ids(&catalog);
let id = dr_catalog::collections::create(
catalog.connection(),
"Trip",
None,
dr_catalog::collections::CollectionKind::Manual,
)
.unwrap();
dr_catalog::collections::add_images(catalog.connection(), id, &all).unwrap();
// Reversed, so position and capture time disagree about everything.
let wanted: Vec<_> = all.iter().rev().copied().collect();
dr_catalog::collections::set_order(catalog.connection(), id, &wanted).unwrap();
let cells = read_cells_scoped(&catalog, Some(Scope::Collection(id)), &RatingFilter::default(), 0, 50).unwrap();
let drawn: Vec<_> = cells
.iter()
.map(|c| dr_types::ImageId(c.image_id as u64))
.collect();
assert_eq!(drawn, wanted, "the grid draws the order that was written");
let spanned =
read_ids_span(&catalog, Some(Scope::Collection(id)), &RatingFilter::default(), false, 0, 4).unwrap();
assert_eq!(spanned, wanted, "and a range resolves through the same one");
assert_eq!(
read_member_order(&catalog, id).unwrap(),
wanted,
"and so does the membership a reorder rewrites"
);
}
#[test]
fn a_collection_with_children_falls_back_to_capture_time() {
// A set draws its descendants' images too, and two children's positions
// are unrelated integers. Ordering by them interleaves the two
// arbitrarily, which is worse than an order that at least means
// something.
let catalog = with_images(4);
let all = image_ids(&catalog);
let parent = dr_catalog::collections::create(
catalog.connection(),
"Iceland",
None,
dr_catalog::collections::CollectionKind::Manual,
)
.unwrap();
dr_catalog::collections::create(
catalog.connection(),
"Day one",
Some(parent),
dr_catalog::collections::CollectionKind::Manual,
)
.unwrap();
dr_catalog::collections::add_images(catalog.connection(), parent, &all).unwrap();
let reversed: Vec<_> = all.iter().rev().copied().collect();
dr_catalog::collections::set_order(catalog.connection(), parent, &reversed).unwrap();
let cells =
read_cells_scoped(&catalog, Some(Scope::Collection(parent)), &RatingFilter::default(), 0, 50).unwrap();
let drawn: Vec<_> = cells
.iter()
.map(|c| dr_types::ImageId(c.image_id as u64))
.collect();
assert_eq!(drawn, all, "capture time, not the positions that were set");
}
#[test]
fn a_smart_collection_has_no_manual_order_to_read() {
// No member rows at all, so `position` is not a column any of its
// images have. Falling back is the only thing there is to do.
let catalog = with_images(3);
let id = dr_catalog::collections::create(
catalog.connection(),
"Picks",
None,
dr_catalog::collections::CollectionKind::Smart,
)
.unwrap();
let (order, params) = grid_order_for(&catalog, Some(Scope::Collection(id)));
assert_eq!(order, GRID_ORDER);
assert!(params.is_empty());
}
#[test]
fn a_date_range_narrows_the_grid_and_the_count_together() {
// The whole reason the range lives on `RatingFilter`: every query path
// threads that one struct, so the header cannot claim a total the grid
// does not draw.
let catalog = scanned(3);
let conn = catalog.connection();
for (n, at) in [(1, 1_000), (2, 5_000), (3, 9_000)] {
conn.execute(
"UPDATE images SET captured_at = ?2 WHERE source_ref LIKE ?1",
rusqlite::params![format!("%IMG_000{n}%"), at],
)
.unwrap();
}
let ranged = RatingFilter {
captured_from: Some(4_000),
captured_to: Some(6_000),
..Default::default()
};
assert_eq!(read_cells_all(&catalog, &ranged, 0, 50).unwrap().len(), 1);
assert_eq!(total_images_filtered(&catalog, &ranged).unwrap(), 1);
}
#[test]
fn an_undated_image_is_not_shown_inside_a_date_range() {
// It cannot be in or out of a span. Drawing it anyway makes a range the
// user just chose look as though it had not applied.
let catalog = scanned(2);
catalog
.connection()
.execute("UPDATE images SET captured_at = NULL", [])
.unwrap();
let ranged = RatingFilter {
captured_from: Some(0),
captured_to: Some(i64::MAX),
..Default::default()
};
assert!(read_cells_all(&catalog, &ranged, 0, 50).unwrap().is_empty());
}
#[test]
fn a_span_reads_the_whole_run_whether_or_not_it_is_loaded() {
// The shift-click this exists for. The grid holds a window of five and
// the user names a run of twelve, so seven of them have no cell and no
// id anywhere in the UI — but they are still what was asked for, and
// the catalog is what knows them.
let catalog = scanned(12);
let filter = RatingFilter::default();
let loaded = read_cells_all(&catalog, &filter, 0, 5).unwrap();
assert_eq!(loaded.len(), 5, "the window is smaller than the run");
let whole: Vec<_> = read_cells_all(&catalog, &filter, 0, 50)
.unwrap()
.iter()
.map(|c| dr_types::ImageId(c.image_id as u64))
.collect();
let span = read_ids_span(&catalog, None, &filter, false, 0, 11).unwrap();
assert_eq!(span.len(), 12);
assert_eq!(span, whole, "the run is the grid's own list, in its order");
}
#[test]
fn a_span_starts_and_ends_where_it_was_asked_to() {
// Ordinals index the grid's list, so a run has to be exactly the slice
// of it the two ends name — one off at either end selects a
// photograph the user did not point at.
let catalog = scanned(12);
let filter = RatingFilter::default();
let whole: Vec<_> = read_cells_all(&catalog, &filter, 0, 50)
.unwrap()
.iter()
.map(|c| dr_types::ImageId(c.image_id as u64))
.collect();
let span = read_ids_span(&catalog, None, &filter, false, 4, 6).unwrap();
assert_eq!(span, whole[4..=6], "ordinals 4..=6, inclusive at both ends");
}
#[test]
fn a_span_is_ordered_by_capture_time_rather_than_by_name() {
// A card written by two cameras interleaves names that have nothing to
// do with each other. What a photographer means by "everything between
// these two" is a stretch of an afternoon, so the run has to be taken
// through capture time — the ordering the grid draws them in.
let catalog = scanned(3);
let conn = catalog.connection();
for (n, at) in [(1, 9_000), (2, 5_000), (3, 1_000)] {
conn.execute(
"UPDATE images SET captured_at = ?2 WHERE source_ref LIKE ?1",
rusqlite::params![format!("%IMG_000{n}%"), at],
)
.unwrap();
}
let span = read_ids_span(&catalog, None, &RatingFilter::default(), false, 0, 2).unwrap();
let names: Vec<String> = span
.iter()
.map(|id| {
conn.query_row(
"SELECT source_ref FROM images WHERE id = ?1",
[id.0 as i64],
|r| r.get::<_, String>(0),
)
.unwrap()
})
.collect();
assert!(
names[0].ends_with("IMG_0003.CR2")
&& names[1].ends_with("IMG_0002.CR2")
&& names[2].ends_with("IMG_0001.CR2"),
"earliest first, which here is the reverse of the file names: {names:?}"
);
}
#[test]
fn the_histogram_ignores_the_range_it_is_used_to_choose() {
// Drawing the axis through the chosen range would collapse it onto the
// selection, leaving nowhere to widen back out from.
let catalog = dated_three();
let ranged = RatingFilter {
captured_from: Some(4_000),
captured_to: Some(6_000),
..Default::default()
};
assert_eq!(
span_scoped(&catalog, None, &ranged),
Some((1_000, 9_000)),
"the axis must keep describing the whole extent"
);
}
/// Three photographs at 1000, 5000 and 9000 seconds.
fn dated_three() -> Catalog {
let catalog = scanned(3);
let conn = catalog.connection();
for (n, at) in [(1, 1_000), (2, 5_000), (3, 9_000)] {
conn.execute(
"UPDATE images SET captured_at = ?2 WHERE source_ref LIKE ?1",
rusqlite::params![format!("%IMG_000{n}%"), at],
)
.unwrap();
}
catalog
}
#[test]
fn the_histogram_has_the_number_of_bins_it_was_asked_for() {
// Fixed, whatever the span holds. The axis draws one bar per bin and
// positions it by index, so a query that returned only the occupied
// ones would put the bars at the wrong dates.
let catalog = dated_three();
let filter = RatingFilter::default();
for bins in [1_u32, 8, 32, 64] {
let bars = timeline_uniform(&catalog, None, &filter, 1_000, 9_000, bins).unwrap();
assert_eq!(bars.len() as u32, bins);
assert_eq!(
bars.iter().map(|b| b.count).sum::<u32>(),
3,
"every photograph is counted exactly once"
);
}
}
#[test]
fn a_bin_starts_where_the_axis_says_it_does() {
// The bar's start is its bin's left edge, not the earliest photograph
// in it. It is what the position marker and the range band are drawn
// against, and an empty bin has no photograph to borrow a date from.
let catalog = dated_three();
let bars = timeline_uniform(&catalog, None, &RatingFilter::default(), 0, 8_000, 8).unwrap();
for (i, bar) in bars.iter().enumerate() {
assert_eq!(bar.start, i as i64 * 1_000);
}
// 1000 and 5000 land in their own bins, 9000 is past the end.
assert_eq!(bars[1].count, 1);
assert_eq!(bars[5].count, 1);
assert_eq!(bars.iter().map(|b| b.count).sum::<u32>(), 2);
}
#[test]
fn the_last_bin_holds_a_photograph_taken_at_the_very_end() {
// The division puts an image captured at exactly `to` one bin past the
// axis. Uncapped it would be dropped from the histogram — and it is
// precisely the image that defines the extent, so it would go missing
// on every unzoomed library.
let catalog = dated_three();
let bars =
timeline_uniform(&catalog, None, &RatingFilter::default(), 1_000, 9_000, 4).unwrap();
assert_eq!(bars.len(), 4);
assert_eq!(bars[3].count, 1, "the image at 9000 is in the last bin");
assert_eq!(bars[0].count, 1);
}
#[test]
fn the_bins_ignore_the_range_they_are_used_to_choose() {
// Same rule as the extent: the bars outside the band are what the
// range is widened back into, so counting through the range would
// leave every one of them empty.
let catalog = dated_three();
let ranged = RatingFilter {
captured_from: Some(4_000),
captured_to: Some(6_000),
..Default::default()
};
let bars = timeline_uniform(&catalog, None, &ranged, 1_000, 9_000, 4).unwrap();
assert_eq!(
bars.iter().map(|b| b.count).sum::<u32>(),
3,
"all three, not just the one inside the range"
);
}
#[test]
fn the_bins_still_honour_every_other_filter() {
// A histogram of the five-star frames is a fair question, and the bars
// have to agree with the grid beneath them.
let catalog = dated_three();
let strict = RatingFilter {
min_rating: 4,
..Default::default()
};
let bars = timeline_uniform(&catalog, None, &strict, 1_000, 9_000, 4).unwrap();
assert_eq!(bars.len(), 4, "the axis keeps its shape");
assert_eq!(
bars.iter().map(|b| b.count).sum::<u32>(),
0,
"nothing here is rated"
);
}
}