`collection_members.position` and `Sort::CollectionPosition` have been in the catalog since collections were, and nothing above dr-catalog has ever written or read either: `collections::set_order` had no callers, and the grid ordered everything by capture time whatever it was scoped to — dr-ui does not construct a `Query` at all, it has its own `GRID_ORDER` constant. So a manual collection was a set with an order nobody could see or change. Three pieces, because it could not be fewer: `grid_order_for` decides the ordering from the scope, and both readers take it from there. That is the load-bearing part. An ordinal only names a photograph relative to an ordering, so the window read and the span read have to agree — 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. `read_ids_span` already stated that invariant about `GRID_ORDER`; this widens it to an ordering that depends on the scope. Only a single manual collection has one. A set draws its descendants' images too, and two children's positions are unrelated integers that interleave arbitrarily; a smart collection has no member rows to carry a position at all. Both fall back to capture time and refuse the drop rather than pretending. The drop is on the cell, on whichever half of it the finger landed — the trailing edge is the only way to name the last place in a collection, since there is no cell beyond the last one to drop in front of. `reordered` is pure and the membership is rewritten whole. `set_order` sets the positions it is given and leaves the rest, so a partial write would interleave the moved run with rows nobody touched; and it is read unfiltered, so what the filter is hiding keeps its place relative to what the user can see. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1246 lines
44 KiB
Rust
1246 lines
44 KiB
Rust
//! TRACES: FR-CAT-7 | FR-CAT-6 | NFR-R5
|
|
//! Collections: hierarchy, membership, and the edits the UI performs.
|
|
//!
|
|
//! The schema for this landed with the catalog (§2) and the cross-device merge
|
|
//! rules landed with [`crate::merge`]. What was missing is the layer between
|
|
//! them — the operations a user actually performs: make a collection, nest it,
|
|
//! drag images into it, take them out again.
|
|
//!
|
|
//! # Two independent structures
|
|
//!
|
|
//! **Hierarchy** is `collections.parent_id`: a collection inside a collection,
|
|
//! Lightroom's "collection set". A parent is an ordinary collection, not a
|
|
//! separate kind of thing, so a set can hold images of its own.
|
|
//!
|
|
//! **Membership** is `collection_members`, a join table. An image is in as many
|
|
//! collections as the user likes; nothing is moved or copied on disk, and no
|
|
//! collection owns an image. That is why dragging an image into a collection is
|
|
//! *additive* by default — it does not remove it from where it already is.
|
|
//!
|
|
//! These two are deliberately not entangled. Adding an image to a child does
|
|
//! **not** insert a row for the parent: a parent's contents are the union of
|
|
//! its own members and its descendants', computed on read by [`descendants`].
|
|
//! Materialising it instead would mean an add touching every ancestor, and a
|
|
//! reparent rewriting membership — both of which the merge layer would then
|
|
//! have to reconcile row by row.
|
|
//!
|
|
//! # Every edit bumps a revision
|
|
//!
|
|
//! [`crate::merge`] resolves conflicts by `revision`, a per-collection counter,
|
|
//! never by timestamp — a device with a skewed clock must not silently win. So
|
|
//! every mutation here goes through [`touch`], and none of them write
|
|
//! `modified` without also incrementing `revision`. Forgetting that is not a
|
|
//! local bug: it makes the *other* device lose work at the next sync.
|
|
//!
|
|
//! # Cycles
|
|
//!
|
|
//! Two ways a cycle can form, and both are refused rather than repaired:
|
|
//! parenting a collection under its own descendant ([`set_parent`]), and a
|
|
//! smart collection whose selector references itself ([`save_smart`]).
|
|
//! A cycle is unbounded recursion in the tree walk, so it must never be
|
|
//! representable in the database, not merely handled when drawn.
|
|
|
|
use rusqlite::{Connection, OptionalExtension};
|
|
|
|
use dr_types::{CollectionId, ImageId, Selector};
|
|
|
|
use crate::error::CatalogError;
|
|
|
|
/// Manual or smart, matching `collections.kind`.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum CollectionKind {
|
|
/// A set the user assembled by hand. Membership lives in
|
|
/// `collection_members`.
|
|
Manual = 0,
|
|
/// A saved filter. Membership is whatever its selector matches *now*, so
|
|
/// there are no member rows and dropping images onto one is refused.
|
|
Smart = 1,
|
|
}
|
|
|
|
impl CollectionKind {
|
|
fn from_i64(v: i64) -> Self {
|
|
match v {
|
|
1 => CollectionKind::Smart,
|
|
_ => CollectionKind::Manual,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One collection, as the sidebar draws it.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct Collection {
|
|
pub id: CollectionId,
|
|
/// Device-independent identity. The integer `id` is local and collides
|
|
/// across devices; this is what a merge keys on.
|
|
pub uuid: String,
|
|
pub name: String,
|
|
pub parent: Option<CollectionId>,
|
|
pub kind: CollectionKind,
|
|
/// The stored selector, for a smart collection.
|
|
pub selector: Option<Selector>,
|
|
/// Direct members only — not descendants'. The sidebar shows both, and
|
|
/// conflating them makes an empty parent of full children look full.
|
|
pub direct_count: usize,
|
|
}
|
|
|
|
/// A collection plus where it sits in the tree, flattened for display.
|
|
///
|
|
/// Slint models are flat, and a recursive `for` is not expressible in `.slint`,
|
|
/// so the tree is flattened here — in the place that already knows the
|
|
/// ordering rules — rather than in the UI.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct TreeRow {
|
|
pub collection: Collection,
|
|
/// 0 for a root collection, incrementing per level.
|
|
pub depth: usize,
|
|
/// Whether this row has children, so the UI can draw a disclosure triangle
|
|
/// without querying per row.
|
|
pub has_children: bool,
|
|
}
|
|
|
|
/// Create a collection, optionally inside `parent`.
|
|
///
|
|
/// The UUID is generated here rather than taken from the caller: it is the
|
|
/// merge identity, and a caller that reuses one silently fuses two collections
|
|
/// on the next sync.
|
|
pub fn create(
|
|
conn: &Connection,
|
|
name: &str,
|
|
parent: Option<CollectionId>,
|
|
kind: CollectionKind,
|
|
) -> Result<CollectionId, CatalogError> {
|
|
if let Some(p) = parent {
|
|
// Fail before inserting rather than leaving an orphan pointing at a
|
|
// parent that does not exist.
|
|
require_exists(conn, p)?;
|
|
}
|
|
|
|
let now = now_secs();
|
|
conn.execute(
|
|
"INSERT INTO collections(uuid, name, parent_id, kind, created, revision, modified)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, 1, ?5)",
|
|
rusqlite::params![
|
|
new_uuid(),
|
|
name.trim(),
|
|
parent.map(|p| p.0 as i64),
|
|
kind as i64,
|
|
now,
|
|
],
|
|
)?;
|
|
Ok(CollectionId(conn.last_insert_rowid() as u64))
|
|
}
|
|
|
|
/// Rename a collection.
|
|
pub fn rename(conn: &Connection, id: CollectionId, name: &str) -> Result<(), CatalogError> {
|
|
let n = conn.execute(
|
|
"UPDATE collections SET name = ?2 WHERE id = ?1 AND deleted = 0",
|
|
rusqlite::params![id.0 as i64, name.trim()],
|
|
)?;
|
|
if n == 0 {
|
|
return Err(CatalogError::NoSuchCollection(id.0));
|
|
}
|
|
touch(conn, id)
|
|
}
|
|
|
|
/// Reparent a collection — the drag that restructures the tree.
|
|
///
|
|
/// `None` moves it to the top level. Refuses to create a cycle: parenting a
|
|
/// collection under its own descendant would make the tree walk recurse
|
|
/// forever, so it is rejected at the write rather than defended against at
|
|
/// every read.
|
|
pub fn set_parent(
|
|
conn: &Connection,
|
|
id: CollectionId,
|
|
parent: Option<CollectionId>,
|
|
) -> Result<(), CatalogError> {
|
|
require_exists(conn, id)?;
|
|
|
|
if let Some(p) = parent {
|
|
require_exists(conn, p)?;
|
|
// A collection cannot be its own parent, and cannot descend from
|
|
// itself. `descendants` includes `id`, which covers both cases in one
|
|
// check.
|
|
if descendants(conn, id)?.contains(&p) {
|
|
return Err(CatalogError::CollectionCycle(id.0));
|
|
}
|
|
}
|
|
|
|
conn.execute(
|
|
"UPDATE collections SET parent_id = ?2 WHERE id = ?1",
|
|
rusqlite::params![id.0 as i64, parent.map(|p| p.0 as i64)],
|
|
)?;
|
|
touch(conn, id)
|
|
}
|
|
|
|
/// Delete a collection, leaving a tombstone.
|
|
///
|
|
/// The row survives with `deleted = 1` because a merge against a device that
|
|
/// still holds the collection would otherwise resurrect it (§8.1). Children are
|
|
/// **promoted to the deleted collection's parent** rather than deleted with it:
|
|
/// the schema's `ON DELETE CASCADE` would take the whole subtree, and losing a
|
|
/// nested collection because its container was tidied away is not recoverable.
|
|
///
|
|
/// Member rows are dropped — they carry no independent identity, and the
|
|
/// tombstone is what merges.
|
|
pub fn delete(conn: &Connection, id: CollectionId) -> Result<(), CatalogError> {
|
|
let parent: Option<i64> = conn
|
|
.query_row(
|
|
"SELECT parent_id FROM collections WHERE id = ?1 AND deleted = 0",
|
|
[id.0 as i64],
|
|
|r| r.get(0),
|
|
)
|
|
.optional()?
|
|
.ok_or(CatalogError::NoSuchCollection(id.0))?;
|
|
|
|
let tx = conn.unchecked_transaction()?;
|
|
|
|
// Promote children first, so the subtree is detached before the tombstone.
|
|
tx.execute(
|
|
"UPDATE collections SET parent_id = ?2 WHERE parent_id = ?1",
|
|
rusqlite::params![id.0 as i64, parent],
|
|
)?;
|
|
tx.execute(
|
|
"DELETE FROM collection_members WHERE collection_id = ?1",
|
|
[id.0 as i64],
|
|
)?;
|
|
tx.execute(
|
|
"UPDATE collections
|
|
SET deleted = 1, revision = revision + 1, modified = ?2
|
|
WHERE id = ?1",
|
|
rusqlite::params![id.0 as i64, now_secs()],
|
|
)?;
|
|
|
|
tx.commit()?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Add images to a manual collection. The drop half of drag-and-drop.
|
|
///
|
|
/// Additive and idempotent: an image already present keeps its original
|
|
/// `position` and `added` rather than jumping to the end, because re-dropping a
|
|
/// selection that overlaps what is already there is a normal thing to do and
|
|
/// must not reshuffle the collection.
|
|
///
|
|
/// Returns how many rows were genuinely new, which is what the UI reports —
|
|
/// "added 3 of 12" is the honest message when nine were already in.
|
|
pub fn add_images(
|
|
conn: &Connection,
|
|
id: CollectionId,
|
|
images: &[ImageId],
|
|
) -> Result<usize, CatalogError> {
|
|
let tx = conn.unchecked_transaction()?;
|
|
let added = add_within(&tx, id, images)?;
|
|
tx.commit()?;
|
|
Ok(added)
|
|
}
|
|
|
|
/// [`add_images`] without opening a transaction.
|
|
///
|
|
/// Separate because SQLite has no nested `BEGIN`: [`move_images`] needs an add
|
|
/// and a removal inside *one* transaction, and calling the public form there
|
|
/// fails at runtime with "cannot start a transaction within a transaction".
|
|
fn add_within(
|
|
conn: &Connection,
|
|
id: CollectionId,
|
|
images: &[ImageId],
|
|
) -> Result<usize, CatalogError> {
|
|
let kind = kind_of(conn, id)?;
|
|
if kind == CollectionKind::Smart {
|
|
// A smart collection's membership *is* its selector. Writing member
|
|
// rows would create a second, silently ignored source of truth.
|
|
return Err(CatalogError::SmartCollectionNotEditable(id.0));
|
|
}
|
|
if images.is_empty() {
|
|
return Ok(0);
|
|
}
|
|
|
|
let now = now_secs();
|
|
|
|
// Append after whatever is already there, so a drop lands at the end in
|
|
// the order the user dragged.
|
|
let mut next: i64 = conn.query_row(
|
|
"SELECT coalesce(max(position), -1) + 1 FROM collection_members
|
|
WHERE collection_id = ?1",
|
|
[id.0 as i64],
|
|
|r| r.get(0),
|
|
)?;
|
|
|
|
let mut added = 0;
|
|
{
|
|
let mut stmt = conn.prepare(
|
|
"INSERT INTO collection_members(collection_id, image_id, position, added)
|
|
VALUES (?1, ?2, ?3, ?4)
|
|
ON CONFLICT(collection_id, image_id) DO NOTHING",
|
|
)?;
|
|
for img in images {
|
|
if stmt.execute(rusqlite::params![id.0 as i64, img.0 as i64, next, now])? > 0 {
|
|
added += 1;
|
|
next += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Only a real change is an edit. Bumping the revision for a no-op drop
|
|
// would make an idle device win a merge against one that did real work.
|
|
if added > 0 {
|
|
touch(conn, id)?;
|
|
}
|
|
Ok(added)
|
|
}
|
|
|
|
/// Remove images from a collection.
|
|
///
|
|
/// Removes membership only — the images themselves are untouched, and stay in
|
|
/// every other collection they belong to. That asymmetry is the whole point of
|
|
/// a join table, and it is why this is "remove from collection" and never
|
|
/// "delete".
|
|
pub fn remove_images(
|
|
conn: &Connection,
|
|
id: CollectionId,
|
|
images: &[ImageId],
|
|
) -> Result<usize, CatalogError> {
|
|
let tx = conn.unchecked_transaction()?;
|
|
let removed = remove_within(&tx, id, images)?;
|
|
tx.commit()?;
|
|
Ok(removed)
|
|
}
|
|
|
|
/// [`remove_images`] without opening a transaction. See [`add_within`].
|
|
fn remove_within(
|
|
conn: &Connection,
|
|
id: CollectionId,
|
|
images: &[ImageId],
|
|
) -> Result<usize, CatalogError> {
|
|
if images.is_empty() {
|
|
return Ok(0);
|
|
}
|
|
let mut removed = 0;
|
|
{
|
|
let mut stmt = conn
|
|
.prepare("DELETE FROM collection_members WHERE collection_id = ?1 AND image_id = ?2")?;
|
|
for img in images {
|
|
removed += stmt.execute(rusqlite::params![id.0 as i64, img.0 as i64])?;
|
|
}
|
|
}
|
|
|
|
if removed > 0 {
|
|
touch(conn, id)?;
|
|
}
|
|
Ok(removed)
|
|
}
|
|
|
|
/// Move images from one collection to another — a drag with the modifier held.
|
|
///
|
|
/// One transaction, so a failure cannot leave the images in neither place.
|
|
/// A move onto the collection they came from is a no-op rather than a
|
|
/// remove-then-add that would lose their positions.
|
|
pub fn move_images(
|
|
conn: &Connection,
|
|
from: CollectionId,
|
|
to: CollectionId,
|
|
images: &[ImageId],
|
|
) -> Result<usize, CatalogError> {
|
|
if from == to || images.is_empty() {
|
|
return Ok(0);
|
|
}
|
|
let tx = conn.unchecked_transaction()?;
|
|
// Add first: if `to` turns out to be a smart collection the whole move
|
|
// aborts with nothing removed, rather than emptying `from` into nowhere.
|
|
let added = add_within(&tx, to, images)?;
|
|
remove_within(&tx, from, images)?;
|
|
tx.commit()?;
|
|
Ok(added)
|
|
}
|
|
|
|
/// Reorder a manual collection: put `images` at the front, in the order given.
|
|
///
|
|
/// Manual position is what `Sort::Position` reads. Rewriting the whole run
|
|
/// rather than patching one row keeps positions dense, so there is no
|
|
/// renumbering pass to run later.
|
|
pub fn set_order(
|
|
conn: &Connection,
|
|
id: CollectionId,
|
|
images: &[ImageId],
|
|
) -> Result<(), CatalogError> {
|
|
let tx = conn.unchecked_transaction()?;
|
|
{
|
|
let mut stmt = tx.prepare(
|
|
"UPDATE collection_members SET position = ?3
|
|
WHERE collection_id = ?1 AND image_id = ?2",
|
|
)?;
|
|
for (i, img) in images.iter().enumerate() {
|
|
stmt.execute(rusqlite::params![id.0 as i64, img.0 as i64, i as i64])?;
|
|
}
|
|
}
|
|
tx.commit()?;
|
|
touch(conn, id)
|
|
}
|
|
|
|
/// Save a smart collection's selector.
|
|
///
|
|
/// Refuses a selector that references this collection, directly or through
|
|
/// another that leads back here — evaluating it would recurse forever.
|
|
pub fn save_smart(
|
|
conn: &Connection,
|
|
id: CollectionId,
|
|
selector: &Selector,
|
|
) -> Result<(), CatalogError> {
|
|
let mut referenced = Vec::new();
|
|
selector.collections(&mut referenced);
|
|
|
|
// Direct self-reference, and indirect: a referenced smart collection whose
|
|
// own selector leads back to `id`.
|
|
for r in &referenced {
|
|
if *r == id || smart_reaches(conn, *r, id, 0)? {
|
|
return Err(CatalogError::CollectionCycle(id.0));
|
|
}
|
|
}
|
|
|
|
let json =
|
|
serde_json::to_string(selector).map_err(|e| CatalogError::BadSelector(e.to_string()))?;
|
|
let n = conn.execute(
|
|
"UPDATE collections SET selector_json = ?2, kind = 1 WHERE id = ?1 AND deleted = 0",
|
|
rusqlite::params![id.0 as i64, json],
|
|
)?;
|
|
if n == 0 {
|
|
return Err(CatalogError::NoSuchCollection(id.0));
|
|
}
|
|
touch(conn, id)
|
|
}
|
|
|
|
/// Whether `from`'s selector can reach `target` by following references.
|
|
///
|
|
/// Depth-limited rather than visited-set tracking: the database already refuses
|
|
/// to store a cycle, so this defends against a hand-edited or merged-in row,
|
|
/// and a bound is enough for that. A merge can introduce a selector this device
|
|
/// never validated, which is exactly when the read path must not hang.
|
|
fn smart_reaches(
|
|
conn: &Connection,
|
|
from: CollectionId,
|
|
target: CollectionId,
|
|
depth: usize,
|
|
) -> Result<bool, CatalogError> {
|
|
const MAX_DEPTH: usize = 16;
|
|
if depth >= MAX_DEPTH {
|
|
// Treat exhaustion as "yes, a cycle": refusing to save an
|
|
// implausibly-nested selector is safer than storing one that hangs.
|
|
return Ok(true);
|
|
}
|
|
|
|
let json: Option<String> = conn
|
|
.query_row(
|
|
"SELECT selector_json FROM collections WHERE id = ?1 AND deleted = 0",
|
|
[from.0 as i64],
|
|
|r| r.get(0),
|
|
)
|
|
.optional()?
|
|
.flatten();
|
|
|
|
let Some(json) = json else { return Ok(false) };
|
|
let Ok(sel) = serde_json::from_str::<Selector>(&json) else {
|
|
// An unreadable stored selector cannot be followed. Reporting "no
|
|
// cycle" is right: the broken row is a separate problem, surfaced when
|
|
// that collection is read.
|
|
return Ok(false);
|
|
};
|
|
|
|
let mut refs = Vec::new();
|
|
sel.collections(&mut refs);
|
|
for r in refs {
|
|
if r == target || smart_reaches(conn, r, target, depth + 1)? {
|
|
return Ok(true);
|
|
}
|
|
}
|
|
Ok(false)
|
|
}
|
|
|
|
/// Every collection an image belongs to.
|
|
///
|
|
/// What the grid needs to badge a cell with its collections, and what makes
|
|
/// "remove from this collection" distinguishable from "this image is only
|
|
/// here".
|
|
pub fn collections_for_image(
|
|
conn: &Connection,
|
|
image: ImageId,
|
|
) -> Result<Vec<CollectionId>, CatalogError> {
|
|
let mut stmt = conn.prepare(
|
|
"SELECT m.collection_id FROM collection_members m
|
|
JOIN collections c ON c.id = m.collection_id
|
|
WHERE m.image_id = ?1 AND c.deleted = 0
|
|
ORDER BY c.name",
|
|
)?;
|
|
let rows = stmt
|
|
.query_map([image.0 as i64], |r| {
|
|
Ok(CollectionId(r.get::<_, i64>(0)? as u64))
|
|
})?
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
Ok(rows)
|
|
}
|
|
|
|
/// What kind of collection `id` is, or `None` if there is no such collection.
|
|
///
|
|
/// Cheaper than reading the whole [`Collection`] where the caller only needs to
|
|
/// know whether member rows exist — the grid asks this to decide whether manual
|
|
/// position is a thing it can order by, and a smart collection has no
|
|
/// `collection_members` rows to carry one.
|
|
pub fn kind(conn: &Connection, id: CollectionId) -> Result<Option<CollectionKind>, CatalogError> {
|
|
let found = conn
|
|
.query_row(
|
|
"SELECT kind FROM collections WHERE id = ?1 AND deleted = 0",
|
|
[id.0 as i64],
|
|
|r| r.get::<_, i64>(0),
|
|
)
|
|
.optional()?;
|
|
Ok(found.map(CollectionKind::from_i64))
|
|
}
|
|
|
|
/// A collection and everything beneath it, including itself.
|
|
///
|
|
/// Used for cycle checks and for scoping the grid to a parent: selecting a
|
|
/// collection set shows its children's images, which is what makes a set useful
|
|
/// rather than an empty folder.
|
|
///
|
|
/// Iterative rather than a recursive CTE so the depth guard is explicit: a
|
|
/// cycle introduced by a merge from another device must terminate here, not
|
|
/// spin inside SQLite.
|
|
pub fn descendants(
|
|
conn: &Connection,
|
|
root: CollectionId,
|
|
) -> Result<Vec<CollectionId>, CatalogError> {
|
|
let mut out = vec![root];
|
|
let mut frontier = vec![root];
|
|
let mut guard = 0usize;
|
|
|
|
let mut stmt =
|
|
conn.prepare("SELECT id FROM collections WHERE parent_id = ?1 AND deleted = 0")?;
|
|
|
|
while let Some(next) = frontier.pop() {
|
|
guard += 1;
|
|
if guard > MAX_TREE_NODES {
|
|
// A cycle that reached us through a merge. Truncating is the only
|
|
// non-hanging option, and the tree is a view — the user sees a
|
|
// clipped subtree rather than a frozen window.
|
|
log::warn!("collection tree walk exceeded {MAX_TREE_NODES} nodes; truncated");
|
|
break;
|
|
}
|
|
|
|
let children = stmt
|
|
.query_map([next.0 as i64], |r| {
|
|
Ok(CollectionId(r.get::<_, i64>(0)? as u64))
|
|
})?
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
|
|
for child in children {
|
|
// Guards against a cycle reintroducing a node already visited.
|
|
if !out.contains(&child) {
|
|
out.push(child);
|
|
frontier.push(child);
|
|
}
|
|
}
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Upper bound on nodes visited in one tree walk.
|
|
///
|
|
/// Not a limit on how many collections a user may have — it bounds a single
|
|
/// walk so a malformed tree cannot hang the UI thread.
|
|
const MAX_TREE_NODES: usize = 10_000;
|
|
|
|
/// The whole tree, flattened depth-first for the sidebar.
|
|
///
|
|
/// Siblings sort by name, so the order is stable across launches and across
|
|
/// devices — id order would differ per device, which is disorienting on the
|
|
/// same library seen from two machines.
|
|
///
|
|
/// One query for the rows and one for the counts, then assembled in memory:
|
|
/// a per-row count query would be one statement per collection, and the
|
|
/// sidebar redraws on every drop.
|
|
pub fn tree(conn: &Connection) -> Result<Vec<TreeRow>, CatalogError> {
|
|
let mut stmt = conn.prepare(
|
|
"SELECT c.id, c.uuid, c.name, c.parent_id, c.kind, c.selector_json,
|
|
(SELECT count(*) FROM collection_members m WHERE m.collection_id = c.id)
|
|
FROM collections c
|
|
WHERE c.deleted = 0
|
|
ORDER BY c.name COLLATE NOCASE, c.id",
|
|
)?;
|
|
|
|
let all: Vec<Collection> = stmt
|
|
.query_map([], |r| {
|
|
let selector = r
|
|
.get::<_, Option<String>>(5)?
|
|
.and_then(|j| serde_json::from_str(&j).ok());
|
|
Ok(Collection {
|
|
id: CollectionId(r.get::<_, i64>(0)? as u64),
|
|
uuid: r.get(1)?,
|
|
name: r.get(2)?,
|
|
parent: r.get::<_, Option<i64>>(3)?.map(|v| CollectionId(v as u64)),
|
|
kind: CollectionKind::from_i64(r.get(4)?),
|
|
selector,
|
|
direct_count: r.get::<_, i64>(6)? as usize,
|
|
})
|
|
})?
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
|
|
// Depth-first from the roots. A collection whose parent is missing or
|
|
// deleted is treated as a root rather than dropped: an orphan must stay
|
|
// reachable, or the images in it become invisible.
|
|
let known: std::collections::HashSet<CollectionId> = all.iter().map(|c| c.id).collect();
|
|
let mut out = Vec::with_capacity(all.len());
|
|
let mut visited = std::collections::HashSet::new();
|
|
|
|
fn walk(
|
|
all: &[Collection],
|
|
parent: Option<CollectionId>,
|
|
depth: usize,
|
|
visited: &mut std::collections::HashSet<CollectionId>,
|
|
out: &mut Vec<TreeRow>,
|
|
) {
|
|
for c in all.iter().filter(|c| c.parent == parent) {
|
|
// A cycle from a merged-in row would otherwise recurse forever.
|
|
if !visited.insert(c.id) {
|
|
continue;
|
|
}
|
|
let has_children = all.iter().any(|k| k.parent == Some(c.id));
|
|
out.push(TreeRow {
|
|
collection: c.clone(),
|
|
depth,
|
|
has_children,
|
|
});
|
|
walk(all, Some(c.id), depth + 1, visited, out);
|
|
}
|
|
}
|
|
|
|
walk(&all, None, 0, &mut visited, &mut out);
|
|
|
|
// Orphans, after the real roots: their parent is gone, so they have no
|
|
// place in the hierarchy but must not disappear.
|
|
for c in &all {
|
|
let orphaned = c.parent.is_some_and(|p| !known.contains(&p));
|
|
if orphaned && visited.insert(c.id) {
|
|
let has_children = all.iter().any(|k| k.parent == Some(c.id));
|
|
out.push(TreeRow {
|
|
collection: c.clone(),
|
|
depth: 0,
|
|
has_children,
|
|
});
|
|
walk(&all, Some(c.id), 1, &mut visited, &mut out);
|
|
}
|
|
}
|
|
|
|
Ok(out)
|
|
}
|
|
|
|
/// How many images a collection holds counting its descendants.
|
|
///
|
|
/// Distinct images, not a sum: an image in both a parent and its child is one
|
|
/// photograph, and reporting two is the kind of small lie that makes a user
|
|
/// stop trusting the counts.
|
|
pub fn deep_count(conn: &Connection, id: CollectionId) -> Result<usize, CatalogError> {
|
|
let ids = descendants(conn, id)?;
|
|
let list: Vec<rusqlite::types::Value> = ids
|
|
.iter()
|
|
.map(|c| rusqlite::types::Value::Integer(c.0 as i64))
|
|
.collect();
|
|
|
|
// `carray` is not compiled in, so the placeholder list is built from the
|
|
// id count — never from user text.
|
|
let placeholders = std::iter::repeat_n("?", list.len())
|
|
.collect::<Vec<_>>()
|
|
.join(",");
|
|
let sql = format!(
|
|
"SELECT count(DISTINCT image_id) FROM collection_members
|
|
WHERE collection_id IN ({placeholders})"
|
|
);
|
|
let n: i64 = conn.query_row(&sql, rusqlite::params_from_iter(list.iter()), |r| r.get(0))?;
|
|
Ok(n as usize)
|
|
}
|
|
|
|
/// Bump `revision` and `modified` together.
|
|
///
|
|
/// Every mutation ends here. The merge layer compares revisions, so an edit
|
|
/// that updates `modified` alone is invisible to it — the other device keeps
|
|
/// its own version and the user's work vanishes at the next sync.
|
|
fn touch(conn: &Connection, id: CollectionId) -> Result<(), CatalogError> {
|
|
conn.execute(
|
|
"UPDATE collections SET revision = revision + 1, modified = ?2 WHERE id = ?1",
|
|
rusqlite::params![id.0 as i64, now_secs()],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
fn kind_of(conn: &Connection, id: CollectionId) -> Result<CollectionKind, CatalogError> {
|
|
conn.query_row(
|
|
"SELECT kind FROM collections WHERE id = ?1 AND deleted = 0",
|
|
[id.0 as i64],
|
|
|r| r.get::<_, i64>(0),
|
|
)
|
|
.optional()?
|
|
.map(CollectionKind::from_i64)
|
|
.ok_or(CatalogError::NoSuchCollection(id.0))
|
|
}
|
|
|
|
fn require_exists(conn: &Connection, id: CollectionId) -> Result<(), CatalogError> {
|
|
let found: Option<i64> = conn
|
|
.query_row(
|
|
"SELECT 1 FROM collections WHERE id = ?1 AND deleted = 0",
|
|
[id.0 as i64],
|
|
|r| r.get(0),
|
|
)
|
|
.optional()?;
|
|
found
|
|
.map(|_| ())
|
|
.ok_or(CatalogError::NoSuchCollection(id.0))
|
|
}
|
|
|
|
/// A random UUID, formatted as the canonical 8-4-4-4-12.
|
|
///
|
|
/// Hand-rolled rather than pulling in the `uuid` crate for one function, the
|
|
/// same reasoning as the connector's date parsing. Version 4 layout, seeded
|
|
/// from the OS via `getrandom` through `rusqlite`'s existing dependency-free
|
|
/// path — see below.
|
|
pub(crate) fn new_uuid() -> String {
|
|
let b = random_bytes();
|
|
// Version 4, variant 1, per RFC 4122 §4.4.
|
|
let v6 = (b[6] & 0x0F) | 0x40;
|
|
let v8 = (b[8] & 0x3F) | 0x80;
|
|
format!(
|
|
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-\
|
|
{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
|
|
b[0],
|
|
b[1],
|
|
b[2],
|
|
b[3],
|
|
b[4],
|
|
b[5],
|
|
v6,
|
|
b[7],
|
|
v8,
|
|
b[9],
|
|
b[10],
|
|
b[11],
|
|
b[12],
|
|
b[13],
|
|
b[14],
|
|
b[15]
|
|
)
|
|
}
|
|
|
|
/// 16 bytes from the OS.
|
|
///
|
|
/// A UUID that collides fuses two different collections on the next merge, so
|
|
/// this reads the system CSPRNG rather than mixing a clock with an address.
|
|
/// The fallback path exists so a UUID is always produced, and it is *logged*:
|
|
/// silently degrading identity quality is how a collision becomes unexplainable
|
|
/// later.
|
|
fn random_bytes() -> [u8; 16] {
|
|
use std::io::Read as _;
|
|
let mut buf = [0u8; 16];
|
|
|
|
match std::fs::File::open("/dev/urandom").and_then(|mut f| f.read_exact(&mut buf)) {
|
|
Ok(()) => return buf,
|
|
Err(e) => log::warn!("no OS randomness for a collection UUID ({e}); using a weak fallback"),
|
|
}
|
|
|
|
// Last resort: time and an address, which is unique enough within one
|
|
// process to avoid fusing two collections created in one session.
|
|
let t = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_nanos())
|
|
.unwrap_or(0);
|
|
let addr = &buf as *const _ as usize as u128;
|
|
let mixed = t ^ (addr << 64) ^ std::process::id() as u128;
|
|
buf.copy_from_slice(&mixed.to_le_bytes());
|
|
buf
|
|
}
|
|
|
|
fn now_secs() -> i64 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_secs() as i64)
|
|
.unwrap_or(0)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::Catalog;
|
|
|
|
fn seeded() -> Catalog {
|
|
let cat = Catalog::in_memory().unwrap();
|
|
let c = cat.connection();
|
|
c.execute(
|
|
"INSERT INTO roots(id, kind, label) VALUES (1, 'local', 'lib')",
|
|
[],
|
|
)
|
|
.unwrap();
|
|
for i in 1..=6i64 {
|
|
c.execute(
|
|
"INSERT INTO images(id, root_id, source_ref, added_at)
|
|
VALUES (?1, 1, ?2, 0)",
|
|
rusqlite::params![i, format!("img{i}.CR3")],
|
|
)
|
|
.unwrap();
|
|
}
|
|
cat
|
|
}
|
|
|
|
fn img(i: u64) -> ImageId {
|
|
ImageId(i)
|
|
}
|
|
|
|
#[test]
|
|
fn a_created_collection_appears_in_the_tree() {
|
|
let cat = seeded();
|
|
let c = cat.connection();
|
|
create(c, "Iceland", None, CollectionKind::Manual).unwrap();
|
|
|
|
let rows = tree(c).unwrap();
|
|
assert_eq!(rows.len(), 1);
|
|
assert_eq!(rows[0].collection.name, "Iceland");
|
|
assert_eq!(rows[0].depth, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn uuids_are_distinct_per_collection() {
|
|
// Two collections sharing a UUID fuse into one at the next merge, and
|
|
// the user loses one of them.
|
|
let cat = seeded();
|
|
let c = cat.connection();
|
|
let a = create(c, "A", None, CollectionKind::Manual).unwrap();
|
|
let b = create(c, "B", None, CollectionKind::Manual).unwrap();
|
|
|
|
let uuid = |id: CollectionId| -> String {
|
|
c.query_row(
|
|
"SELECT uuid FROM collections WHERE id = ?1",
|
|
[id.0 as i64],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap()
|
|
};
|
|
assert_ne!(uuid(a), uuid(b));
|
|
// Canonical 8-4-4-4-12 layout, which is what the merge layer compares.
|
|
assert_eq!(uuid(a).len(), 36);
|
|
}
|
|
|
|
#[test]
|
|
fn nesting_reports_depth_and_children() {
|
|
let cat = seeded();
|
|
let c = cat.connection();
|
|
let trips = create(c, "Trips", None, CollectionKind::Manual).unwrap();
|
|
create(c, "Iceland", Some(trips), CollectionKind::Manual).unwrap();
|
|
|
|
let rows = tree(c).unwrap();
|
|
assert_eq!(rows.len(), 2);
|
|
assert_eq!(rows[0].collection.name, "Trips");
|
|
assert!(
|
|
rows[0].has_children,
|
|
"a parent must draw a disclosure arrow"
|
|
);
|
|
assert_eq!(rows[1].depth, 1, "the child is indented one level");
|
|
}
|
|
|
|
#[test]
|
|
fn siblings_sort_by_name_not_by_id() {
|
|
// Id order differs per device; the same library on two machines must
|
|
// not present its collections in two different orders.
|
|
let cat = seeded();
|
|
let c = cat.connection();
|
|
create(c, "Zebra", None, CollectionKind::Manual).unwrap();
|
|
create(c, "Antelope", None, CollectionKind::Manual).unwrap();
|
|
|
|
let names: Vec<String> = tree(c)
|
|
.unwrap()
|
|
.iter()
|
|
.map(|r| r.collection.name.clone())
|
|
.collect();
|
|
assert_eq!(names, vec!["Antelope", "Zebra"]);
|
|
}
|
|
|
|
#[test]
|
|
fn an_image_can_be_in_several_collections_at_once() {
|
|
// The property the whole join table exists for: no collection owns an
|
|
// image.
|
|
let cat = seeded();
|
|
let c = cat.connection();
|
|
let a = create(c, "Portfolio", None, CollectionKind::Manual).unwrap();
|
|
let b = create(c, "Print queue", None, CollectionKind::Manual).unwrap();
|
|
|
|
add_images(c, a, &[img(1), img(2)]).unwrap();
|
|
add_images(c, b, &[img(2)]).unwrap();
|
|
|
|
let of_2 = collections_for_image(c, img(2)).unwrap();
|
|
assert_eq!(of_2.len(), 2);
|
|
assert!(of_2.contains(&a) && of_2.contains(&b));
|
|
}
|
|
|
|
#[test]
|
|
fn dropping_the_same_images_twice_adds_nothing_and_reorders_nothing() {
|
|
// Re-dropping an overlapping selection is normal; it must not shuffle
|
|
// what is already in the collection.
|
|
let cat = seeded();
|
|
let c = cat.connection();
|
|
let a = create(c, "Set", None, CollectionKind::Manual).unwrap();
|
|
|
|
assert_eq!(add_images(c, a, &[img(1), img(2)]).unwrap(), 2);
|
|
// Only image 3 is new.
|
|
assert_eq!(add_images(c, a, &[img(1), img(2), img(3)]).unwrap(), 1);
|
|
|
|
let positions: Vec<(i64, i64)> = {
|
|
let mut stmt = c
|
|
.prepare(
|
|
"SELECT image_id, position FROM collection_members
|
|
WHERE collection_id = ?1 ORDER BY position",
|
|
)
|
|
.unwrap();
|
|
stmt.query_map([a.0 as i64], |r| Ok((r.get(0)?, r.get(1)?)))
|
|
.unwrap()
|
|
.map(Result::unwrap)
|
|
.collect()
|
|
};
|
|
assert_eq!(positions, vec![(1, 0), (2, 1), (3, 2)]);
|
|
}
|
|
|
|
#[test]
|
|
fn removing_from_one_collection_leaves_the_others() {
|
|
let cat = seeded();
|
|
let c = cat.connection();
|
|
let a = create(c, "A", None, CollectionKind::Manual).unwrap();
|
|
let b = create(c, "B", None, CollectionKind::Manual).unwrap();
|
|
add_images(c, a, &[img(1)]).unwrap();
|
|
add_images(c, b, &[img(1)]).unwrap();
|
|
|
|
remove_images(c, a, &[img(1)]).unwrap();
|
|
|
|
assert_eq!(collections_for_image(c, img(1)).unwrap(), vec![b]);
|
|
// And the image itself is untouched — this is not a delete.
|
|
let n: i64 = c
|
|
.query_row("SELECT count(*) FROM images WHERE id = 1", [], |r| r.get(0))
|
|
.unwrap();
|
|
assert_eq!(n, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn every_edit_bumps_the_revision() {
|
|
// The merge layer resolves by revision. An edit that does not bump it
|
|
// loses to an idle device at the next sync.
|
|
let cat = seeded();
|
|
let c = cat.connection();
|
|
let a = create(c, "A", None, CollectionKind::Manual).unwrap();
|
|
let rev = |c: &Connection| -> i64 {
|
|
c.query_row(
|
|
"SELECT revision FROM collections WHERE id = ?1",
|
|
[a.0 as i64],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap()
|
|
};
|
|
|
|
let start = rev(c);
|
|
add_images(c, a, &[img(1)]).unwrap();
|
|
let after_add = rev(c);
|
|
assert!(after_add > start, "an add is an edit");
|
|
|
|
rename(c, a, "Renamed").unwrap();
|
|
assert!(rev(c) > after_add, "a rename is an edit");
|
|
|
|
remove_images(c, a, &[img(1)]).unwrap();
|
|
assert!(rev(c) > after_add + 1, "a removal is an edit");
|
|
}
|
|
|
|
#[test]
|
|
fn a_drop_that_adds_nothing_does_not_bump_the_revision() {
|
|
// Otherwise an idle device that re-dropped the same images would win a
|
|
// merge against one that did real work.
|
|
let cat = seeded();
|
|
let c = cat.connection();
|
|
let a = create(c, "A", None, CollectionKind::Manual).unwrap();
|
|
add_images(c, a, &[img(1)]).unwrap();
|
|
|
|
let before: i64 = c
|
|
.query_row(
|
|
"SELECT revision FROM collections WHERE id = ?1",
|
|
[a.0 as i64],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(add_images(c, a, &[img(1)]).unwrap(), 0);
|
|
let after: i64 = c
|
|
.query_row(
|
|
"SELECT revision FROM collections WHERE id = ?1",
|
|
[a.0 as i64],
|
|
|r| r.get(0),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(before, after);
|
|
}
|
|
|
|
#[test]
|
|
fn a_collection_cannot_be_parented_under_its_own_descendant() {
|
|
// A cycle is unbounded recursion in the tree walk, so it must not be
|
|
// storable at all.
|
|
let cat = seeded();
|
|
let c = cat.connection();
|
|
let top = create(c, "Top", None, CollectionKind::Manual).unwrap();
|
|
let mid = create(c, "Mid", Some(top), CollectionKind::Manual).unwrap();
|
|
let leaf = create(c, "Leaf", Some(mid), CollectionKind::Manual).unwrap();
|
|
|
|
assert!(matches!(
|
|
set_parent(c, top, Some(leaf)),
|
|
Err(CatalogError::CollectionCycle(_))
|
|
));
|
|
// And the direct case.
|
|
assert!(matches!(
|
|
set_parent(c, top, Some(top)),
|
|
Err(CatalogError::CollectionCycle(_))
|
|
));
|
|
// The tree is unchanged and still walks.
|
|
assert_eq!(tree(c).unwrap().len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn reparenting_to_the_top_level_is_allowed() {
|
|
let cat = seeded();
|
|
let c = cat.connection();
|
|
let top = create(c, "Top", None, CollectionKind::Manual).unwrap();
|
|
let child = create(c, "Child", Some(top), CollectionKind::Manual).unwrap();
|
|
|
|
set_parent(c, child, None).unwrap();
|
|
let rows = tree(c).unwrap();
|
|
assert!(rows.iter().all(|r| r.depth == 0));
|
|
assert!(!rows.iter().any(|r| r.has_children));
|
|
}
|
|
|
|
#[test]
|
|
fn deleting_a_parent_promotes_its_children_rather_than_taking_them() {
|
|
// The schema's ON DELETE CASCADE would remove the whole subtree.
|
|
// Losing a nested collection because its container was tidied away is
|
|
// not recoverable.
|
|
let cat = seeded();
|
|
let c = cat.connection();
|
|
let top = create(c, "Top", None, CollectionKind::Manual).unwrap();
|
|
let mid = create(c, "Mid", Some(top), CollectionKind::Manual).unwrap();
|
|
add_images(c, mid, &[img(1)]).unwrap();
|
|
|
|
delete(c, top).unwrap();
|
|
|
|
let rows = tree(c).unwrap();
|
|
assert_eq!(rows.len(), 1);
|
|
assert_eq!(rows[0].collection.id, mid);
|
|
assert_eq!(rows[0].depth, 0, "promoted to the top level");
|
|
assert_eq!(rows[0].collection.direct_count, 1, "members survived");
|
|
}
|
|
|
|
#[test]
|
|
fn a_deleted_collection_leaves_a_tombstone() {
|
|
// Without it, merging with a device that still holds the collection
|
|
// resurrects it (§8.1).
|
|
let cat = seeded();
|
|
let c = cat.connection();
|
|
let a = create(c, "Gone", None, CollectionKind::Manual).unwrap();
|
|
delete(c, a).unwrap();
|
|
|
|
assert!(tree(c).unwrap().is_empty(), "hidden from the sidebar");
|
|
let (deleted, rev): (i64, i64) = c
|
|
.query_row(
|
|
"SELECT deleted, revision FROM collections WHERE id = ?1",
|
|
[a.0 as i64],
|
|
|r| Ok((r.get(0)?, r.get(1)?)),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(deleted, 1, "the row survives as a tombstone");
|
|
assert!(rev > 1, "deletion competes on revision like any other edit");
|
|
}
|
|
|
|
#[test]
|
|
fn a_parent_counts_its_descendants_images_without_double_counting() {
|
|
// A set of collections must not read as empty, and an image in both a
|
|
// parent and a child is one photograph.
|
|
let cat = seeded();
|
|
let c = cat.connection();
|
|
let trips = create(c, "Trips", None, CollectionKind::Manual).unwrap();
|
|
let iceland = create(c, "Iceland", Some(trips), CollectionKind::Manual).unwrap();
|
|
let japan = create(c, "Japan", Some(trips), CollectionKind::Manual).unwrap();
|
|
|
|
add_images(c, trips, &[img(1)]).unwrap();
|
|
add_images(c, iceland, &[img(1), img(2)]).unwrap();
|
|
add_images(c, japan, &[img(3)]).unwrap();
|
|
|
|
// Direct: only image 1. Deep: images 1, 2, 3 — with 1 counted once.
|
|
assert_eq!(deep_count(c, trips).unwrap(), 3);
|
|
assert_eq!(deep_count(c, iceland).unwrap(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn moving_images_is_atomic_across_both_collections() {
|
|
let cat = seeded();
|
|
let c = cat.connection();
|
|
let a = create(c, "From", None, CollectionKind::Manual).unwrap();
|
|
let b = create(c, "To", None, CollectionKind::Manual).unwrap();
|
|
add_images(c, a, &[img(1), img(2)]).unwrap();
|
|
|
|
move_images(c, a, b, &[img(1)]).unwrap();
|
|
|
|
assert_eq!(collections_for_image(c, img(1)).unwrap(), vec![b]);
|
|
assert_eq!(collections_for_image(c, img(2)).unwrap(), vec![a]);
|
|
}
|
|
|
|
#[test]
|
|
fn moving_onto_the_source_collection_changes_nothing() {
|
|
// A remove-then-add would drop the images' manual positions.
|
|
let cat = seeded();
|
|
let c = cat.connection();
|
|
let a = create(c, "A", None, CollectionKind::Manual).unwrap();
|
|
add_images(c, a, &[img(1), img(2)]).unwrap();
|
|
|
|
assert_eq!(move_images(c, a, a, &[img(1)]).unwrap(), 0);
|
|
assert_eq!(collections_for_image(c, img(1)).unwrap(), vec![a]);
|
|
}
|
|
|
|
#[test]
|
|
fn images_cannot_be_dropped_onto_a_smart_collection() {
|
|
// Its membership *is* its selector; member rows would be a second,
|
|
// silently ignored source of truth.
|
|
let cat = seeded();
|
|
let c = cat.connection();
|
|
let s = create(c, "Five star", None, CollectionKind::Smart).unwrap();
|
|
|
|
assert!(matches!(
|
|
add_images(c, s, &[img(1)]),
|
|
Err(CatalogError::SmartCollectionNotEditable(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn a_smart_collection_cannot_reference_itself() {
|
|
let cat = seeded();
|
|
let c = cat.connection();
|
|
let s = create(c, "Recursive", None, CollectionKind::Smart).unwrap();
|
|
|
|
assert!(matches!(
|
|
save_smart(c, s, &Selector::Collection(s)),
|
|
Err(CatalogError::CollectionCycle(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn a_smart_collection_cannot_reference_itself_through_another() {
|
|
// The indirect case: A references B, and B is then pointed back at A.
|
|
let cat = seeded();
|
|
let c = cat.connection();
|
|
let a = create(c, "A", None, CollectionKind::Smart).unwrap();
|
|
let b = create(c, "B", None, CollectionKind::Smart).unwrap();
|
|
|
|
save_smart(c, b, &Selector::Collection(a)).unwrap();
|
|
assert!(matches!(
|
|
save_smart(c, a, &Selector::Collection(b)),
|
|
Err(CatalogError::CollectionCycle(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn a_saved_selector_round_trips_into_the_tree() {
|
|
let cat = seeded();
|
|
let c = cat.connection();
|
|
let s = create(c, "Keepers", None, CollectionKind::Smart).unwrap();
|
|
let sel = Selector::Rating { min: 4 };
|
|
save_smart(c, s, &sel).unwrap();
|
|
|
|
let row = tree(c).unwrap().into_iter().next().unwrap();
|
|
assert_eq!(row.collection.kind, CollectionKind::Smart);
|
|
assert_eq!(row.collection.selector, Some(sel));
|
|
}
|
|
|
|
#[test]
|
|
fn manual_order_can_be_rewritten() {
|
|
let cat = seeded();
|
|
let c = cat.connection();
|
|
let a = create(c, "Sequence", None, CollectionKind::Manual).unwrap();
|
|
add_images(c, a, &[img(1), img(2), img(3)]).unwrap();
|
|
|
|
set_order(c, a, &[img(3), img(1), img(2)]).unwrap();
|
|
|
|
let order: Vec<i64> = {
|
|
let mut stmt = c
|
|
.prepare(
|
|
"SELECT image_id FROM collection_members
|
|
WHERE collection_id = ?1 ORDER BY position",
|
|
)
|
|
.unwrap();
|
|
stmt.query_map([a.0 as i64], |r| r.get(0))
|
|
.unwrap()
|
|
.map(Result::unwrap)
|
|
.collect()
|
|
};
|
|
assert_eq!(order, vec![3, 1, 2]);
|
|
}
|
|
|
|
#[test]
|
|
fn editing_a_missing_collection_is_an_error_not_a_silent_no_op() {
|
|
let cat = seeded();
|
|
let c = cat.connection();
|
|
let ghost = CollectionId(999);
|
|
|
|
assert!(matches!(
|
|
rename(c, ghost, "x"),
|
|
Err(CatalogError::NoSuchCollection(999))
|
|
));
|
|
assert!(matches!(
|
|
add_images(c, ghost, &[img(1)]),
|
|
Err(CatalogError::NoSuchCollection(999))
|
|
));
|
|
assert!(matches!(
|
|
create(c, "child", Some(ghost), CollectionKind::Manual),
|
|
Err(CatalogError::NoSuchCollection(999))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn a_collection_under_a_tombstoned_parent_stays_reachable() {
|
|
// A merge from another device can tombstone a parent this device still
|
|
// has children under — `delete` promotes them, but a merge writes the
|
|
// tombstone directly. Dropping the child from the tree would make the
|
|
// images inside invisible with no way to reach them.
|
|
let cat = seeded();
|
|
let c = cat.connection();
|
|
let top = create(c, "Top", None, CollectionKind::Manual).unwrap();
|
|
let child = create(c, "Child", Some(top), CollectionKind::Manual).unwrap();
|
|
add_images(c, child, &[img(1)]).unwrap();
|
|
|
|
// What a merge does: tombstone the parent without touching children.
|
|
c.execute(
|
|
"UPDATE collections SET deleted = 1 WHERE id = ?1",
|
|
[top.0 as i64],
|
|
)
|
|
.unwrap();
|
|
|
|
let rows = tree(c).unwrap();
|
|
assert_eq!(rows.len(), 1, "the child survives its parent");
|
|
assert_eq!(rows[0].collection.id, child);
|
|
assert_eq!(rows[0].depth, 0, "shown at the top level");
|
|
assert_eq!(rows[0].collection.direct_count, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn a_cyclic_tree_from_a_merge_does_not_hang_the_walk() {
|
|
// The write path refuses cycles, but a merged-in row from another
|
|
// device was never validated here. The read path must terminate.
|
|
let cat = seeded();
|
|
let c = cat.connection();
|
|
let a = create(c, "A", None, CollectionKind::Manual).unwrap();
|
|
let b = create(c, "B", Some(a), CollectionKind::Manual).unwrap();
|
|
// Force the cycle behind the API's back.
|
|
c.execute(
|
|
"UPDATE collections SET parent_id = ?2 WHERE id = ?1",
|
|
rusqlite::params![a.0 as i64, b.0 as i64],
|
|
)
|
|
.unwrap();
|
|
|
|
// Neither walk may recurse forever.
|
|
let rows = tree(c).unwrap();
|
|
assert!(rows.len() <= 2);
|
|
let d = descendants(c, a).unwrap();
|
|
assert!(d.len() <= 2);
|
|
}
|
|
}
|