From 89859d39d15f1d4c338c4edac04bccda5133a677 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Thu, 24 Sep 2026 20:18:13 -0400 Subject: [PATCH] Let the catalog set colour labels, toggle them, and count them Colour labels reached `versions.label` only from an XMP sidecar: nothing in the catalog could set one, clear one, or read it back alongside the stars, so there was nothing for an interface to call. `set_label` and `set_label_many` write it the way ratings are written, the bulk form in one transaction so a key over a selection is one commit. `toggled_label` holds Lightroom's rule for a label key: it clears only when every image already carries that label, and otherwise sets it on all of them, so a half-red selection comes out red rather than inverted. `Judgement` carries the label, so the grid's one window query brings it with the stars, and `label_histogram` counts each label in one grouped statement for the filter chips. A label does not make a frame "judged": it is a pile of the photographer's own, not a cull decision. The doc comment for `default_version_id` had been stranded above `label_code` when that was inserted; it is back on its function. --- core/dr-catalog/src/rating.rs | 182 +++++++++++++++++++++++++++++++--- 1 file changed, 168 insertions(+), 14 deletions(-) diff --git a/core/dr-catalog/src/rating.rs b/core/dr-catalog/src/rating.rs index 9aadd6d..04b4e30 100644 --- a/core/dr-catalog/src/rating.rs +++ b/core/dr-catalog/src/rating.rs @@ -49,6 +49,12 @@ pub struct Judgement { /// 0..=5. Zero means *unrated*, which is a state in its own right. pub rating: u8, pub flag: FlagState, + /// TRACES: FR-CAT-5 + /// The colour label, or `None`. Not part of [`Judgement::is_judged`]: + /// a label sorts photographs into piles of the photographer's own + /// meaning — "to print", "send to Anna" — and says nothing about whether + /// a frame has been culled, which is the question "unjudged" asks. + pub label: Option, } impl Judgement { @@ -267,14 +273,6 @@ pub fn align_default_version_uuids(conn: &Connection) -> Result) -> Option { }) } +/// 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( @@ -387,6 +393,88 @@ pub fn set_flag_many( apply_many(conn, images, |conn, id| set_flag(conn, id, flag)) } +/// TRACES: FR-CAT-5 +/// Set or clear the colour label for one image. +pub fn set_label( + conn: &Connection, + image: ImageId, + label: Option, +) -> Result<(), CatalogError> { + let version = default_version_id(conn, image)?; + conn.execute( + "UPDATE versions SET label = ?2 WHERE id = ?1", + rusqlite::params![version, label.map(label_code)], + )?; + Ok(()) +} + +/// TRACES: FR-CAT-5 +/// Set or clear a label on many images in one transaction — one keystroke +/// over a selection is one commit, as for [`set_rating_many`]. +pub fn set_label_many( + conn: &Connection, + images: &[ImageId], + label: Option, +) -> Result { + apply_many(conn, images, |conn, id| set_label(conn, id, label)) +} + +/// TRACES: FR-CAT-5 +/// What a label key does to a set of images: Lightroom's toggle. +/// +/// Pressing the key for the label every one of them already carries takes it +/// off; otherwise every one of them gets it. Decided over the whole set +/// rather than per image, so a selection that was half red comes out all red +/// rather than inverted — the photographer pressed "red", and a key that +/// turned half of them red and the other half plain would be two answers to +/// one question. +pub fn toggled_label( + current: impl IntoIterator>, + pressed: ColourLabel, +) -> Option { + let mut any = false; + for label in current { + any = true; + if label != Some(pressed) { + return Some(pressed); + } + } + if any { + None + } else { + Some(pressed) + } +} + +/// TRACES: FR-CAT-5 | FR-CAT-6 +/// How the library divides by colour label, for the filter chips' counts. +/// +/// Index 0 is unlabelled and index `n` the label whose code is `n`. One +/// grouped statement — the same shape as [`rating_histogram`], and for the +/// same reason it LEFT JOINs: an image without a version row is unlabelled, +/// not missing. +pub fn label_histogram(conn: &Connection) -> Result<[usize; 6], CatalogError> { + let mut out = [0usize; 6]; + let mut stmt = conn.prepare( + "SELECT coalesce(v.label, 0) AS l, count(*) + FROM images i + LEFT JOIN versions v ON v.image_id = i.id AND v.is_default = 1 + GROUP BY l", + )?; + let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?; + for (code, count) in rows.flatten() { + // A code this build does not know counts as unlabelled, which is how + // `label_from_code` reads it everywhere else. + let slot = if label_from_code(Some(code)).is_some() { + code as usize + } else { + 0 + }; + out[slot] += count as usize; + } + Ok(out) +} + /// 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. @@ -411,21 +499,22 @@ fn apply_many( /// 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 + let row: Option<(i64, i64, Option)> = conn .query_row( - "SELECT rating, flag FROM versions + "SELECT rating, flag, label 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)?)), + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), ) .optional()?; Ok(match row { - Some((rating, flag)) => Judgement { + Some((rating, flag, label)) => Judgement { rating: rating.clamp(0, MAX_RATING as i64) as u8, flag: flag_from_code(flag), + label: label_from_code(label), }, None => Judgement::default(), }) @@ -452,7 +541,7 @@ pub fn judgements( .collect::>() .join(","); let sql = format!( - "SELECT image_id, rating, flag FROM versions + "SELECT image_id, rating, flag, label FROM versions WHERE image_id IN ({placeholders}) AND is_default = 1" ); @@ -467,15 +556,17 @@ pub fn judgements( r.get::<_, i64>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, + r.get::<_, Option>(3)?, )) })?; - for (image, rating, flag) in rows.flatten() { + for (image, rating, flag, label) 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), + label: label_from_code(label), }, ); } @@ -686,6 +777,69 @@ mod tests { assert_eq!(distinct, 200); } + #[test] + fn a_label_round_trips_and_clears() { + // TRACES: FR-CAT-5 + let cat = with_images(1); + let id = ids(&cat)[0]; + set_label(cat.connection(), id, Some(ColourLabel::Green)).unwrap(); + assert_eq!( + judgement(cat.connection(), id).unwrap().label, + Some(ColourLabel::Green) + ); + set_label(cat.connection(), id, None).unwrap(); + assert_eq!(judgement(cat.connection(), id).unwrap().label, None); + } + + #[test] + fn a_label_is_not_a_judgement() { + // "Unjudged" is the cull's resume point; a label is a pile of the + // photographer's own, and labelling a frame must not hide it there. + let cat = with_images(1); + let id = ids(&cat)[0]; + set_label(cat.connection(), id, Some(ColourLabel::Red)).unwrap(); + assert!(!judgement(cat.connection(), id).unwrap().is_judged()); + } + + #[test] + fn labelling_a_selection_is_one_commit_and_reaches_every_image() { + // TRACES: FR-CAT-5 + let cat = with_images(4); + let all = ids(&cat); + assert_eq!( + set_label_many(cat.connection(), &all, Some(ColourLabel::Blue)).unwrap(), + 4 + ); + let found = judgements(cat.connection(), &all).unwrap(); + assert!(all + .iter() + .all(|id| found[id].label == Some(ColourLabel::Blue))); + assert_eq!(label_histogram(cat.connection()).unwrap(), [0, 0, 0, 0, 4, 0]); + } + + #[test] + fn a_label_key_toggles_only_when_every_image_already_has_it() { + // TRACES: FR-CAT-5 + use ColourLabel::*; + assert_eq!(toggled_label([Some(Red), Some(Red)], Red), None); + assert_eq!(toggled_label([Some(Red), None], Red), Some(Red)); + assert_eq!(toggled_label([Some(Blue)], Red), Some(Red)); + assert_eq!(toggled_label([], Red), Some(Red)); + } + + #[test] + fn the_label_histogram_sums_to_the_library() { + // TRACES: FR-CAT-6 + // Images without a version row count as unlabelled rather than + // vanishing, as the rating histogram's do. + let cat = with_images(3); + let first = ids(&cat)[0]; + set_label(cat.connection(), first, Some(ColourLabel::Purple)).unwrap(); + let h = label_histogram(cat.connection()).unwrap(); + assert_eq!(h, [2, 0, 0, 0, 0, 1]); + assert_eq!(h.iter().sum::(), 3); + } + #[test] fn a_rating_round_trips() { let cat = with_images(1);