//! 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 { // 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 n = ensure_default_versions_within(&tx)?; tx.commit()?; Ok(n) } /// [`ensure_default_versions`] without opening a transaction. /// /// Separate because SQLite has no nested `BEGIN`: [`crate::merge`] needs the /// invariant restored *inside* the merge transaction — an incoming keyword /// lands on a default version, so an image without one would silently drop it — /// and calling the public form there fails at runtime with "cannot start a /// transaction within a transaction". The same split, for the same reason, as /// `collections::add_within`. pub fn ensure_default_versions_within(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); } { let mut insert = conn.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])?; } } 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); } }