Files
DarkRoom/core/dr-catalog/src/collections.rs
T
dtourolleandClaude Opus 5 2147eaa6a5 Put a keyword on a photograph, not only search for one
The catalog has been able to *find* by keyword since v1 — query.rs joins the
keywords table, matches it exactly, and substring-matches it for free text — and
nothing anywhere could ever put a word there. A user could filter to a keyword
they had no way to apply.

This is the missing half: create, rename, delete, list, assign, unassign, and
the two reads a panel needs. Bulk-only for assignment, because keywording a
selection is the common case rather than the exception — the photographer picks
out the frames with the puffin in them and applies "puffin" once, in one
transaction.

Schema v6 adds `keyword_terms`, and deliberately does *not* touch the v1 join.
The assignment keeps the word as text because the catalog is a rebuildable index
and the durable copies of that fact — the sidecar, XMP dc:subject — both carry a
string; a foreign key would mean a catalog rebuilt from sidecars had to invent
identity rows before it could record anything, and would break the query path
that already works. So the text is the fact, and the new table is only the
identity a rename and a deletion can be keyed on.

`keyword_terms.name` carries no unique index, which looks like an oversight and
is not: two devices that each type "Iceland" are both right until they meet, and
a constraint would abort the merge at that moment. Uniqueness is converged upon
instead — create resolves an existing name, fuse_duplicates collapses a
cross-device pair onto the smaller uuid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 15:50:49 +02:00

1229 lines
43 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)
}
/// 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);
}
}