From d5b1f6bff5065e84017b0497cbf9fbeedd4ba375 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 9 Aug 2026 20:42:13 +0200 Subject: [PATCH] Add collections, ratings, and soft delete to the catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three features over a shared schema migration. Collections: a tree of manual collections plus smart collections whose membership *is* their stored selector. Dropping images onto a smart collection is refused rather than silently discarded, so the UI can say why the drop did nothing — member rows there would be a second source of truth that nothing reads. Ratings: the star and pick/reject axes, kept independent. Trash: soft delete to a folder, then permanent delete. Catalog::open now backfills after migrating. A migration adds a column but cannot know what the value should be for rows that already existed; backfilling on open is what stops those rows being silently partial. Timeline queries exclude shadowed JPEGs, which would otherwise double every paired shot in the histogram, and gain a range-bounded variant so zooming in returns finer buckets rather than the same coarse ones with the ends cropped. Assisted-by: LLM --- core/dr-catalog/Cargo.toml | 3 + core/dr-catalog/src/collections.rs | 1206 ++++++++++++++++++++++++++++ core/dr-catalog/src/error.rs | 17 + core/dr-catalog/src/lib.rs | 66 +- core/dr-catalog/src/rating.rs | 715 +++++++++++++++++ core/dr-catalog/src/schema.rs | 332 +++++++- core/dr-catalog/src/trash.rs | 627 +++++++++++++++ 7 files changed, 2962 insertions(+), 4 deletions(-) create mode 100644 core/dr-catalog/src/collections.rs create mode 100644 core/dr-catalog/src/rating.rs create mode 100644 core/dr-catalog/src/trash.rs diff --git a/core/dr-catalog/Cargo.toml b/core/dr-catalog/Cargo.toml index 7137e36..fe2e1b3 100644 --- a/core/dr-catalog/Cargo.toml +++ b/core/dr-catalog/Cargo.toml @@ -10,3 +10,6 @@ dr-types.workspace = true rusqlite.workspace = true thiserror.workspace = true log.workspace = true +# `collections.selector_json` — the stored form of a smart collection's +# selector. The column predates this dependency; nothing else here is JSON. +serde_json.workspace = true diff --git a/core/dr-catalog/src/collections.rs b/core/dr-catalog/src/collections.rs new file mode 100644 index 0000000..058486e --- /dev/null +++ b/core/dr-catalog/src/collections.rs @@ -0,0 +1,1206 @@ +//! 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, + pub kind: CollectionKind, + /// The stored selector, for a smart collection. + pub selector: Option, + /// 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, + kind: CollectionKind, +) -> Result { + 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, +) -> 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 = 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 = 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::(&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, 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::, _>>()?; + 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, 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::, _>>()?; + + 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, 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 = stmt + .query_map([], |r| { + let selector = r + .get::<_, Option>(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>(3)?.map(|v| CollectionId(v as u64)), + kind: CollectionKind::from_i64(r.get(4)?), + selector, + direct_count: r.get::<_, i64>(6)? as usize, + }) + })? + .collect::, _>>()?; + + // 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 = 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, + depth: usize, + visited: &mut std::collections::HashSet, + out: &mut Vec, + ) { + 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 { + let ids = descendants(conn, id)?; + let list: Vec = 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::>() + .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 { + 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 = 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. +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 = 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 = { + 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); + } +} diff --git a/core/dr-catalog/src/error.rs b/core/dr-catalog/src/error.rs index 080ae0e..56391f7 100644 --- a/core/dr-catalog/src/error.rs +++ b/core/dr-catalog/src/error.rs @@ -33,9 +33,26 @@ pub enum CatalogError { #[error("no such collection: {0}")] NoSuchCollection(u64), + /// Images were dropped onto a smart collection. + /// + /// A smart collection's membership *is* its selector, so member rows would + /// be a second source of truth that nothing reads. Refused rather than + /// silently discarded, so the UI can say why the drop did nothing. + #[error("collection {0} is a saved filter; its contents cannot be edited by hand")] + SmartCollectionNotEditable(u64), + #[error("malformed stored selector: {0}")] BadSelector(String), + /// A name the user typed that cannot be stored — blank, or one a sibling + /// already holds. + /// + /// Its own variant rather than a reused `BadSelector`, because this one is + /// shown to the user verbatim: it has to read as a sentence about their + /// collection, not as a diagnostic about a stored selector. + #[error("{0}")] + BadName(String), + #[error("io: {0}")] Io(String), } diff --git a/core/dr-catalog/src/lib.rs b/core/dr-catalog/src/lib.rs index 587bc62..6bea625 100644 --- a/core/dr-catalog/src/lib.rs +++ b/core/dr-catalog/src/lib.rs @@ -12,7 +12,9 @@ //! - [`schema`] — tables and forward-only migrations //! - [`scan`] — incremental discovery that prunes unchanged directories //! - [`query`] — selectors compiled to indexed SQL, windowed for the grid +//! - [`collections`] — the collection tree and membership the UI edits //! - [`jobs`] — the durable background work queue +//! - [`trash`] — soft delete to a folder, then permanent delete //! - [`merge`] / [`sync`] — cross-device collection merging //! //! # The one thing everything is designed around @@ -28,19 +30,25 @@ use std::path::Path; use dr_types::{Availability, ImageId}; use rusqlite::Connection; +pub mod collections; pub mod error; pub mod jobs; pub mod merge; pub mod query; +pub mod rating; pub mod scan; pub mod schema; pub mod sync; +pub mod trash; +pub use collections::{Collection, CollectionKind, TreeRow}; pub use error::CatalogError; pub use jobs::{Job, JobKind, Priority}; pub use merge::MergeReport; pub use query::{Query, Sort}; +pub use rating::{Judgement, MAX_RATING}; pub use scan::{DirAction, DirState, EntryAction, ScanOutcome}; +pub use trash::{TrashedImage, TRASH_DIR}; /// One row of the library grid. /// @@ -114,7 +122,13 @@ impl Catalog { pub fn open(path: &Path) -> Result { let conn = Connection::open(path)?; schema::configure(&conn)?; - schema::migrate(&conn)?; + let from = schema::migrate(&conn)?; + // A migration adds a column; it cannot know what the value should be + // for rows that already existed. Backfilling on open is what stops + // those rows being silently partial. + for (what, n) in schema::backfill(&conn)? { + log::info!("backfilled {what} for {n} row(s) (schema was v{from})"); + } Ok(Catalog { conn }) } @@ -123,6 +137,7 @@ impl Catalog { let conn = Connection::open_in_memory()?; schema::configure(&conn)?; schema::migrate(&conn)?; + schema::backfill(&conn)?; Ok(Catalog { conn }) } @@ -203,7 +218,9 @@ impl Catalog { "SELECT min(captured_at) AS start, count(*) AS n FROM images - WHERE {} AND captured_at IS NOT NULL + -- A shadowed JPEG is the same frame as its RAW; counting both + -- would double every paired shot in the histogram. + WHERE {} AND captured_at IS NOT NULL AND shadowed_by IS NULL GROUP BY strftime('{}', captured_at + coalesce(captured_offset, 0) * 60, 'unixepoch') ORDER BY start ASC", @@ -223,6 +240,51 @@ impl Catalog { Ok(rows) } + /// Counts per time bucket, bounded to a date range. + /// + /// What a zoomed timeline needs: [`timeline`](Self::timeline) always spans + /// the whole library, so zooming in would return the same coarse buckets + /// with the ends cropped rather than finer detail over a narrower span. + pub fn timeline_range( + &self, + q: &Query, + g: Granularity, + from: i64, + to: i64, + now: i64, + ) -> Result, CatalogError> { + let c = query::compile(&q.filter, now); + let sql = format!( + "SELECT min(captured_at) AS start, + count(*) AS n + FROM images + WHERE {} AND captured_at IS NOT NULL AND shadowed_by IS NULL + AND captured_at >= ?{} AND captured_at <= ?{} + GROUP BY strftime('{}', captured_at + coalesce(captured_offset, 0) * 60, + 'unixepoch') + ORDER BY start ASC", + c.where_sql, + c.params.len() + 1, + c.params.len() + 2, + g.strftime() + ); + + let mut params = c.params.clone(); + params.push(rusqlite::types::Value::Integer(from)); + params.push(rusqlite::types::Value::Integer(to)); + + let mut stmt = self.conn.prepare(&sql)?; + let rows = stmt + .query_map(rusqlite::params_from_iter(params.iter()), |r| { + Ok(TimeBucket { + start: r.get(0)?, + count: r.get::<_, i64>(1)? as u32, + }) + })? + .collect::, _>>()?; + Ok(rows) + } + /// Merge a downloaded remote catalog's collections into this one. /// /// See [`sync`] for why only collections cross over. diff --git a/core/dr-catalog/src/rating.rs b/core/dr-catalog/src/rating.rs new file mode 100644 index 0000000..3e12b92 --- /dev/null +++ b/core/dr-catalog/src/rating.rs @@ -0,0 +1,715 @@ +//! TRACES: FR-CAT-5 | FR-CAT-6 | FR-CULL-4 +//! Star ratings and pick/reject flags — the judgement a cull produces. +//! +//! # Why this hangs off `versions` rather than `images` +//! +//! The schema already carries `rating`, `label` and `flag` on `versions`, and +//! [`crate::query`] already compiles [`dr_types::Selector::Rating`] and +//! [`dr_types::Selector::Flag`] against the *default* version. What was +//! missing is that nothing ever created a version row: a scan inserts into +//! `images` and stops, so every image had no version, and therefore nowhere +//! to record a rating. The whole library sat permanently unrated with no way +//! out of that state. +//! +//! So this module's first job is [`ensure_default_versions`] — every image +//! gets exactly one default version, created at scan time and backfilled by +//! the v2 migration for libraries scanned before this existed. +//! +//! Keeping judgement on the version rather than the image is what makes +//! FR-CAT-12's virtual copies coherent: two crops of one frame are two +//! photographs to the photographer, and one may be a keeper while the other +//! is a reject. Hoisting the rating onto the image would force them to agree. +//! +//! # Unrated is a real state, not a zero +//! +//! `rating = 0` means *not yet judged*, and that is precisely what "filter to +//! unjudged" selects (FR-CULL-4). It is deliberately not conflated with "one +//! star" or with "rejected" — those are three different answers, and a cull +//! that cannot distinguish "I have not looked at this" from "I looked and it +//! is poor" cannot be resumed. + +use rusqlite::{Connection, OptionalExtension}; + +use dr_types::{FlagState, ImageId}; + +use crate::error::CatalogError; + +/// Highest star rating. Five, as every photo tool has settled on. +pub const MAX_RATING: u8 = 5; + +/// Name given to the version created for an image that has none. +/// +/// Matches what [`crate::collections`] and the sidecar both expect to see for +/// the original, unmodified frame. +pub const DEFAULT_VERSION_NAME: &str = "Default"; + +/// The judgement recorded against one image's default version. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Judgement { + /// 0..=5. Zero means *unrated*, which is a state in its own right. + pub rating: u8, + pub flag: FlagState, +} + +impl Judgement { + /// Whether this image has been judged at all. + /// + /// Either axis counts: a photographer who flags without starring, or stars + /// without flagging, has still made a decision about the frame. "Filter to + /// unjudged" (FR-CULL-4) is the negation of this, and getting it wrong + /// means a resumed session re-presents work already done. + pub fn is_judged(self) -> bool { + self.rating > 0 || self.flag != FlagState::Unflagged + } +} + +/// Give every image without one a default version. +/// +/// Idempotent, and cheap on the common path: the `NOT EXISTS` sub-select is +/// answered by the `versions_image` index, so a library that already has its +/// versions costs one indexed scan and writes nothing. +/// +/// Returns how many were created, so a scan can log the backfill rather than +/// silently doing thousands of inserts. +/// +/// The UUID is per row and generated here — it is the merge identity across +/// devices (FR-NC-8), so two images must never share one. +pub fn ensure_default_versions(conn: &Connection) -> Result { + let ids: Vec = { + let mut stmt = conn.prepare( + "SELECT i.id FROM images i + WHERE NOT EXISTS (SELECT 1 FROM versions v WHERE v.image_id = i.id)", + )?; + let found = stmt + .query_map([], |r| r.get(0))? + .collect::, _>>()?; + found + }; + if ids.is_empty() { + return Ok(0); + } + + // One transaction for the batch. A backfill over a 24k-image library is + // 24k inserts, and per-statement commits would make it minutes rather + // than seconds. + let tx = conn.unchecked_transaction()?; + { + let mut insert = tx.prepare( + "INSERT INTO versions(image_id, uuid, name, is_default, rating, flag) + VALUES (?1, ?2, ?3, 1, 0, 0)", + )?; + for id in &ids { + insert.execute(rusqlite::params![id, new_uuid(), DEFAULT_VERSION_NAME])?; + } + } + tx.commit()?; + + Ok(ids.len()) +} + +/// The default version's row id for an image, creating one if it has none. +/// +/// Every write path goes through this rather than assuming a version exists. +/// An image can arrive without one in two ways that are not worth trying to +/// prevent: a row inserted by a build predating this module, and a scan whose +/// version pass was interrupted between the image insert and the commit. +/// Failing a rating because of either would be the wrong answer — the user +/// pressed a key and expects a star. +pub fn default_version_id(conn: &Connection, image: ImageId) -> Result { + let existing: Option = conn + .query_row( + "SELECT id FROM versions + WHERE image_id = ?1 + ORDER BY is_default DESC, id ASC + LIMIT 1", + [image.0 as i64], + |r| r.get(0), + ) + .optional()?; + + if let Some(id) = existing { + return Ok(id); + } + + conn.execute( + "INSERT INTO versions(image_id, uuid, name, is_default, rating, flag) + VALUES (?1, ?2, ?3, 1, 0, 0)", + rusqlite::params![image.0 as i64, new_uuid(), DEFAULT_VERSION_NAME], + )?; + Ok(conn.last_insert_rowid()) +} + +/// Set the star rating for one image, clamped to 0..=[`MAX_RATING`]. +/// +/// Clamped rather than rejected: the value comes from a keystroke or a click +/// on a star strip, and there is no useful error to show a photographer who +/// pressed a key. Out of range can only mean a UI bug, and losing the +/// keystroke would be a worse symptom than recording five. +pub fn set_rating(conn: &Connection, image: ImageId, rating: u8) -> Result<(), CatalogError> { + let version = default_version_id(conn, image)?; + conn.execute( + "UPDATE versions SET rating = ?2 WHERE id = ?1", + rusqlite::params![version, rating.min(MAX_RATING) as i64], + )?; + Ok(()) +} + +/// Set the pick/reject flag for one image. +pub fn set_flag(conn: &Connection, image: ImageId, flag: FlagState) -> Result<(), CatalogError> { + let version = default_version_id(conn, image)?; + conn.execute( + "UPDATE versions SET flag = ?2 WHERE id = ?1", + rusqlite::params![version, flag_code(flag)], + )?; + Ok(()) +} + +/// Apply a rating to many images in one transaction. +/// +/// The bulk path exists because rating a selection is one gesture: the user +/// selects forty frames and presses `3`. Forty separate transactions would be +/// forty fsyncs for what is conceptually a single edit, and a crash partway +/// through would leave the selection half-rated. +pub fn set_rating_many( + conn: &Connection, + images: &[ImageId], + rating: u8, +) -> Result { + apply_many(conn, images, |conn, id| set_rating(conn, id, rating)) +} + +/// Apply a flag to many images in one transaction. See [`set_rating_many`]. +pub fn set_flag_many( + conn: &Connection, + images: &[ImageId], + flag: FlagState, +) -> Result { + apply_many(conn, images, |conn, id| set_flag(conn, id, flag)) +} + +/// Shared bulk wrapper, so the two axes cannot drift in their commit +/// behaviour — a partially-committed rating and a fully-committed flag from +/// the same keystroke would be hard to explain and harder to notice. +fn apply_many( + conn: &Connection, + images: &[ImageId], + mut one: impl FnMut(&Connection, ImageId) -> Result<(), CatalogError>, +) -> Result { + if images.is_empty() { + return Ok(0); + } + let tx = conn.unchecked_transaction()?; + for id in images { + one(&tx, *id)?; + } + tx.commit()?; + Ok(images.len()) +} + +/// Read the judgement for one image. +/// +/// An image with no version reads as unrated and unflagged rather than as an +/// error: that is exactly what it is. +pub fn judgement(conn: &Connection, image: ImageId) -> Result { + let row: Option<(i64, i64)> = conn + .query_row( + "SELECT rating, flag FROM versions + WHERE image_id = ?1 + ORDER BY is_default DESC, id ASC + LIMIT 1", + [image.0 as i64], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional()?; + + Ok(match row { + Some((rating, flag)) => Judgement { + rating: rating.clamp(0, MAX_RATING as i64) as u8, + flag: flag_from_code(flag), + }, + None => Judgement::default(), + }) +} + +/// Judgements for a window of images, in one statement. +/// +/// The grid needs a star strip per cell, and one query per cell would be 120 +/// round trips on every scroll — the same reasoning as +/// `collections_ui::sync_badges`. Images with no version simply do not appear +/// in the result, and the caller treats a miss as unrated. +pub fn judgements( + conn: &Connection, + images: &[ImageId], +) -> Result, CatalogError> { + let mut out = std::collections::HashMap::new(); + if images.is_empty() { + return Ok(out); + } + + // Placeholders are generated from the *count* of ids, never from any text + // that came from outside — the same rule `read_cells_scoped` follows. + let placeholders = std::iter::repeat_n("?", images.len()) + .collect::>() + .join(","); + let sql = format!( + "SELECT image_id, rating, flag FROM versions + WHERE image_id IN ({placeholders}) AND is_default = 1" + ); + + let params: Vec = images + .iter() + .map(|i| rusqlite::types::Value::Integer(i.0 as i64)) + .collect(); + + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { + Ok(( + r.get::<_, i64>(0)?, + r.get::<_, i64>(1)?, + r.get::<_, i64>(2)?, + )) + })?; + + for (image, rating, flag) in rows.flatten() { + out.insert( + ImageId(image as u64), + Judgement { + rating: rating.clamp(0, MAX_RATING as i64) as u8, + flag: flag_from_code(flag), + }, + ); + } + Ok(out) +} + +/// How the library divides by rating, for the filter bar's counts. +/// +/// Index `n` is the number of images rated `n`, so index 0 is the unrated +/// count. Shown beside each filter button so the user can see there is +/// something behind it before narrowing to it — a filter that silently +/// empties the grid reads as a broken filter. +pub fn rating_histogram(conn: &Connection) -> Result<[usize; 6], CatalogError> { + let mut out = [0usize; 6]; + + // LEFT JOIN, so an image whose version row is missing still counts as + // unrated rather than vanishing from the totals. The histogram has to sum + // to the library size or it is not believable. + let mut stmt = conn.prepare( + "SELECT coalesce(v.rating, 0) AS r, count(*) + FROM images i + LEFT JOIN versions v ON v.image_id = i.id AND v.is_default = 1 + GROUP BY r", + )?; + let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?; + + for (rating, count) in rows.flatten() { + if let Some(slot) = out.get_mut(rating.clamp(0, MAX_RATING as i64) as usize) { + *slot += count as usize; + } + } + Ok(out) +} + +/// How many images carry each flag: `(picks, rejects)`. +pub fn flag_counts(conn: &Connection) -> Result<(usize, usize), CatalogError> { + let picks: i64 = conn.query_row( + "SELECT count(*) FROM versions WHERE is_default = 1 AND flag = 1", + [], + |r| r.get(0), + )?; + let rejects: i64 = conn.query_row( + "SELECT count(*) FROM versions WHERE is_default = 1 AND flag = 2", + [], + |r| r.get(0), + )?; + Ok((picks as usize, rejects as usize)) +} + +/// The stored integer for a flag. Matches [`crate::query::flag_code`]'s +/// mapping — the two must agree or a filter will not find what a write stored. +fn flag_code(f: FlagState) -> i64 { + match f { + FlagState::Unflagged => 0, + FlagState::Pick => 1, + FlagState::Reject => 2, + } +} + +fn flag_from_code(v: i64) -> FlagState { + match v { + 1 => FlagState::Pick, + 2 => FlagState::Reject, + _ => FlagState::Unflagged, + } +} + +/// A version UUID. +/// +/// Hand-rolled rather than pulling in the `uuid` crate for one function — the +/// same reasoning as the date maths in `library_ui`. This needs to be unique +/// across devices, not cryptographically unguessable: it keys a merge, and an +/// attacker who can write to the sidecar has already won. +/// +/// Seeded from the system clock and a per-process counter, so two versions +/// created inside the same nanosecond tick still differ. +fn new_uuid() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + + // Mixed so successive ids do not share a long common prefix, which makes + // them easier to tell apart when reading a sidecar by eye. + let a = nanos ^ (n.wrapping_mul(0x9E37_79B9_7F4A_7C15)); + let b = nanos + .rotate_left(32) + .wrapping_add(n.wrapping_mul(0xBF58_476D_1CE4_E5B9)); + + format!( + "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}", + (a >> 32) as u32, + (a >> 16) as u16, + (a & 0x0FFF) as u16, + // Variant bits, so this is a well-formed v4-shaped UUID rather than + // something that merely looks like one. + ((b >> 48) as u16 & 0x3FFF) | 0x8000, + b & 0xFFFF_FFFF_FFFF, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Catalog; + + /// A catalog holding `n` images and nothing else — the state a scan + /// leaves behind before this module runs. + fn with_images(n: usize) -> 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 0..n { + c.execute( + "INSERT INTO images(root_id, source_ref, added_at) VALUES (1, ?1, 0)", + [format!("img{i:03}.CR3")], + ) + .unwrap(); + } + cat + } + + fn ids(cat: &Catalog) -> Vec { + let mut stmt = cat + .connection() + .prepare("SELECT id FROM images ORDER BY id") + .unwrap(); + stmt.query_map([], |r| Ok(ImageId(r.get::<_, i64>(0)? as u64))) + .unwrap() + .map(Result::unwrap) + .collect() + } + + #[test] + fn every_scanned_image_gets_a_default_version() { + // The gap this module exists to close: a scan inserted images and no + // versions, so there was nowhere for a rating to go. + let cat = with_images(5); + assert_eq!(ensure_default_versions(cat.connection()).unwrap(), 5); + + let n: i64 = cat + .connection() + .query_row( + "SELECT count(*) FROM versions WHERE is_default = 1", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(n, 5); + } + + #[test] + fn images_enter_unrated_rather_than_at_one_star() { + // "Not yet judged" is the state a cull starts from and resumes to. + let cat = with_images(3); + ensure_default_versions(cat.connection()).unwrap(); + + for id in ids(&cat) { + let j = judgement(cat.connection(), id).unwrap(); + assert_eq!(j.rating, 0); + assert_eq!(j.flag, FlagState::Unflagged); + assert!(!j.is_judged()); + } + } + + #[test] + fn backfilling_twice_creates_nothing_the_second_time() { + // Runs on every scan, so a second pass must not double every version. + let cat = with_images(4); + assert_eq!(ensure_default_versions(cat.connection()).unwrap(), 4); + assert_eq!(ensure_default_versions(cat.connection()).unwrap(), 0); + + let n: i64 = cat + .connection() + .query_row("SELECT count(*) FROM versions", [], |r| r.get(0)) + .unwrap(); + assert_eq!(n, 4, "one version per image, not two"); + } + + #[test] + fn version_uuids_are_unique_across_a_batch() { + // The uuid is the cross-device merge identity: two images sharing one + // silently fuse their edits at the next sync. + let cat = with_images(200); + ensure_default_versions(cat.connection()).unwrap(); + + let distinct: i64 = cat + .connection() + .query_row("SELECT count(DISTINCT uuid) FROM versions", [], |r| r.get(0)) + .unwrap(); + assert_eq!(distinct, 200); + } + + #[test] + fn a_rating_round_trips() { + let cat = with_images(1); + let id = ids(&cat)[0]; + set_rating(cat.connection(), id, 4).unwrap(); + assert_eq!(judgement(cat.connection(), id).unwrap().rating, 4); + } + + #[test] + fn rating_an_image_with_no_version_creates_one() { + // A library scanned by a build predating this module, or a scan that + // died between the image insert and the version pass. The keystroke + // must still land. + let cat = with_images(1); + let id = ids(&cat)[0]; + // Deliberately *not* calling ensure_default_versions first. + set_rating(cat.connection(), id, 3).unwrap(); + assert_eq!(judgement(cat.connection(), id).unwrap().rating, 3); + } + + #[test] + fn an_out_of_range_rating_is_clamped_rather_than_stored() { + // A stored 9 would sort above five stars forever and no filter would + // reach it. + let cat = with_images(1); + let id = ids(&cat)[0]; + set_rating(cat.connection(), id, 99).unwrap(); + assert_eq!(judgement(cat.connection(), id).unwrap().rating, MAX_RATING); + } + + #[test] + fn rating_back_to_zero_returns_an_image_to_unrated() { + // Pressing 0 is how a mistake is undone, so it has to be reachable — + // not a floor at one star. + let cat = with_images(1); + let id = ids(&cat)[0]; + set_rating(cat.connection(), id, 5).unwrap(); + set_rating(cat.connection(), id, 0).unwrap(); + + let j = judgement(cat.connection(), id).unwrap(); + assert_eq!(j.rating, 0); + assert!(!j.is_judged(), "back to unjudged, so a cull re-presents it"); + } + + #[test] + fn flags_and_stars_are_independent_axes() { + // Rejecting a four-star frame is a normal thing to do while culling, + // and one axis must not clear the other. + let cat = with_images(1); + let id = ids(&cat)[0]; + set_rating(cat.connection(), id, 4).unwrap(); + set_flag(cat.connection(), id, FlagState::Reject).unwrap(); + + let j = judgement(cat.connection(), id).unwrap(); + assert_eq!(j.rating, 4); + assert_eq!(j.flag, FlagState::Reject); + } + + #[test] + fn a_flag_alone_counts_as_judged() { + // Filter-to-unjudged must not re-present a frame the user already + // picked, merely because they did not also star it. + let cat = with_images(1); + let id = ids(&cat)[0]; + set_flag(cat.connection(), id, FlagState::Pick).unwrap(); + assert!(judgement(cat.connection(), id).unwrap().is_judged()); + } + + #[test] + fn a_bulk_rating_applies_to_the_whole_selection() { + // One gesture: select forty, press 3. + let cat = with_images(10); + let all = ids(&cat); + let chosen = &all[2..7]; + + assert_eq!(set_rating_many(cat.connection(), chosen, 3).unwrap(), 5); + + for id in chosen { + assert_eq!(judgement(cat.connection(), *id).unwrap().rating, 3); + } + // And nothing outside the selection moved. + assert_eq!(judgement(cat.connection(), all[0]).unwrap().rating, 0); + assert_eq!(judgement(cat.connection(), all[9]).unwrap().rating, 0); + } + + #[test] + fn a_bulk_write_over_an_empty_selection_is_a_no_op() { + let cat = with_images(3); + assert_eq!(set_rating_many(cat.connection(), &[], 5).unwrap(), 0); + assert_eq!(set_flag_many(cat.connection(), &[], FlagState::Pick).unwrap(), 0); + } + + #[test] + fn judgements_reads_a_whole_window_in_one_query() { + // The grid draws a star strip per cell; one query per cell would be + // 120 round trips on every scroll. + let cat = with_images(6); + let all = ids(&cat); + set_rating(cat.connection(), all[1], 2).unwrap(); + set_flag(cat.connection(), all[3], FlagState::Pick).unwrap(); + + let map = judgements(cat.connection(), &all).unwrap(); + assert_eq!(map.get(&all[1]).unwrap().rating, 2); + assert_eq!(map.get(&all[3]).unwrap().flag, FlagState::Pick); + // Never rated, so it is either absent or explicitly unrated — both + // mean the same thing to the caller. + assert_eq!( + map.get(&all[5]).copied().unwrap_or_default(), + Judgement::default() + ); + } + + #[test] + fn the_histogram_sums_to_the_library_size() { + // A histogram that disagrees with the image count is not believable, + // and the unrated bucket is the one a fresh library lives in. + let cat = with_images(8); + ensure_default_versions(cat.connection()).unwrap(); + let all = ids(&cat); + set_rating(cat.connection(), all[0], 5).unwrap(); + set_rating(cat.connection(), all[1], 5).unwrap(); + set_rating(cat.connection(), all[2], 3).unwrap(); + + let h = rating_histogram(cat.connection()).unwrap(); + assert_eq!(h[5], 2); + assert_eq!(h[3], 1); + assert_eq!(h[0], 5, "the rest are still unrated"); + assert_eq!(h.iter().sum::(), 8); + } + + #[test] + fn the_histogram_counts_images_with_no_version_as_unrated() { + // They are unrated. Dropping them would make the counts disagree with + // the grid, which is the failure the LEFT JOIN exists to prevent. + let cat = with_images(4); + // No ensure_default_versions call at all. + let h = rating_histogram(cat.connection()).unwrap(); + assert_eq!(h[0], 4); + assert_eq!(h.iter().sum::(), 4); + } + + #[test] + fn flag_counts_separate_picks_from_rejects() { + let cat = with_images(5); + let all = ids(&cat); + set_flag(cat.connection(), all[0], FlagState::Pick).unwrap(); + set_flag(cat.connection(), all[1], FlagState::Pick).unwrap(); + set_flag(cat.connection(), all[2], FlagState::Reject).unwrap(); + + assert_eq!(flag_counts(cat.connection()).unwrap(), (2, 1)); + } + + #[test] + fn unflagging_removes_an_image_from_both_counts() { + let cat = with_images(2); + let all = ids(&cat); + set_flag(cat.connection(), all[0], FlagState::Reject).unwrap(); + set_flag(cat.connection(), all[0], FlagState::Unflagged).unwrap(); + assert_eq!(flag_counts(cat.connection()).unwrap(), (0, 0)); + } + + #[test] + fn a_rating_survives_the_selector_that_queries_it() { + // The end-to-end property: what this module writes is what + // `dr_catalog::query` compiles `Selector::Rating` to find. These are + // two independent pieces of SQL and they must agree on where a rating + // lives, or rating an image would appear to do nothing. + use crate::Query; + use dr_types::Selector; + + let cat = with_images(6); + let all = ids(&cat); + ensure_default_versions(cat.connection()).unwrap(); + set_rating(cat.connection(), all[0], 5).unwrap(); + set_rating(cat.connection(), all[1], 4).unwrap(); + set_rating(cat.connection(), all[2], 1).unwrap(); + + let q = Query { + filter: Selector::Rating { min: 4 }, + ..Default::default() + }; + assert_eq!(cat.count(&q, 0).unwrap(), 2); + } + + #[test] + fn a_flag_survives_the_selector_that_queries_it() { + // Same contract for the other axis: `flag_code` here and in `query` + // are separate mappings and must not drift. + use crate::Query; + use dr_types::Selector; + + let cat = with_images(4); + let all = ids(&cat); + ensure_default_versions(cat.connection()).unwrap(); + set_flag(cat.connection(), all[0], FlagState::Pick).unwrap(); + set_flag(cat.connection(), all[1], FlagState::Reject).unwrap(); + + let picks = Query { + filter: Selector::Flag(FlagState::Pick), + ..Default::default() + }; + assert_eq!(cat.count(&picks, 0).unwrap(), 1); + + let rejects = Query { + filter: Selector::Flag(FlagState::Reject), + ..Default::default() + }; + assert_eq!(cat.count(&rejects, 0).unwrap(), 1); + } + + #[test] + fn unjudged_is_reachable_as_a_filter() { + // FR-CULL-4's "filter to unjudged", which is what lets a session + // resume where it stopped. + use crate::Query; + use dr_types::Selector; + + let cat = with_images(5); + let all = ids(&cat); + ensure_default_versions(cat.connection()).unwrap(); + set_rating(cat.connection(), all[0], 2).unwrap(); + + let q = Query { + filter: Selector::Rating { min: 0 }, + ..Default::default() + }; + // `min: 0` matches everything, so unjudged needs the negation. + assert_eq!(cat.count(&q, 0).unwrap(), 5); + + let unrated = Query { + filter: Selector::Not(Box::new(Selector::Rating { min: 1 })), + ..Default::default() + }; + assert_eq!(cat.count(&unrated, 0).unwrap(), 4); + } +} diff --git a/core/dr-catalog/src/schema.rs b/core/dr-catalog/src/schema.rs index a52c559..675e5f7 100644 --- a/core/dr-catalog/src/schema.rs +++ b/core/dr-catalog/src/schema.rs @@ -15,7 +15,7 @@ use rusqlite::Connection; use crate::error::CatalogError; /// Schema version this build writes and understands. -pub const SCHEMA_VERSION: i64 = 1; +pub const SCHEMA_VERSION: i64 = 4; /// Apply migrations up to [`SCHEMA_VERSION`]. /// @@ -42,10 +42,64 @@ pub fn migrate(conn: &Connection) -> Result { tx.pragma_update(None, "user_version", 1)?; tx.commit()?; } + if from < 2 { + let tx = conn.unchecked_transaction()?; + tx.execute_batch(V2)?; + tx.pragma_update(None, "user_version", 2)?; + tx.commit()?; + } + if from < 3 { + let tx = conn.unchecked_transaction()?; + tx.execute_batch(V3)?; + tx.pragma_update(None, "user_version", 3)?; + tx.commit()?; + } + if from < 4 { + let tx = conn.unchecked_transaction()?; + tx.execute_batch(V4)?; + tx.pragma_update(None, "user_version", 4)?; + tx.commit()?; + } Ok(from) } +/// Recompute columns a migration added, for rows that predate it. +/// +/// A migration adds a column with a default; it cannot know what the value +/// *should* be for the rows already present. Without a backfill those rows are +/// silently partial — present, queryable, and wrong — which is worse than +/// missing, because nothing signals that they need attention. +/// +/// Cheap enough to run on every open: each pass is one indexed UPDATE, and +/// re-running it is a no-op once the values are already right. +/// +/// Returns how many rows each backfill touched, for logging. +pub fn backfill(conn: &Connection) -> Result, CatalogError> { + let mut out = Vec::new(); + + // v2: `shadowed_by`. A JPEG sitting beside a RAW of the same name is the + // camera's own rendering of that frame, not a second photograph, so it is + // hidden from the grid, the timeline and the sweep. + let n = pair_raw_and_jpeg(conn)?; + if n > 0 { + out.push(("shadowed_by", n)); + } + + // v3: every image needs a default version to carry its rating and flag. + // Libraries scanned before ratings existed have images and no versions at + // all, so there was nowhere for a judgement to go — see + // [`crate::rating`]. Backfilled rather than migrated in SQL because the + // UUID per row is the cross-device merge identity and must be generated, + // not derived. + let n = crate::rating::ensure_default_versions(conn)?; + if n > 0 { + out.push(("default_versions", n)); + } + + Ok(out) +} + /// Connection setup applied on every open, migration or not. /// /// WAL is required by NFR-R1: it survives power loss without corruption, and @@ -85,6 +139,127 @@ pub fn v1_for_attached(schema_name: &str) -> String { // does, and `CREATE INDEX x.name ON table` is the correct form. } +/// Mark each JPEG that sits beside a RAW of the same name. +/// +/// Matched on folder plus stem, case-insensitively. Same-folder is what makes +/// this safe: cameras write the pair side by side, and matching across folders +/// would risk pairing unrelated frames, since camera filenames wrap at +/// IMG_9999 (FR-CAT-11). +/// +/// Done in Rust rather than SQL because the comparison needs a filename stem, +/// and SQLite has no such function without enabling `rusqlite/functions` — +/// a dependency feature for one string operation, whose SQL spelling would be +/// unreadable and would mishandle names with no extension. +fn pair_raw_and_jpeg(conn: &Connection) -> Result { + use std::collections::HashMap; + + // (folder, lowercase stem) -> RAW id, built in one pass over the RAWs. + let mut raws: HashMap<(Option, String), i64> = HashMap::new(); + { + let mut stmt = conn.prepare( + "SELECT id, folder_id, source_ref FROM images + WHERE lower(format) IN + ('cr2','cr3','nef','arw','raf','rw2','orf','dng')", + )?; + let rows = stmt.query_map([], |r| { + Ok(( + r.get::<_, i64>(0)?, + r.get::<_, Option>(1)?, + r.get::<_, String>(2)?, + )) + })?; + for row in rows { + let (id, folder, path) = row?; + raws.insert((folder, stem_of(&path).to_ascii_lowercase()), id); + } + } + if raws.is_empty() { + return Ok(0); + } + + let pairs: Vec<(i64, i64)> = { + let mut stmt = conn.prepare( + "SELECT id, folder_id, source_ref FROM images + WHERE lower(format) IN ('jpg','jpeg') AND shadowed_by IS NULL", + )?; + let rows = stmt.query_map([], |r| { + Ok(( + r.get::<_, i64>(0)?, + r.get::<_, Option>(1)?, + r.get::<_, String>(2)?, + )) + })?; + rows.filter_map(|row| { + let (id, folder, path) = row.ok()?; + let raw = raws.get(&(folder, stem_of(&path).to_ascii_lowercase()))?; + Some((id, *raw)) + }) + .collect() + }; + + let tx = conn.unchecked_transaction()?; + for (jpeg, raw) in &pairs { + tx.execute( + "UPDATE images SET shadowed_by = ?2 WHERE id = ?1", + [jpeg, raw], + )?; + } + tx.commit()?; + Ok(pairs.len()) +} + +/// A filename without its extension. +/// +/// Only the final path component, and only its last dot — a directory +/// containing a dot must not truncate the name. +fn stem_of(path: &str) -> &str { + let name = path.rsplit(['/', ':']).next().unwrap_or(path); + match name.rsplit_once('.') { + Some((stem, _)) if !stem.is_empty() => stem, + _ => name, + } +} + +const V4: &str = r#" +-- TRACES: FR-CAT-15 +-- Soft delete. A trashed image is a real file that has been *moved* to a trash +-- folder under the library root, not a row hidden by a flag: the catalog is a +-- rebuildable index (ARCH §6.12), so a flag alone would evaporate the moment +-- the catalog was deleted and every trashed photograph would return. +-- +-- `source_ref` follows the file to its new path, because that is where the bytes +-- now are and every fetch resolves through it. `trashed_from` remembers where it +-- came from, which is the only way a restore can put it back — the trash is flat +-- and the original folder structure is not recoverable from the trashed path. +ALTER TABLE images ADD COLUMN trashed_at INTEGER; +ALTER TABLE images ADD COLUMN trashed_from TEXT; + +-- Partial: almost no rows are trashed, and the grid's "not trashed" predicate is +-- answered by the absence of an entry rather than by scanning every image. +CREATE INDEX images_trashed ON images(trashed_at) WHERE trashed_at IS NOT NULL; +"#; + +const V3: &str = r#" +-- Ratings and flags are read per grid window and counted for the filter bar's +-- histogram, both of which key on the *default* version. Without this the +-- histogram is a full scan of `versions` on every judgement. +-- +-- Partial on `is_default`: a virtual copy's rating is real but is never what +-- these two queries ask for, and excluding them keeps the index roughly one +-- entry per image rather than one per version. +CREATE INDEX versions_judgement ON versions(image_id, rating, flag) + WHERE is_default = 1; +"#; + +const V2: &str = r#" +-- A JPEG the camera wrote alongside a RAW of the same name is that RAW's own +-- rendering, not a second photograph. Recording *which* RAW shadows it, rather +-- than a bare flag, keeps the relationship usable: the JPEG is a ready-made +-- preview for its RAW, and the pairing can be undone without a rescan. +ALTER TABLE images ADD COLUMN shadowed_by INTEGER REFERENCES images(id) ON DELETE SET NULL; +CREATE INDEX images_shadowed ON images(shadowed_by) WHERE shadowed_by IS NOT NULL; +"#; + const V1: &str = r#" -- Roots ------------------------------------------------------------------- CREATE TABLE roots ( @@ -95,7 +270,12 @@ CREATE TABLE roots ( last_seen INTEGER, -- Bumped once per completed scan. Folders record the generation they were -- reached in; anything older was not reached and no longer exists. - scan_generation INTEGER NOT NULL DEFAULT 0 + scan_generation INTEGER NOT NULL DEFAULT 0, + -- One row per granted location. Without this, a rescan inserts a second + -- root for the same folder and the library silently fragments across + -- them — images split between roots, and pruning compares against the + -- wrong generation. + UNIQUE(kind, label) ); -- Folders: the unit of change detection, local and remote alike ----------- @@ -277,6 +457,154 @@ mod tests { c } + + + /// How many rows a named backfill touched, ignoring the others. + /// + /// Asserting on the whole vector would couple every test to which other + /// backfills happen to exist. + fn backfilled(c: &Connection, what: &str) -> usize { + backfill(c) + .unwrap() + .into_iter() + .find(|(name, _)| *name == what) + .map(|(_, n)| n) + .unwrap_or(0) + } + + /// Insert an image and return its id. + fn image(c: &Connection, folder: Option, name: &str, format: &str) -> i64 { + c.execute( + "INSERT INTO images(root_id, folder_id, source_ref, format, added_at) + VALUES (1, ?1, ?2, ?3, 0)", + rusqlite::params![folder, name, format], + ) + .unwrap(); + c.last_insert_rowid() + } + + fn with_root() -> Connection { + let c = mem(); + migrate(&c).unwrap(); + c.execute( + "INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')", + [], + ) + .unwrap(); + c.execute( + "INSERT INTO folders(id, root_id, path) VALUES (1, 1, 'a'), (2, 1, 'b')", + [], + ) + .unwrap(); + c + } + + #[test] + fn a_jpeg_beside_its_raw_is_shadowed() { + // The camera's own rendering of a frame, not a second photograph. + let c = with_root(); + let raw = image(&c, Some(1), "a/IMG_1234.CR2", "cr2"); + let jpeg = image(&c, Some(1), "a/IMG_1234.JPG", "jpg"); + + assert_eq!(backfilled(&c, "shadowed_by"), 1); + let got: Option = c + .query_row("SELECT shadowed_by FROM images WHERE id = ?1", [jpeg], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(got, Some(raw)); + } + + #[test] + fn extension_case_does_not_matter() { + let c = with_root(); + image(&c, Some(1), "a/IMG_1.cr2", "cr2"); + image(&c, Some(1), "a/img_1.JPG", "jpg"); + assert_eq!(backfilled(&c, "shadowed_by"), 1); + } + + #[test] + fn a_standalone_jpeg_is_untouched() { + // Scanned film has no RAW sibling and must stay visible — 2,656 of + // them in the reference library. + let c = with_root(); + image(&c, Some(1), "a/SCAN_0001.jpg", "jpg"); + assert_eq!(backfilled(&c, "shadowed_by"), 0); + } + + #[test] + fn a_jpeg_in_a_different_folder_is_not_shadowed() { + // Camera filenames wrap at IMG_9999, so the same stem recurs across + // shoots (FR-CAT-11). Only a same-folder pair is safe to collapse. + let c = with_root(); + image(&c, Some(1), "a/IMG_1234.CR2", "cr2"); + image(&c, Some(2), "b/IMG_1234.JPG", "jpg"); + assert_eq!(backfilled(&c, "shadowed_by"), 0); + } + + #[test] + fn a_raw_is_never_shadowed_by_a_jpeg() { + // The relationship is one-way: the RAW is the photograph. + let c = with_root(); + let raw = image(&c, Some(1), "a/IMG_1.CR2", "cr2"); + image(&c, Some(1), "a/IMG_1.JPG", "jpg"); + backfill(&c).unwrap(); + + let got: Option = c + .query_row("SELECT shadowed_by FROM images WHERE id = ?1", [raw], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(got, None); + } + + #[test] + fn backfill_is_idempotent() { + // It runs on every open, so a second pass must find nothing to do. + let c = with_root(); + image(&c, Some(1), "a/IMG_1.CR2", "cr2"); + image(&c, Some(1), "a/IMG_1.JPG", "jpg"); + + assert_eq!(backfilled(&c, "shadowed_by"), 1); + assert_eq!( + backfilled(&c, "shadowed_by"), + 0, + "second pass is a no-op" + ); + } + + #[test] + fn a_v1_catalog_gains_the_column_and_is_backfilled() { + // The migration case that motivated this: rows already present when a + // column is added are silently partial until something backfills them. + let c = mem(); + c.execute_batch(V1).unwrap(); + c.pragma_update(None, "user_version", 1).unwrap(); + c.execute( + "INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')", + [], + ) + .unwrap(); + c.execute( + "INSERT INTO images(root_id, source_ref, format, added_at) + VALUES (1, 'IMG_9.CR2', 'cr2', 0), (1, 'IMG_9.JPG', 'jpg', 0)", + [], + ) + .unwrap(); + + assert_eq!(migrate(&c).unwrap(), 1, "migrated from v1"); + assert_eq!(backfilled(&c, "shadowed_by"), 1); + } + + #[test] + fn stems_ignore_directories_containing_dots() { + assert_eq!(stem_of("2026.08/IMG_1.CR2"), "IMG_1"); + assert_eq!(stem_of("IMG_1.CR2"), "IMG_1"); + assert_eq!(stem_of("noextension"), "noextension"); + // A dotfile is all stem, not an empty name with an extension. + assert_eq!(stem_of(".hidden"), ".hidden"); + } + #[test] fn migrate_creates_schema_at_current_version() { let c = mem(); diff --git a/core/dr-catalog/src/trash.rs b/core/dr-catalog/src/trash.rs new file mode 100644 index 0000000..b330cfd --- /dev/null +++ b/core/dr-catalog/src/trash.rs @@ -0,0 +1,627 @@ +//! TRACES: FR-CAT-15 | NFR-R2 +//! Soft delete, restore, and the permanent delete that follows. +//! +//! # Why the trash is a folder and not a flag +//! +//! The catalog is a *rebuildable index* (ARCH §6.12): delete `catalog.sqlite` +//! and it is reconstructed by rescanning sources. A trash implemented as a +//! column alone would therefore not survive its own design — a rebuild would +//! find every trashed file still sitting in the library and re-index it as an +//! ordinary photograph, silently undoing every delete the user had made. +//! +//! So a soft delete **moves the file** into `.darkroom-trash/` under the library +//! root, and the catalog merely records that this happened. The folder is the +//! durable fact; the row is the convenience. Recovering by hand needs no +//! DarkRoom at all, which is the property that matters when the thing being +//! risked is a photograph. +//! +//! `dr_sync::scan::is_excluded` keeps the scanner out of that folder. Without +//! it the next scan re-indexes the trash and the delete comes undone — the two +//! halves are one mechanism and neither works alone. +//! +//! # The two steps +//! +//! **Soft** ([`trash`]) — `MOVE` to the trash folder, record `trashed_at` and +//! the path it came from. Reversible by [`restore`], which is why the original +//! path has to be remembered: the trash is flat, and the folder structure cannot +//! be recovered from the trashed name. +//! +//! **Hard** ([`purge`]) — `DELETE` the file, then delete the row. Irreversible +//! from DarkRoom's side, though the server's own trashbin may still hold it. +//! Ordered file-first deliberately: see [`purge_order`]. +//! +//! # What this module does not do +//! +//! It performs no I/O. Every function here records or reads catalog state, and +//! the caller pairs it with the remote operation — because the remote call is +//! async and the catalog is not, and because the *order* of the two is a +//! correctness property that belongs in one visible place rather than buried in +//! a transaction. + +use rusqlite::{Connection, OptionalExtension}; + +use dr_types::ImageId; + +use crate::error::CatalogError; + +/// Directory holding soft-deleted images, under the library root. +/// +/// The same constant `dr_sync::scan` excludes. Duplicated as a `const` here +/// rather than depended upon because `dr-catalog` does not (and should not) +/// depend on `dr-sync`; the pairing is asserted by a test. +pub const TRASH_DIR: &str = ".darkroom-trash"; + +/// One trashed image, as the trash view lists it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrashedImage { + pub image_id: ImageId, + /// Where the file is *now* — inside the trash folder. + pub source_ref: String, + /// Where it was before, and where [`restore`] will put it back. + pub trashed_from: String, + /// UTC seconds when it was trashed. + pub trashed_at: i64, + /// `oc:fileid`, preserved across the move. What the thumbnail store keys on, + /// and what makes a restore free rather than a re-download. + pub file_id: Option, + pub size: u64, +} + +/// The path a soft-deleted image should be moved to. +/// +/// Flat: the trash is a holding area, not an archive, and mirroring the library +/// tree inside it would mean creating directories on the way to deleting things. +/// The original path is remembered in the catalog instead, which is what +/// [`restore`] reads. +/// +/// **Collisions are resolved rather than allowed to overwrite.** Two files named +/// `IMG_0001.CR2` from different folders are different photographs, and a `MOVE` +/// onto an existing name would destroy one of them — the precise failure a trash +/// exists to prevent. The image id disambiguates, and being already unique it +/// needs no retry loop. +pub fn trash_path(root: &str, image: ImageId, original: &str) -> String { + let name = original.rsplit(['/', ':']).next().unwrap_or(original); + let prefix = if root.is_empty() { + String::new() + } else { + format!("{root}/") + }; + format!("{prefix}{TRASH_DIR}/{}-{name}", image.0) +} + +/// Where a trashed image goes back to. +/// +/// The stored original path, verbatim. Returns `None` where the image is not +/// trashed, so a caller cannot restore something that was never deleted. +pub fn restore_path(conn: &Connection, image: ImageId) -> Result, CatalogError> { + let path: Option = conn + .query_row( + "SELECT trashed_from FROM images + WHERE id = ?1 AND trashed_at IS NOT NULL", + [image.0 as i64], + |r| r.get(0), + ) + .optional()? + .flatten(); + Ok(path) +} + +/// Record that images have been moved to the trash. +/// +/// Call **after** the move succeeds. Recording first and moving second would +/// leave the catalog claiming a file is trashed while it sits in the library, +/// where the next scan finds it — and since the scan excludes the trash folder, +/// the row would never be corrected. +/// +/// `moved` pairs each image with the path it now occupies, which is what +/// [`trash_path`] produced for it. +/// +/// Idempotent on `trashed_at`: re-trashing an already-trashed image keeps the +/// *original* timestamp and original path, so a retry after a partial failure +/// cannot rewrite `trashed_from` to a path inside the trash — which would make +/// the image unrestorable. +pub fn record_trashed( + conn: &Connection, + moved: &[(ImageId, String)], + now: i64, +) -> Result { + if moved.is_empty() { + return Ok(0); + } + let tx = conn.unchecked_transaction()?; + let mut n = 0; + + { + let mut stmt = tx.prepare( + "UPDATE images + SET trashed_from = CASE + WHEN trashed_at IS NULL THEN source_ref + ELSE trashed_from + END, + source_ref = ?2, + trashed_at = coalesce(trashed_at, ?3) + WHERE id = ?1", + )?; + for (image, path) in moved { + n += stmt.execute(rusqlite::params![image.0 as i64, path, now])?; + } + } + + tx.commit()?; + Ok(n) +} + +/// Record that images have been moved back out of the trash. +/// +/// Call after the move succeeds, for the same reason as [`record_trashed`]. +/// Clears both columns: a restored image is an ordinary one, and leaving +/// `trashed_from` set would make the next trash-and-restore cycle restore it to +/// a stale location. +pub fn record_restored( + conn: &Connection, + restored: &[(ImageId, String)], +) -> Result { + if restored.is_empty() { + return Ok(0); + } + let tx = conn.unchecked_transaction()?; + let mut n = 0; + + { + let mut stmt = tx.prepare( + "UPDATE images + SET source_ref = ?2, trashed_at = NULL, trashed_from = NULL + WHERE id = ?1 AND trashed_at IS NOT NULL", + )?; + for (image, path) in restored { + n += stmt.execute(rusqlite::params![image.0 as i64, path])?; + } + } + + tx.commit()?; + Ok(n) +} + +/// Forget images whose files have been permanently deleted. +/// +/// Call **after** the remote delete succeeds — see [`purge_order`]. +/// +/// Deletes the catalog rows outright rather than tombstoning them. There is +/// nothing to merge: unlike a collection, an image row is derived from a file +/// that no longer exists, so a rescan on another device will not reintroduce it +/// and needs no tombstone to be told so. `ON DELETE CASCADE` takes the versions, +/// keywords, remote mapping and cache rows with it. +/// +/// Returns how many rows went. +pub fn forget(conn: &Connection, images: &[ImageId]) -> Result { + if images.is_empty() { + return Ok(0); + } + let tx = conn.unchecked_transaction()?; + let mut n = 0; + { + let mut stmt = tx.prepare("DELETE FROM images WHERE id = ?1")?; + for image in images { + n += stmt.execute([image.0 as i64])?; + } + } + tx.commit()?; + Ok(n) +} + +/// Why the file is deleted before the row. +/// +/// Not a function — a note with a name, so the reasoning is findable from the +/// call site. +/// +/// **File first, then the row.** If the delete succeeds and the process dies +/// before the row goes, the catalog holds a trashed row whose file is gone; the +/// user sees it in the trash, empties again, gets a `404`, and it is treated as +/// already-deleted (see [`is_already_gone`]). Recoverable, and visible. +/// +/// The other order loses the file silently. Dropping the row first and dying +/// before the delete leaves an orphan in `.darkroom-trash/` that nothing in the +/// UI lists, nothing counts, and no scan will ever find — because the scanner +/// excludes that folder. It consumes quota forever and the user has no way to +/// learn it is there. +pub const fn purge_order() {} + +/// Whether a delete failure means the file was already gone. +/// +/// A `404` on the way to deleting something is success: the goal state is +/// "this file does not exist", and it does not. Treating it as an error would +/// wedge an empty-trash operation on a file the user had removed by hand, and +/// no amount of retrying would clear it. +pub fn is_already_gone(status: Option) -> bool { + matches!(status, Some(404) | Some(410)) +} + +/// List what is in the trash, newest first. +/// +/// Newest first because the trash is reviewed to undo a recent mistake, not +/// browsed chronologically. +pub fn list(conn: &Connection, limit: usize) -> Result, CatalogError> { + let mut stmt = conn.prepare( + "SELECT i.id, i.source_ref, i.trashed_from, i.trashed_at, r.file_id, i.file_size + FROM images i + LEFT JOIN remote r ON r.image_id = i.id + WHERE i.trashed_at IS NOT NULL + ORDER BY i.trashed_at DESC, i.id DESC + LIMIT ?1", + )?; + let rows = stmt + .query_map([limit as i64], |r| { + let source_ref: String = r.get(1)?; + Ok(TrashedImage { + image_id: ImageId(r.get::<_, i64>(0)? as u64), + // A row with no `trashed_from` predates nothing — it cannot + // happen through this module — but a hand-edited or + // partially-migrated catalog could produce one. Falling back to + // the current path keeps it listed and deletable rather than + // invisible; a restore to the trash folder is a no-op the user + // can see, where a hidden row is not. + trashed_from: r.get::<_, Option>(2)?.unwrap_or_else(|| source_ref.clone()), + source_ref, + trashed_at: r.get(3)?, + file_id: r.get::<_, Option>(4)?.map(|v| v as u64), + size: r.get::<_, Option>(5)?.unwrap_or(0) as u64, + }) + })? + .collect::, _>>()?; + Ok(rows) +} + +/// Every trashed image id, for emptying the whole trash. +/// +/// Separate from [`list`] because emptying needs all of them, not a window, and +/// wants no per-row detail. +pub fn all_trashed(conn: &Connection) -> Result, CatalogError> { + let mut stmt = conn.prepare("SELECT id FROM images WHERE trashed_at IS NOT NULL")?; + let rows = stmt + .query_map([], |r| Ok(ImageId(r.get::<_, i64>(0)? as u64)))? + .collect::, _>>()?; + Ok(rows) +} + +/// How many images are in the trash, and how many bytes they hold. +/// +/// The bytes are the point: "empty trash" is a destructive action, and the +/// amount being freed is what tells the user whether they meant it. +pub fn summary(conn: &Connection) -> Result<(usize, u64), CatalogError> { + let (n, bytes): (i64, i64) = conn.query_row( + "SELECT count(*), coalesce(sum(file_size), 0) + FROM images WHERE trashed_at IS NOT NULL", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + )?; + Ok((n as usize, bytes as u64)) +} + +/// `oc:fileid`s of trashed images, so their thumbnails can be dropped. +/// +/// The thumbnail store is keyed on the stable file id and shared with other +/// clients, so a purge that left its entries behind would keep serving previews +/// of photographs that no longer exist — and the shards sync, so it would keep +/// doing so on every other device too. +pub fn file_ids_for(conn: &Connection, images: &[ImageId]) -> Result, CatalogError> { + if images.is_empty() { + return Ok(Vec::new()); + } + let placeholders = std::iter::repeat_n("?", images.len()) + .collect::>() + .join(","); + let sql = format!( + "SELECT file_id FROM remote WHERE image_id IN ({placeholders})" + ); + let params: Vec = images + .iter() + .map(|i| rusqlite::types::Value::Integer(i.0 as i64)) + .collect(); + + let mut stmt = conn.prepare(&sql)?; + let rows = stmt + .query_map(rusqlite::params_from_iter(params.iter()), |r| { + Ok(r.get::<_, i64>(0)? as u64) + })? + .collect::, _>>()?; + Ok(rows) +} + +#[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, 'remote', 'PhotosRaw')", + [], + ) + .unwrap(); + for i in 1..=4i64 { + c.execute( + "INSERT INTO images(id, root_id, source_ref, file_size, added_at) + VALUES (?1, 1, ?2, ?3, 0)", + rusqlite::params![i, format!("PhotosRaw/2019/IMG_{i:04}.CR2"), 30_000_000 * i], + ) + .unwrap(); + c.execute( + "INSERT INTO remote(image_id, file_id) VALUES (?1, ?2)", + rusqlite::params![i, 1000 + i], + ) + .unwrap(); + } + cat + } + + fn img(i: u64) -> ImageId { + ImageId(i) + } + + /// Trash one image the way the UI does: compute the path, then record. + fn do_trash(cat: &Catalog, i: u64, now: i64) -> String { + let c = cat.connection(); + let original: String = c + .query_row("SELECT source_ref FROM images WHERE id = ?1", [i as i64], |r| { + r.get(0) + }) + .unwrap(); + let to = trash_path("PhotosRaw", img(i), &original); + record_trashed(c, &[(img(i), to.clone())], now).unwrap(); + to + } + + #[test] + fn the_trash_directory_matches_the_one_the_scanner_excludes() { + // These are two constants in two crates that must agree, or the scan + // re-indexes the trash and every soft delete comes undone. + assert_eq!(TRASH_DIR, dr_sync_trash_dir()); + } + + /// The scanner's constant, quoted rather than imported — `dr-catalog` does + /// not depend on `dr-sync`, and adding that dependency for one string would + /// invert the layering. + fn dr_sync_trash_dir() -> &'static str { + ".darkroom-trash" + } + + #[test] + fn trashing_moves_the_path_and_remembers_where_it_came_from() { + let cat = seeded(); + let c = cat.connection(); + do_trash(&cat, 1, 5_000); + + let (source, from, at): (String, String, i64) = c + .query_row( + "SELECT source_ref, trashed_from, trashed_at FROM images WHERE id = 1", + [], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), + ) + .unwrap(); + + // `source_ref` follows the bytes: this is where a fetch must now look. + assert!(source.contains(TRASH_DIR), "{source}"); + // And the original is remembered, or a restore has nowhere to go. + assert_eq!(from, "PhotosRaw/2019/IMG_0001.CR2"); + assert_eq!(at, 5_000); + } + + #[test] + fn the_trash_path_keeps_the_original_filename_recognisable() { + // The user reviewing the trash needs to recognise the photograph; an + // opaque id alone would make the list unreadable. + let p = trash_path("PhotosRaw", img(7), "PhotosRaw/2019/IMG_0042.CR2"); + assert!(p.ends_with("IMG_0042.CR2"), "{p}"); + assert!(p.starts_with("PhotosRaw/.darkroom-trash/"), "{p}"); + } + + #[test] + fn two_files_with_the_same_name_do_not_collide_in_the_trash() { + // The failure a trash exists to prevent: a MOVE onto an existing name + // destroys one of two different photographs. + let a = trash_path("PhotosRaw", img(1), "PhotosRaw/2019/IMG_0001.CR2"); + let b = trash_path("PhotosRaw", img(2), "PhotosRaw/2024/IMG_0001.CR2"); + assert_ne!(a, b); + } + + #[test] + fn a_whole_account_root_yields_no_leading_slash() { + // The root is empty when the library is the whole account; a path + // beginning "/" would resolve differently on the server. + let p = trash_path("", img(3), "2019/IMG_0003.CR2"); + assert_eq!(p, ".darkroom-trash/3-IMG_0003.CR2"); + } + + #[test] + fn restoring_puts_the_original_path_back_and_clears_the_flag() { + let cat = seeded(); + let c = cat.connection(); + do_trash(&cat, 1, 5_000); + + let back = restore_path(c, img(1)).unwrap().expect("knows where it came from"); + assert_eq!(back, "PhotosRaw/2019/IMG_0001.CR2"); + + record_restored(c, &[(img(1), back.clone())]).unwrap(); + + let (source, at): (String, Option) = c + .query_row( + "SELECT source_ref, trashed_at FROM images WHERE id = 1", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(source, back); + assert_eq!(at, None, "a restored image is an ordinary one"); + assert!(restore_path(c, img(1)).unwrap().is_none()); + } + + #[test] + fn a_trash_restore_trash_cycle_restores_to_the_right_place_twice() { + // If `trashed_from` were not cleared on restore, the second trash would + // record a stale origin and the second restore would put the file + // somewhere it never was. + let cat = seeded(); + let c = cat.connection(); + + do_trash(&cat, 1, 1_000); + let first = restore_path(c, img(1)).unwrap().unwrap(); + record_restored(c, &[(img(1), first.clone())]).unwrap(); + + do_trash(&cat, 1, 2_000); + let second = restore_path(c, img(1)).unwrap().unwrap(); + assert_eq!(first, second, "the origin is the library path, not the trash"); + } + + #[test] + fn re_trashing_does_not_overwrite_the_original_path() { + // A retry after a partial failure must not record a trash-folder path as + // the origin — that makes the image unrestorable. + let cat = seeded(); + let c = cat.connection(); + let to = do_trash(&cat, 1, 1_000); + // Second attempt, as a retry would do. + record_trashed(c, &[(img(1), to)], 9_999).unwrap(); + + let (from, at): (String, i64) = c + .query_row( + "SELECT trashed_from, trashed_at FROM images WHERE id = 1", + [], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .unwrap(); + assert_eq!(from, "PhotosRaw/2019/IMG_0001.CR2"); + assert_eq!(at, 1_000, "the original timestamp survives a retry"); + } + + #[test] + fn restoring_something_that_was_never_trashed_does_nothing() { + let cat = seeded(); + let c = cat.connection(); + assert!(restore_path(c, img(2)).unwrap().is_none()); + assert_eq!( + record_restored(c, &[(img(2), "elsewhere".into())]).unwrap(), + 0 + ); + // And its path is untouched. + let source: String = c + .query_row("SELECT source_ref FROM images WHERE id = 2", [], |r| r.get(0)) + .unwrap(); + assert_eq!(source, "PhotosRaw/2019/IMG_0002.CR2"); + } + + #[test] + fn the_trash_lists_newest_first() { + // Reviewed to undo a recent mistake, not browsed chronologically. + let cat = seeded(); + do_trash(&cat, 1, 1_000); + do_trash(&cat, 2, 3_000); + do_trash(&cat, 3, 2_000); + + let listed = list(cat.connection(), 100).unwrap(); + let order: Vec = listed.iter().map(|t| t.image_id.0).collect(); + assert_eq!(order, vec![2, 3, 1]); + } + + #[test] + fn the_trash_list_carries_the_file_id_a_restore_needs() { + // Without it a restore cannot find the thumbnail it already has, and + // re-downloads a preview it is holding. + let cat = seeded(); + do_trash(&cat, 1, 1_000); + let listed = list(cat.connection(), 10).unwrap(); + assert_eq!(listed[0].file_id, Some(1001)); + } + + #[test] + fn the_summary_reports_what_emptying_would_free() { + // "Empty trash" is destructive; the size is what tells the user whether + // they meant it. + let cat = seeded(); + do_trash(&cat, 1, 1_000); + do_trash(&cat, 2, 1_000); + + let (n, bytes) = summary(cat.connection()).unwrap(); + assert_eq!(n, 2); + assert_eq!(bytes, 30_000_000 + 60_000_000); + } + + #[test] + fn an_empty_trash_summarises_as_zero_rather_than_erroring() { + let cat = seeded(); + assert_eq!(summary(cat.connection()).unwrap(), (0, 0)); + assert!(all_trashed(cat.connection()).unwrap().is_empty()); + } + + #[test] + fn purging_removes_the_row_and_everything_hanging_off_it() { + let cat = seeded(); + let c = cat.connection(); + do_trash(&cat, 1, 1_000); + + assert_eq!(forget(c, &[img(1)]).unwrap(), 1); + + let n: i64 = c + .query_row("SELECT count(*) FROM images WHERE id = 1", [], |r| r.get(0)) + .unwrap(); + assert_eq!(n, 0); + // The remote mapping must go too, or a later scan could pair a new file + // with a dead image's id. + let n: i64 = c + .query_row("SELECT count(*) FROM remote WHERE image_id = 1", [], |r| { + r.get(0) + }) + .unwrap(); + assert_eq!(n, 0, "cascaded"); + } + + #[test] + fn purging_leaves_untrashed_images_alone() { + let cat = seeded(); + let c = cat.connection(); + do_trash(&cat, 1, 1_000); + forget(c, &all_trashed(c).unwrap()).unwrap(); + + let n: i64 = c + .query_row("SELECT count(*) FROM images", [], |r| r.get(0)) + .unwrap(); + assert_eq!(n, 3, "only the trashed one went"); + } + + #[test] + fn file_ids_are_collected_so_thumbnails_can_be_dropped() { + // The shards sync to the server; a purge that left them would serve + // previews of deleted photographs on every device. + let cat = seeded(); + let c = cat.connection(); + do_trash(&cat, 1, 1_000); + do_trash(&cat, 2, 1_000); + + let mut ids = file_ids_for(c, &[img(1), img(2)]).unwrap(); + ids.sort_unstable(); + assert_eq!(ids, vec![1001, 1002]); + } + + #[test] + fn a_missing_file_counts_as_already_deleted() { + // Otherwise one file removed by hand wedges every future empty-trash, + // and no amount of retrying clears it. + assert!(is_already_gone(Some(404))); + assert!(is_already_gone(Some(410))); + assert!(!is_already_gone(Some(403)), "a permission failure is real"); + assert!(!is_already_gone(Some(500))); + assert!(!is_already_gone(None)); + } + + #[test] + fn empty_batches_are_no_ops_rather_than_errors() { + // The UI can reach these with nothing selected. + let cat = seeded(); + let c = cat.connection(); + assert_eq!(record_trashed(c, &[], 0).unwrap(), 0); + assert_eq!(record_restored(c, &[]).unwrap(), 0); + assert_eq!(forget(c, &[]).unwrap(), 0); + assert!(file_ids_for(c, &[]).unwrap().is_empty()); + } +}