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.
This commit is contained in:
+168
-14
@@ -49,6 +49,12 @@ pub struct Judgement {
|
|||||||
/// 0..=5. Zero means *unrated*, which is a state in its own right.
|
/// 0..=5. Zero means *unrated*, which is a state in its own right.
|
||||||
pub rating: u8,
|
pub rating: u8,
|
||||||
pub flag: FlagState,
|
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<ColourLabel>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Judgement {
|
impl Judgement {
|
||||||
@@ -267,14 +273,6 @@ pub fn align_default_version_uuids(conn: &Connection) -> Result<usize, CatalogEr
|
|||||||
Ok(moved)
|
Ok(moved)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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.
|
|
||||||
/// TRACES: FR-CAT-13
|
/// TRACES: FR-CAT-13
|
||||||
/// How `versions.label` encodes a colour label, and back.
|
/// How `versions.label` encodes a colour label, and back.
|
||||||
///
|
///
|
||||||
@@ -304,6 +302,14 @@ pub fn label_from_code(code: Option<i64>) -> Option<ColourLabel> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<i64, CatalogError> {
|
pub fn default_version_id(conn: &Connection, image: ImageId) -> Result<i64, CatalogError> {
|
||||||
let existing: Option<i64> = conn
|
let existing: Option<i64> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
@@ -387,6 +393,88 @@ pub fn set_flag_many(
|
|||||||
apply_many(conn, images, |conn, id| set_flag(conn, id, flag))
|
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<ColourLabel>,
|
||||||
|
) -> 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<ColourLabel>,
|
||||||
|
) -> Result<usize, CatalogError> {
|
||||||
|
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<Item = Option<ColourLabel>>,
|
||||||
|
pressed: ColourLabel,
|
||||||
|
) -> Option<ColourLabel> {
|
||||||
|
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
|
/// Shared bulk wrapper, so the two axes cannot drift in their commit
|
||||||
/// behaviour — a partially-committed rating and a fully-committed flag from
|
/// behaviour — a partially-committed rating and a fully-committed flag from
|
||||||
/// the same keystroke would be hard to explain and harder to notice.
|
/// 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
|
/// An image with no version reads as unrated and unflagged rather than as an
|
||||||
/// error: that is exactly what it is.
|
/// error: that is exactly what it is.
|
||||||
pub fn judgement(conn: &Connection, image: ImageId) -> Result<Judgement, CatalogError> {
|
pub fn judgement(conn: &Connection, image: ImageId) -> Result<Judgement, CatalogError> {
|
||||||
let row: Option<(i64, i64)> = conn
|
let row: Option<(i64, i64, Option<i64>)> = conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT rating, flag FROM versions
|
"SELECT rating, flag, label FROM versions
|
||||||
WHERE image_id = ?1
|
WHERE image_id = ?1
|
||||||
ORDER BY is_default DESC, id ASC
|
ORDER BY is_default DESC, id ASC
|
||||||
LIMIT 1",
|
LIMIT 1",
|
||||||
[image.0 as i64],
|
[image.0 as i64],
|
||||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
|
||||||
)
|
)
|
||||||
.optional()?;
|
.optional()?;
|
||||||
|
|
||||||
Ok(match row {
|
Ok(match row {
|
||||||
Some((rating, flag)) => Judgement {
|
Some((rating, flag, label)) => Judgement {
|
||||||
rating: rating.clamp(0, MAX_RATING as i64) as u8,
|
rating: rating.clamp(0, MAX_RATING as i64) as u8,
|
||||||
flag: flag_from_code(flag),
|
flag: flag_from_code(flag),
|
||||||
|
label: label_from_code(label),
|
||||||
},
|
},
|
||||||
None => Judgement::default(),
|
None => Judgement::default(),
|
||||||
})
|
})
|
||||||
@@ -452,7 +541,7 @@ pub fn judgements(
|
|||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(",");
|
.join(",");
|
||||||
let sql = format!(
|
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"
|
WHERE image_id IN ({placeholders}) AND is_default = 1"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -467,15 +556,17 @@ pub fn judgements(
|
|||||||
r.get::<_, i64>(0)?,
|
r.get::<_, i64>(0)?,
|
||||||
r.get::<_, i64>(1)?,
|
r.get::<_, i64>(1)?,
|
||||||
r.get::<_, i64>(2)?,
|
r.get::<_, i64>(2)?,
|
||||||
|
r.get::<_, Option<i64>>(3)?,
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
for (image, rating, flag) in rows.flatten() {
|
for (image, rating, flag, label) in rows.flatten() {
|
||||||
out.insert(
|
out.insert(
|
||||||
ImageId(image as u64),
|
ImageId(image as u64),
|
||||||
Judgement {
|
Judgement {
|
||||||
rating: rating.clamp(0, MAX_RATING as i64) as u8,
|
rating: rating.clamp(0, MAX_RATING as i64) as u8,
|
||||||
flag: flag_from_code(flag),
|
flag: flag_from_code(flag),
|
||||||
|
label: label_from_code(label),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -686,6 +777,69 @@ mod tests {
|
|||||||
assert_eq!(distinct, 200);
|
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::<usize>(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_rating_round_trips() {
|
fn a_rating_round_trips() {
|
||||||
let cat = with_images(1);
|
let cat = with_images(1);
|
||||||
|
|||||||
Reference in New Issue
Block a user