Merge: collection ordering, and a range that says where it ends

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

# Conflicts:
#	docs/traceability.md
#	ui/dr-ui/src/collections_ui.rs
#	ui/dr-ui/src/library.rs
#	ui/dr-ui/ui/app.slint
#	ui/dr-ui/ui/library.slint
#	ui/dr-ui/ui/widgets.slint
This commit is contained in:
2026-08-30 16:38:08 +02:00
2 changed files with 175 additions and 0 deletions
+158
View File
@@ -376,6 +376,64 @@ pub fn set_order(
touch(conn, id)
}
/// Whether this collection has a manual order worth reading.
///
/// The grid asks before choosing an `ORDER BY`, and the answer is narrower
/// than "is it manual". Two things have to be true.
///
/// It must be **manual**: a saved filter's contents are whatever its selector
/// matches now, in whatever order the query returns them, and there are no
/// member rows to carry a position.
///
/// And it must have **no children**. A parent's contents are the union of its
/// own members and its descendants' ([`descendants`]), and positions are only
/// ever assigned within one collection — so two children's positions are
/// unrelated integers, and interleaving them by value would order the grid by
/// a coincidence. Capture time is the honest answer for a set.
///
/// Kept here rather than assembled at the call site, so the rule has one name
/// and one test, and the grid does not have to know that "no children" is part
/// of it.
pub fn orders_manually(conn: &Connection, id: CollectionId) -> Result<bool, CatalogError> {
if kind_of(conn, id)? != CollectionKind::Manual {
return Ok(false);
}
let has_child: Option<i64> = conn
.query_row(
"SELECT 1 FROM collections
WHERE parent_id = ?1 AND deleted = 0 LIMIT 1",
[id.0 as i64],
|r| r.get(0),
)
.optional()?;
Ok(has_child.is_none())
}
/// Every image in a manual collection, in manual order.
///
/// The whole membership, not the filtered view the grid happens to be showing.
/// [`set_order`] renumbers exactly the images it is handed, so a caller that
/// reordered a filtered list would renumber those and leave every hidden image
/// on a stale position — two images sharing a position, and a grid that
/// rearranges itself when the filter comes off.
///
/// `image_id` breaks ties. Positions are dense in practice, but a merge can
/// deliver two devices' rows at the same position, and an order that is not
/// total is one the grid draws differently each time it reads it.
pub fn members_in_order(conn: &Connection, id: CollectionId) -> Result<Vec<ImageId>, CatalogError> {
let mut stmt = conn.prepare(
"SELECT image_id FROM collection_members
WHERE collection_id = ?1
ORDER BY position, image_id",
)?;
let rows = stmt
.query_map([id.0 as i64], |r| Ok(ImageId(r.get::<_, i64>(0)? as u64)))?
.collect::<Result<Vec<_>, _>>()?;
Ok(rows)
}
/// Save a smart collection's selector.
///
/// Refuses a selector that references this collection, directly or through
@@ -789,6 +847,106 @@ mod tests {
ImageId(i)
}
#[test]
fn a_manual_leaf_orders_manually() {
let cat = seeded();
let c = cat.connection();
let id = create(c, "Trip", None, CollectionKind::Manual).unwrap();
assert!(orders_manually(c, id).unwrap());
}
#[test]
fn a_saved_filter_has_no_manual_order() {
let cat = seeded();
let c = cat.connection();
let id = create(c, "Picks", None, CollectionKind::Smart).unwrap();
assert!(
!orders_manually(c, id).unwrap(),
"a smart collection has no member rows to carry a position"
);
}
#[test]
fn a_parent_has_no_manual_order_of_its_own() {
let cat = seeded();
let c = cat.connection();
let parent = create(c, "2026", None, CollectionKind::Manual).unwrap();
let child = create(c, "Iceland", Some(parent), CollectionKind::Manual).unwrap();
assert!(
!orders_manually(c, parent).unwrap(),
"a set shows its descendants' images, whose positions are unrelated"
);
assert!(
orders_manually(c, child).unwrap(),
"the child is still a leaf and still orders manually"
);
}
#[test]
fn deleting_the_last_child_gives_the_parent_its_order_back() {
let cat = seeded();
let c = cat.connection();
let parent = create(c, "2026", None, CollectionKind::Manual).unwrap();
let child = create(c, "Iceland", Some(parent), CollectionKind::Manual).unwrap();
assert!(!orders_manually(c, parent).unwrap());
delete(c, child).unwrap();
assert!(
orders_manually(c, parent).unwrap(),
"a tombstoned child must not keep counting as a child"
);
}
#[test]
fn members_come_back_in_the_order_they_were_put_in() {
let cat = seeded();
let c = cat.connection();
let id = create(c, "Trip", None, CollectionKind::Manual).unwrap();
add_images(c, id, &[img(3), img(1), img(2)]).unwrap();
assert_eq!(
members_in_order(c, id).unwrap(),
vec![img(3), img(1), img(2)],
"adding assigns position in the order given, and reading returns it"
);
}
#[test]
fn members_come_back_in_the_order_set_order_wrote() {
let cat = seeded();
let c = cat.connection();
let id = create(c, "Trip", None, CollectionKind::Manual).unwrap();
add_images(c, id, &[img(1), img(2), img(3)]).unwrap();
set_order(c, id, &[img(3), img(2), img(1)]).unwrap();
assert_eq!(
members_in_order(c, id).unwrap(),
vec![img(3), img(2), img(1)]
);
}
#[test]
fn members_in_order_is_total_even_where_positions_collide() {
let cat = seeded();
let c = cat.connection();
let id = create(c, "Trip", None, CollectionKind::Manual).unwrap();
add_images(c, id, &[img(1), img(2), img(3)]).unwrap();
// What a merge can deliver: two devices' rows landing on one position.
c.execute(
"UPDATE collection_members SET position = 0 WHERE collection_id = ?1",
[id.0 as i64],
)
.unwrap();
assert_eq!(
members_in_order(c, id).unwrap(),
vec![img(1), img(2), img(3)],
"image_id breaks the tie, so two reads cannot disagree"
);
}
#[test]
fn a_created_collection_appears_in_the_tree() {
let cat = seeded();