Files
DarkRoom/ui/dr-ui/src/library_ui/ratings_keywords.rs
T
dtourolle 220e9af222 Add the duplicate originals review, from the sidebar and from Settings
"Duplicate originals" appears under the trash in the collections
sidebar while the catalog holds any, and Settings says how many there
are beside the other whole-library passes. Both open one page: every
group with its picture and paths, the copy that stays (tap another path
to change it), a per-group Include box, what the survivor will gain and
any flag, label or face conflict, and why a group was skipped.

The summary is the dry run -- "N groups, M files to trash, K skipped" --
and nothing moves until "Check" has read the copies and "Move M copies
to trash" is pressed. Both run on workers with progress on the page, in
the activity register and, for the move, on the library status line;
Stop ends a job between groups. When it ends the grid, the sidebar and
the trash are refreshed and the survivors' judgements are written to
their sidecars and XMP the way a rating keystroke writes them.

The page is paginated at 30 groups, so a redraw decodes 30 thumbnails
and previews 30 merges whatever the size of the library. Back and
Escape leave it like its own Back button.
2026-09-26 07:19:13 -04:00

1450 lines
54 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Ratings, flags, bursts and keywords: applying a judgement to a selection,
//! keeping the grid and the filter chips honest about what landed, and the
//! sidecar and XMP writes a judgement or a paste of develop settings queues
//! behind it.
//!
//! `dr_catalog::rating` and `dr_catalog::keywords` own the data rules; this
//! owns which photographs a keystroke or a sheet acts on, and reporting what
//! actually changed rather than what was asked for. See
//! `docs/dev/code-health.md` CH-1.
use std::cell::RefCell;
use std::rc::Rc;
use dr_catalog::Catalog;
use slint::{ComponentHandle, Model as _};
use crate::library;
use crate::{AppWindow, KeywordRow, Library};
use super::controller::{stop, LibraryController};
use super::window::load_window;
/// Push each visible image's stars and flag into the grid model.
///
/// One query for the window, mirroring `collections_ui::sync_badges` — 120
/// cells is 120 round trips otherwise, on every scroll and after every
/// keystroke.
pub fn sync_ratings(window: &AppWindow, catalog: &Catalog, ids: &[dr_types::ImageId]) {
if ids.is_empty() {
return;
}
let found = match dr_catalog::rating::judgements(catalog.connection(), ids) {
Ok(j) => j,
Err(e) => {
// The grid is still usable without stars, so this is logged rather
// than surfaced — a failure here must not blank the library.
log::debug!("reading ratings: {e}");
return;
}
};
let model = window.global::<Library>().get_library_cells();
for (row, id) in ids.iter().enumerate() {
// Absent means unrated, which is a real state rather than missing data.
let j = found.get(id).copied().unwrap_or_default();
let (rating, flag) = (j.rating as i32, flag_code(j.flag));
let label = j
.label
.map_or(0, |l| dr_catalog::rating::label_code(l) as i32);
if let Some(mut cell) = model.row_data(row) {
if cell.rating != rating || cell.flag != flag || cell.label != label {
cell.rating = rating;
cell.flag = flag;
cell.label = label;
model.set_row_data(row, cell);
}
}
}
}
/// Refresh the filter chips' per-star counts.
///
/// Whole-library figures, deliberately: they say what narrowing to a filter
/// would show, so computing them over the current window would make each chip
/// describe the view it is meant to change.
pub(super) fn refresh_rating_counts(window: &AppWindow, catalog: &Catalog) {
let counts = dr_catalog::rating::rating_histogram(catalog.connection()).unwrap_or_default();
let as_i32: Vec<i32> = counts.iter().map(|n| *n as i32).collect();
window
.global::<Library>()
.set_library_rating_counts(slint::ModelRc::new(slint::VecModel::from(as_i32)));
// TRACES: FR-CAT-9
// How many originals are actually here, for the "On this device" chip.
// Shown for the same reason the star counts are: a filter that silently
// empties the grid reads as broken, and this one will legitimately be zero
// on a library nothing has been downloaded from yet.
window
.global::<Library>()
.set_library_local_count(library::local_original_count(catalog).unwrap_or(0) as i32);
}
/// TRACES: FR-CAT-6
/// Refresh the label chips' counts. Separate from the star counts because
/// the two change on different events: a star key cannot move a label count,
/// so a cull does not pay for this read on every keystroke.
pub(super) fn refresh_label_counts(window: &AppWindow, catalog: &Catalog) {
let counts = dr_catalog::rating::label_histogram(catalog.connection()).unwrap_or_default();
let as_i32: Vec<i32> = counts.iter().map(|n| *n as i32).collect();
window
.global::<Library>()
.set_library_label_counts(slint::ModelRc::new(slint::VecModel::from(as_i32)));
}
/// TRACES: FR-CAT-5
/// What a label gesture asks for: a key toggles, the picker names the answer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LabelGesture {
Toggle(dr_types::ColourLabel),
Set(Option<dr_types::ColourLabel>),
}
impl LabelGesture {
/// From Slint's code and whether it came from a key. A code with no
/// colour is "none" for the picker, and nothing at all for a key.
fn from_code(code: i32, toggle: bool) -> Option<Self> {
let label = dr_catalog::rating::label_from_code(Some(code as i64));
match (toggle, label) {
(true, Some(l)) => Some(Self::Toggle(l)),
(true, None) => None,
(false, l) => Some(Self::Set(l)),
}
}
}
/// TRACES: FR-CAT-5 | FR-CAT-13
/// Label a set of images: catalog first, in one transaction, then the grid,
/// the chips and both sidecars — the order and the reasons of
/// [`apply_judgement`].
fn apply_label(
window: &AppWindow,
ctl: &Rc<LibraryController>,
images: &[dr_types::ImageId],
gesture: LabelGesture,
) {
if images.is_empty() {
window
.global::<Library>()
.set_library_status("Select an image first".into());
return;
}
let writes = {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let conn = catalog.connection();
let label = match gesture {
LabelGesture::Set(l) => l,
// One read of what they carry now, for the whole set.
LabelGesture::Toggle(pressed) => {
let now = dr_catalog::rating::judgements(conn, images).unwrap_or_default();
dr_catalog::rating::toggled_label(
images.iter().map(|id| now.get(id).and_then(|j| j.label)),
pressed,
)
}
};
if let Err(e) = dr_catalog::rating::set_label_many(conn, images, label) {
window
.global::<Library>()
.set_library_error(format!("recording label: {e}").into());
return;
}
window
.global::<Library>()
.set_library_status(label_summary(images.len(), label).into());
let visible = ctl.visible_ids();
sync_ratings(window, catalog, &visible);
refresh_label_counts(window, catalog);
collect_sidecar_writes(catalog, images)
};
// A grid narrowed to red no longer holds a frame just made green.
if !ctl.filter.borrow().is_unfiltered() {
load_window(window, ctl);
}
start_sidecar_writes(window, ctl, writes);
start_xmp_writes(window, ctl, images);
}
/// What the status line says about a label that just landed — in words, as
/// every other judgement is reported.
fn label_summary(n: usize, label: Option<dr_types::ColourLabel>) -> String {
let what = match label {
Some(l) => format!("{} label", label_name(l).to_lowercase()),
None => "no label".to_string(),
};
if n == 1 {
what[..1].to_uppercase() + &what[1..]
} else {
format!("{n} images · {what}")
}
}
/// A label's name, as the chips and the picker spell it.
fn label_name(l: dr_types::ColourLabel) -> &'static str {
match l {
dr_types::ColourLabel::Red => "Red",
dr_types::ColourLabel::Yellow => "Yellow",
dr_types::ColourLabel::Green => "Green",
dr_types::ColourLabel::Blue => "Blue",
dr_types::ColourLabel::Purple => "Purple",
}
}
// --- keywords (FR-CAT-5, FR-CAT-6) ---------------------------------------
//
// `dr_catalog::keywords` owns the data rules — the vocabulary, the many-to-many
// join, what a rename does to the assignments. This part owns the *interaction*:
// which photographs the sheet is acting on, and keeping what it draws honest
// about what actually landed.
/// Redraw the keywording sheet against whatever is selected now.
///
/// Called when the sheet opens and after every assignment, rather than on every
/// selection change: the selection moves on each arrow key and the sheet is shut
/// for almost all of them, so computing coverage over a forty-image selection
/// on each one would be work nobody is looking at.
///
/// Re-read from the catalog rather than patched in place after a write. A word
/// applied to a selection that partly already had it moves from "3 of 12" to
/// "12 of 12", and a model updated by hand would have to reproduce the rule
/// that decides that — which is exactly the rule the catalog has just applied.
fn refresh_keywords(window: &AppWindow, ctl: &Rc<LibraryController>, images: &[dr_types::ImageId]) {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let rows = match dr_catalog::keywords::for_images(catalog.connection(), images) {
Ok(rows) => rows,
Err(e) => {
// The grid is entirely usable without the sheet, so this is logged
// rather than surfaced: a keyword read that failed must not put an
// error banner over a library the user is browsing.
log::debug!("reading keywords: {e}");
return;
}
};
let model: Vec<KeywordRow> = rows
.into_iter()
.map(|row| KeywordRow {
id: row.keyword.id.0 as i32,
name: row.keyword.name.into(),
coverage: match row.coverage {
dr_catalog::Coverage::None => 0,
dr_catalog::Coverage::Some => 1,
dr_catalog::Coverage::All => 2,
},
selected_count: row.selected_count as i32,
image_count: row.keyword.image_count as i32,
})
.collect();
window
.global::<Library>()
.set_library_keywords(slint::ModelRc::new(slint::VecModel::from(model)));
}
/// Put a keyword on the selection, or take it off.
///
/// # Why this does not write a sidecar
///
/// Every other judgement in this file — a star, a flag — is written to the
/// catalog and then queued to the image's sidecar, because the sidecar is what
/// makes it survive a catalog rebuild (ARCH §6.12). A keyword has no place in
/// the sidecar format yet: `dr_pipeline::sidecar::Version` carries `rating` and
/// `flag` and nothing else that is not an edit-graph parameter.
///
/// So a keyword is, for now, catalog state that reaches the user's other
/// devices through the *catalog* merge ([`dr_catalog::merge`]) rather than
/// through the sidecar. That is a real limitation and not a silent one: a
/// deleted catalog loses keywords where it would keep ratings, until the
/// sidecar gains a `dc:subject` field (FR-CAT-13) and this grows the same
/// queued write the stars have.
fn apply_keyword(window: &AppWindow, ctl: &Rc<LibraryController>, word: &str, assigning: bool) {
let Some(coll) = ctl.coll_ctl.borrow().as_ref().and_then(|c| c.upgrade()) else {
return;
};
let images = coll.selected();
// The word as it will be *stored*, resolved before anything is written.
// The status line below quotes it back, and quoting what was typed would
// report a leading space the catalog is about to drop — leaving the user to
// wonder whether it mattered.
//
// This is also where a blank keyword is caught, which is why it happens
// before the selection check: "you typed nothing" is a better answer than
// "select an image first" to someone who pressed return on an empty field.
let word = match dr_catalog::keywords::normalise(word) {
Ok(word) => word,
Err(e) => {
// `BadName` carries text written to be read by the user rather than
// by a developer, so it is shown as it is.
window
.global::<Library>()
.set_library_error(format!("{e}").into());
return;
}
};
// Assigning with nothing selected still means something — it puts the word
// in the vocabulary, ready for the photographs it was typed for — so only
// the removal half needs a selection to act on.
if images.is_empty() && !assigning {
window
.global::<Library>()
.set_library_status("Select an image first".into());
return;
}
let outcome = {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let conn = catalog.connection();
if assigning {
dr_catalog::keywords::assign(conn, &images, &word)
} else {
dr_catalog::keywords::unassign(conn, &images, &word)
}
};
let n = match outcome {
Ok(n) => n,
Err(e) => {
window
.global::<Library>()
.set_library_error(format!("{e}").into());
return;
}
};
window
.global::<Library>()
.set_library_error(slint::SharedString::new());
window
.global::<Library>()
.set_library_status(keyword_summary(&word, n, images.len(), assigning).into());
refresh_keywords(window, ctl, &images);
start_xmp_writes(window, ctl, &images);
// A filtered grid may no longer hold what was just keyworded — taking
// "puffin" off an image while showing only puffins means it belongs
// elsewhere now. The same reasoning as a rating that falls below the star
// filter.
if !ctl.filter.borrow().is_unfiltered() {
load_window(window, ctl);
}
}
/// What the status line says about a keyword that just landed.
///
/// The honest count, not the requested one: "added to 3 of 12" is what
/// happened when nine of them already carried the word, and a message that
/// claimed twelve would be teaching the user that the counts are decorative.
fn keyword_summary(word: &str, changed: usize, selected: usize, assigning: bool) -> String {
if selected == 0 {
return format!("Added “{word}” to the keyword list");
}
let verb = if assigning { "Added" } else { "Removed" };
let preposition = if assigning { "to" } else { "from" };
if changed == 0 {
return if assigning {
format!("Every selected photograph already had “{word}”")
} else {
format!("None of the selected photographs had “{word}”")
};
}
if changed == selected {
let what = if selected == 1 {
"1 photograph".to_string()
} else {
format!("{selected} photographs")
};
return format!("{verb} “{word}” {preposition} {what}");
}
format!("{verb} “{word}” {preposition} {changed} of {selected}")
}
/// Apply a judgement to a set of images: catalog first, then sidecars.
///
/// # Order matters
///
/// The catalog is written **synchronously and first**, so the star appears
/// immediately and survives a restart even if the network is down. The sidecar
/// write is queued behind it on a worker thread — it is what makes the
/// judgement survive a *catalog rebuild* (ARCH §6.12), which is a slower and
/// rarer concern than the user seeing their keystroke take effect.
///
/// Doing it the other way round would mean a cull that stalls on every
/// keypress waiting for a round trip, on a workflow whose entire premise is
/// speed (FR-CULL-1).
fn apply_judgement(
window: &AppWindow,
ctl: &Rc<LibraryController>,
images: &[dr_types::ImageId],
rating: Option<u8>,
flag: Option<dr_types::FlagState>,
) {
if images.is_empty() {
// Nothing selected. Said out loud rather than ignored: a keystroke
// that silently does nothing reads as a broken key.
window
.global::<Library>()
.set_library_status("Select an image first".into());
return;
}
let writes = {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let conn = catalog.connection();
let wrote = match (rating, flag) {
(Some(r), _) => dr_catalog::rating::set_rating_many(conn, images, r),
(_, Some(f)) => dr_catalog::rating::set_flag_many(conn, images, f),
// Neither axis named: nothing to do, and not an error.
(None, None) => return,
};
if let Err(e) = wrote {
window
.global::<Library>()
.set_library_error(format!("recording rating: {e}").into());
return;
}
// Report what happened, in the user's terms rather than as a count of
// rows. A bulk judgement on a selection is easy to trigger by accident
// and the status line is the only confirmation of its extent.
window
.global::<Library>()
.set_library_status(judgement_summary(images.len(), rating, flag).into());
// Refresh the grid and the chips from what actually landed, rather
// than assuming the write took: a clamped or coalesced value must show
// as what is stored.
let visible = ctl.visible_ids();
sync_ratings(window, catalog, &visible);
refresh_rating_counts(window, catalog);
collect_sidecar_writes(catalog, images)
};
// A filtered grid may no longer contain what was just judged — rating an
// image 2 while showing "★4+" means it belongs elsewhere now. Reloading
// keeps the cells and the header count honest.
if !ctl.filter.borrow().is_unfiltered() {
load_window(window, ctl);
}
start_sidecar_writes(window, ctl, writes);
start_xmp_writes(window, ctl, images);
}
/// TRACES: FR-UI-5 | FR-CULL-4
/// The photograph at one row of the loaded window, for the gestures that
/// name a single photograph: a star clicked on a cell, and develop's keys
/// and top bar, which name the open one by its roll row.
///
/// `None` for a negative row as well as one past the end. Develop passes
/// `-1` when the open photograph is not in the window, and the answer then
/// is to judge nothing — never to fall back on the grid's selection, which
/// is not what is on screen.
fn image_at_row(ids: &[i64], row: i32) -> Option<dr_types::ImageId> {
let row = usize::try_from(row).ok()?;
ids.get(row).map(|id| dr_types::ImageId(*id as u64))
}
/// What the status line says about a judgement that just landed.
fn judgement_summary(n: usize, rating: Option<u8>, flag: Option<dr_types::FlagState>) -> String {
let what = match (rating, flag) {
(Some(0), _) => "unrated".to_string(),
(Some(r), _) => format!("{r} star{}", if r == 1 { "" } else { "s" }),
(_, Some(dr_types::FlagState::Pick)) => "picked".to_string(),
(_, Some(dr_types::FlagState::Reject)) => "rejected".to_string(),
(_, Some(dr_types::FlagState::Unflagged)) => "unflagged".to_string(),
(None, None) => return String::new(),
};
if n == 1 {
what
} else {
format!("{n} images · {what}")
}
}
/// TRACES: FR-DEV-6
/// Apply copied develop settings to a selection of images.
///
/// # Why this goes straight to the sidecars
///
/// The sidecar is the authoritative store for an edit (ARCH §6.12) and the
/// catalog holds no parameters at all — only a `graph_hash` — so there is
/// nothing here for the catalog to record. Nor is any image opened: applying
/// to forty frames by loading forty develop sessions would mean forty RAW
/// downloads and forty demosaics to move some numbers between two maps, which
/// is not a thing to ask of a phone. See [`crate::presets`].
///
/// # What the user sees
///
/// Nothing in the grid changes — a thumbnail is rendered from the server's
/// preview and does not reflect an edit — so the status line is the only
/// confirmation, exactly as it is for a bulk judgement. The applied settings
/// appear when a target is next opened in develop, which is what reads the
/// sidecar back.
pub fn paste_settings_to_selection(
window: &AppWindow,
ctl: &Rc<LibraryController>,
images: &[dr_types::ImageId],
preset: &dr_pipeline::Preset,
scope: dr_pipeline::Scope,
) {
if images.is_empty() {
// Said out loud rather than ignored, matching what a judgement
// keystroke does with an empty selection.
window
.global::<Library>()
.set_library_status("Select an image first".into());
return;
}
let writes = {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
// A never-judged image may have no version row yet, and the query
// below joins on one. Ratings create them as a side effect; a paste
// is the first write path that can reach an image which has never
// been rated, so it has to ask for them itself.
if let Err(e) = dr_catalog::rating::ensure_default_versions(catalog.connection()) {
log::debug!("ensuring versions before a paste: {e}");
}
collect_settings_writes(catalog, images, preset, scope)
};
if writes.is_empty() {
window
.global::<Library>()
.set_library_error("Could not find those images in the catalog.".into());
return;
}
let count = writes.len();
window.global::<Library>().set_library_status(
format!(
"Applied settings to {count} image{}.",
if count == 1 { "" } else { "s" }
)
.into(),
);
start_sidecar_writes(window, ctl, writes);
}
/// Gather one settings write per image, addressed by remote path and version.
///
/// The uuid comes from the catalog for the same reason a judgement's does: it
/// is the identity a cross-device merge keys on, and a generated one would
/// write a second version beside the one the image already has (FR-NC-8).
fn collect_settings_writes(
catalog: &Catalog,
images: &[dr_types::ImageId],
preset: &dr_pipeline::Preset,
scope: dr_pipeline::Scope,
) -> Vec<library::SidecarWrite> {
let placeholders = std::iter::repeat_n("?", images.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"SELECT i.source_ref, v.uuid
FROM images i
JOIN versions v ON v.image_id = i.id AND v.is_default = 1
WHERE i.id IN ({placeholders})"
);
let params: Vec<rusqlite::types::Value> = images
.iter()
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
.collect();
let Ok(mut stmt) = catalog.connection().prepare(&sql) else {
return Vec::new();
};
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok(library::SidecarWrite {
image_path: r.get(0)?,
version_uuid: r.get(1)?,
amendment: library::Amendment::Settings {
preset: preset.clone(),
scope,
// A paste carries no masks, and must not: a mask is drawn
// against one photograph and describes nothing on another.
// The target keeps whatever local adjustments it already had.
masks: None,
// A paste carries no film either, and for a plainer reason
// than the masks: a preset is a parameter map, and a stock is
// not a parameter. Pasting one would be pasting a choice the
// clipboard never captured.
film: None,
// Nor snapshots: they are states of the photograph they were
// taken on, and mean nothing on another.
snapshots: None,
},
})
});
match rows {
Ok(rows) => rows.flatten().collect(),
Err(e) => {
log::debug!("collecting settings writes: {e}");
Vec::new()
}
}
}
/// Gather what the sidecar writer needs for each judged image.
///
/// The version uuid comes from the catalog rather than being generated here:
/// it is the identity a cross-device merge keys on, so the sidecar and the
/// catalog must name the same version or a sync would treat one photograph's
/// judgement as two (FR-NC-8).
fn collect_sidecar_writes(
catalog: &Catalog,
images: &[dr_types::ImageId],
) -> Vec<library::SidecarWrite> {
let placeholders = std::iter::repeat_n("?", images.len())
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"SELECT i.source_ref, v.uuid, v.rating, v.flag, coalesce(v.label, 0)
FROM images i
JOIN versions v ON v.image_id = i.id AND v.is_default = 1
WHERE i.id IN ({placeholders})"
);
let params: Vec<rusqlite::types::Value> = images
.iter()
.map(|i| rusqlite::types::Value::Integer(i.0 as i64))
.collect();
let Ok(mut stmt) = catalog.connection().prepare(&sql) else {
return Vec::new();
};
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
Ok(library::SidecarWrite {
image_path: r.get(0)?,
version_uuid: r.get(1)?,
amendment: library::Amendment::Judgement {
rating: r.get::<_, i64>(2)? as u8,
flag: r.get::<_, i64>(3)? as u8,
label: r.get::<_, i64>(4)?.clamp(0, 5) as u8,
},
})
});
match rows {
Ok(rows) => rows.flatten().collect(),
Err(e) => {
log::debug!("collecting sidecar writes: {e}");
Vec::new()
}
}
}
/// TRACES: FR-CAT-11a | FR-CAT-13
/// Write these images' judgements to their sidecars and, where switched on,
/// their `.xmp` files, and bring the chips up to date — what a rating
/// keystroke does after its catalog write, for a change that did not come
/// from a keystroke. Consolidating duplicates merges ratings, flags, labels
/// and keywords onto the copy that stays, and this is how that reaches the
/// file beside it.
pub(crate) fn save_judgements(
window: &AppWindow,
ctl: &Rc<LibraryController>,
images: &[dr_types::ImageId],
) {
if images.is_empty() {
return;
}
let writes = {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
refresh_rating_counts(window, catalog);
refresh_label_counts(window, catalog);
collect_sidecar_writes(catalog, images)
};
start_sidecar_writes(window, ctl, writes);
start_xmp_writes(window, ctl, images);
}
/// Push judgements out to sidecars on a worker, reporting once at the end.
/// TRACES: FR-CAT-13 | NFR-R4
/// Write these images' ratings, labels and keywords to the `.xmp` beside
/// each, where the user has switched that on.
///
/// The record is read from the catalog *now*, whole, rather than carried
/// from the gesture: a rating and a keyword typed a second apart are two
/// writes of the same file, and the second must not carry a copy of the
/// first taken before it landed.
pub(crate) fn start_xmp_writes(
window: &AppWindow,
ctl: &Rc<LibraryController>,
images: &[dr_types::ImageId],
) {
if !ctl.write_xmp.get() || images.is_empty() || ctl.is_offline() {
return;
}
let Some((conn, _)) = ctl.session.borrow().clone() else {
return;
};
let writes: Vec<library::XmpWrite> = {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let c = catalog.connection();
images
.iter()
.filter_map(|&image| {
let version = dr_catalog::rating::default_version_id(c, image).ok()?;
let image_path: String = c
.query_row(
"SELECT source_ref FROM images WHERE id = ?1",
[image.0 as i64],
|r| r.get(0),
)
.ok()?;
Some(library::XmpWrite {
image_path,
record: crate::xmp_sync::record_of(c, image, version),
})
})
.collect()
};
if writes.is_empty() {
return;
}
let count = writes.len();
let rx = library::spawn_xmp_writes(conn, writes);
let job = ctl.activity.begin(
crate::activity::Kind::Upload,
format!("Writing {count} XMP sidecar(s)"),
);
drain_xmp(window.as_weak(), ctl.clone(), rx, job, false);
}
/// TRACES: FR-CAT-13
/// The offered reload: take the sidecars' values for every photograph the
/// last pull found disagreeing with the catalog.
pub(crate) fn start_xmp_reload(window: &AppWindow, ctl: &Rc<LibraryController>) {
let Some((conn, _)) = ctl.session.borrow().clone() else {
return;
};
let paths: Vec<String> = {
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
let Some(root_id) = root_id_of(catalog, &conn.account.root) else {
return;
};
crate::xmp_sync::conflicts(catalog, root_id)
.into_iter()
.map(|c| c.path)
.collect()
};
if paths.is_empty() {
return;
}
let count = paths.len();
let rx = library::spawn_xmp_reload(
conn.clone(),
conn.account.root.clone(),
library::catalog_path(&conn.account),
paths,
);
let job = ctl.activity.begin(
crate::activity::Kind::Download,
format!("Reloading {count} XMP sidecar(s)"),
);
drain_xmp(window.as_weak(), ctl.clone(), rx, job, true);
}
/// Wait for a batch of XMP work to report, then say what it did.
fn drain_xmp(
weak: slint::Weak<AppWindow>,
ctl: Rc<LibraryController>,
rx: std::sync::mpsc::Receiver<library::XmpMessage>,
job: crate::activity::Activity,
reload: bool,
) {
let timer = slint::Timer::default();
let job = RefCell::new(Some(job));
let ctl_cb = ctl.clone();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(200),
move || {
let ctl = &ctl_cb;
let message = match rx.try_recv() {
Ok(m) => m,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
stop(&ctl.xmp_timer);
return;
}
};
stop(&ctl.xmp_timer);
let library::XmpMessage::Finished {
written,
failed,
last_error,
} = message;
let status = match (reload, failed, last_error) {
(false, 0, _) => format!("{written} XMP sidecar(s) written"),
(true, 0, _) => format!("{written} photograph(s) reloaded from XMP"),
(_, n, Some(e)) => format!("{n} XMP sidecar(s) failed: {e}"),
(_, n, None) => format!("{n} XMP sidecar(s) failed"),
};
if let Some(job) = job.borrow_mut().take() {
if failed > 0 {
job.fail(status.clone());
} else {
job.finish(status.clone());
}
}
if let Some(w) = weak.upgrade() {
w.global::<Library>().set_library_status(status.into());
if reload {
// The grid draws what the reload changed, and the
// settings page stops offering what is settled.
let visible = ctl.visible_ids();
if let Some(catalog) = ctl.catalog.borrow().as_ref() {
sync_ratings(&w, catalog, &visible);
refresh_rating_counts(&w, catalog);
refresh_label_counts(&w, catalog);
}
refresh_xmp_conflicts(&w, ctl);
}
}
},
);
*ctl.xmp_timer.borrow_mut() = Some(timer);
}
/// TRACES: FR-CAT-13
/// How many sidecars the last pull found disagreeing with the catalog, for
/// the settings page to offer the reload against.
pub(crate) fn refresh_xmp_conflicts(window: &AppWindow, ctl: &Rc<LibraryController>) {
let count = (|| {
let (conn, _) = ctl.session.borrow().clone()?;
let borrow = ctl.catalog.borrow();
let catalog = borrow.as_ref()?;
let root_id = root_id_of(catalog, &conn.account.root)?;
Some(crate::xmp_sync::conflicts(catalog, root_id).len())
})()
.unwrap_or(0);
window.set_settings_xmp_conflicts(count as i32);
}
fn root_id_of(catalog: &Catalog, root: &str) -> Option<i64> {
catalog
.connection()
.query_row(
"SELECT id FROM roots WHERE label = ?1 AND kind = 'remote'",
[root],
|r| r.get(0),
)
.ok()
}
pub(crate) fn start_sidecar_writes(
window: &AppWindow,
ctl: &Rc<LibraryController>,
writes: Vec<library::SidecarWrite>,
) {
if writes.is_empty() {
return;
}
// TRACES: FR-CAT-9
// Offline is passed down rather than used to skip.
//
// It used to skip, and the reasoning was that a cull stays responsive
// because the rating is safe in the catalog. That held for judgements and
// not for edits: the catalog stores no parameters, so a pasted edit made
// offline survived nowhere at all. Every write now commits to the local
// sidecar cache first and the upload is best-effort, which keeps the
// keystroke path off the network — the original concern — without the
// write being conditional on it.
let offline = ctl.is_offline();
let Some((conn, _)) = ctl.session.borrow().clone() else {
return;
};
let Some(cache_dir) = ctl.sidecar_cache_dir() else {
return;
};
let count = writes.len();
let rx = library::spawn_sidecar_writes(conn.clone(), writes, cache_dir, offline);
let timer = slint::Timer::default();
let weak = window.as_weak();
let ctl_cb = ctl.clone();
// The writer reports once at the end, so there is no per-file progress to
// show — but a cull that has just rated forty frames has forty uploads in
// flight, and "is that saved yet" deserves an answer somewhere.
let job = ctl.activity.begin(
crate::activity::Kind::Upload,
format!("Saving {count} judgement(s)"),
);
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(200),
move || {
let Some(w) = weak.upgrade() else { return };
// One message is all this channel ever carries — the writer reports
// `Finished` once and hangs up — so this drains a single item rather
// than looping like the scan and thumbnail drains do.
match rx.try_recv() {
Ok(library::SidecarMessage::Finished {
written,
queued,
failed,
last_error,
}) => {
if failed == 0 && queued > 0 {
// Recorded locally, waiting for the server. Said out
// loud because the user has just made an edit with no
// connection and deserves to know it is safe — the
// old behaviour here was to drop it silently.
log::debug!("{queued} sidecar(s) queued for upload");
ctl_cb.outbox_maybe_dirty.set(true);
job.finish_quietly();
w.global::<Library>().set_library_status(
format!("{queued} edit(s) saved · will upload when back online").into(),
);
stop(&ctl_cb.sidecar_timer);
return;
}
if failed > 0 {
// A failed upload left the edit in the outbox.
ctl_cb.outbox_maybe_dirty.set(true);
log::warn!(
"{failed} sidecar write(s) failed: {}",
last_error.clone().unwrap_or_default()
);
job.fail(format!(
"{written} saved · {failed} failed: {}",
last_error.clone().unwrap_or_default()
));
// Said plainly, because the consequence is specific:
// the rating is safe in the catalog but will not
// survive deleting it.
w.global::<Library>().set_library_status(
format!(
"{written} saved · {failed} could not be written \
to the library folder"
)
.into(),
);
} else {
log::debug!("{written} sidecar(s) written");
// Quietly: a cull produces one of these every few
// seconds and none of them is news.
job.finish_quietly();
}
stop(&ctl_cb.sidecar_timer);
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
stop(&ctl_cb.sidecar_timer);
}
}
},
);
*ctl.sidecar_timer.borrow_mut() = Some(timer);
}
/// Slint carries the flag as an integer, matching the catalog's encoding.
fn flag_code(f: dr_types::FlagState) -> i32 {
match f {
dr_types::FlagState::Unflagged => 0,
dr_types::FlagState::Pick => 1,
dr_types::FlagState::Reject => 2,
}
}
/// The flag an integer from Slint stands for.
fn flag_from_code(v: i32) -> dr_types::FlagState {
match v {
1 => dr_types::FlagState::Pick,
2 => dr_types::FlagState::Reject,
_ => dr_types::FlagState::Unflagged,
}
}
/// --- ratings and flags (FR-CAT-5, FR-CULL-4) --------------------------
pub(super) fn wire_ratings_and_flags(
window: &AppWindow,
ctl: &Rc<LibraryController>,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
) {
// Clicking a star rates *that cell*, not the selection. The pointer names
// one photograph unambiguously, and a click that silently rated forty
// others would be a trap — the keyboard is the bulk gesture.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window
.global::<Library>()
.on_library_cell_rated(move |row, stars| {
let Some(w) = weak.upgrade() else { return };
let Some(id) = image_at_row(&ctl.image_ids.borrow(), row) else {
return;
};
apply_judgement(&w, &ctl, &[id], Some(stars.clamp(0, 5) as u8), None);
});
}
// TRACES: FR-UI-5 | FR-CULL-4
// The flag of one photograph by row: develop's Pick and Reject, and its
// P, X and U. The open photograph only, for the reason a star clicked in
// the grid rates only its own cell — develop is working on one frame, and
// a selection left behind in the grid is not what is in front of it.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window
.global::<Library>()
.on_library_cell_flagged(move |row, flag| {
let Some(w) = weak.upgrade() else { return };
let Some(id) = image_at_row(&ctl.image_ids.borrow(), row) else {
return;
};
apply_judgement(&w, &ctl, &[id], None, Some(flag_from_code(flag)));
});
}
// Fold a burst up, or open it out (FR-CULL-5). A reload rather than a repaint, because
// it changes what the grid's query returns — see [`crate::bursts::toggle`].
{
let weak = window.as_weak();
let ctl = ctl.clone();
window
.global::<Library>()
.on_library_burst_toggled(move |row| {
let Some(w) = weak.upgrade() else { return };
let id = ctl
.image_ids
.borrow()
.get(row as usize)
.map(|id| dr_types::ImageId(*id as u64));
let Some(id) = id else { return };
let changed = {
let borrow = ctl.catalog.borrow();
match borrow.as_ref() {
Some(catalog) => crate::bursts::toggle(catalog, id),
None => false,
}
};
if changed {
ctl.requested.borrow_mut().clear();
load_window(&w, &ctl);
}
});
}
// Name the frame an open burst folds to (FR-CULL-5). The default is the
// earliest of them, chosen because it is a fact about the clock and not a
// judgement about the photograph; this is the photographer, who is the only
// one who knows which of the twelve is the keeper, saying otherwise.
//
// A repaint of the badges rather than a reload, which is what separates it
// from folding a burst up: the grid's query returns the same rows either
// way while the group is open, and only the mark on them moves. Refilled
// for the whole window in one statement — the tick has to leave the frame
// that was carrying it, and that frame is a different cell.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window
.global::<Library>()
.on_library_burst_representative_chosen(move |row| {
let Some(w) = weak.upgrade() else { return };
let ids: Vec<dr_types::ImageId> = ctl
.image_ids
.borrow()
.iter()
.map(|id| dr_types::ImageId(*id as u64))
.collect();
let Some(id) = ids.get(row as usize).copied() else {
return;
};
let borrow = ctl.catalog.borrow();
let Some(catalog) = borrow.as_ref() else {
return;
};
if crate::bursts::choose(catalog, id) {
crate::bursts::sync_badges(&w, catalog, &ids);
}
});
}
// A rating or flag key. Applies to the whole selection, which is what
// makes judging a run of frames one keystroke rather than forty — unless
// the pointer is over a photograph outside it, which then takes the key
// alone (darktable's rule; see `judged_images`).
{
let weak = window.as_weak();
let ctl = ctl.clone();
let coll_for_keys = coll_ctl.clone();
window
.global::<Library>()
.on_library_judged(move |rating, flag, hovered| {
let Some(w) = weak.upgrade() else { return };
let offset = w.global::<Library>().get_library_offset().max(0) as usize;
let under_pointer = usize::try_from(hovered)
.ok()
.and_then(|ordinal| ordinal.checked_sub(offset))
.and_then(|row| ctl.visible_ids().get(row).copied());
let chosen = judged_images(coll_for_keys.selected(), under_pointer);
// Exactly one axis is meant per keystroke; the other arrives as
// -1 so a star press cannot disturb a flag or the reverse.
if rating >= 0 {
apply_judgement(&w, &ctl, &chosen, Some(rating.clamp(0, 5) as u8), None);
} else if flag >= 0 {
apply_judgement(&w, &ctl, &chosen, None, Some(flag_from_code(flag)));
}
});
}
// TRACES: FR-CAT-5 | FR-UI-5
// A label key in the grid: the photographs a rating key would reach,
// toggled as one set.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let coll_for_keys = coll_ctl.clone();
window
.global::<Library>()
.on_library_labelled(move |code, hovered| {
let Some(w) = weak.upgrade() else { return };
let Some(gesture) = LabelGesture::from_code(code, true) else {
return;
};
let offset = w.global::<Library>().get_library_offset().max(0) as usize;
let under_pointer = usize::try_from(hovered)
.ok()
.and_then(|ordinal| ordinal.checked_sub(offset))
.and_then(|row| ctl.visible_ids().get(row).copied());
let chosen = judged_images(coll_for_keys.selected(), under_pointer);
apply_label(&w, &ctl, &chosen, gesture);
});
}
// TRACES: FR-CAT-5 | FR-UI-4
// The selection bar's picker: the selection, set outright.
{
let weak = window.as_weak();
let ctl = ctl.clone();
let coll = coll_ctl.clone();
window
.global::<Library>()
.on_library_label_chosen(move |code| {
let Some(w) = weak.upgrade() else { return };
let Some(gesture) = LabelGesture::from_code(code, false) else {
return;
};
apply_label(&w, &ctl, &coll.selected(), gesture);
});
}
// TRACES: FR-CAT-5
// One photograph by row — the one open in develop.
{
let weak = window.as_weak();
let ctl = ctl.clone();
window
.global::<Library>()
.on_library_cell_labelled(move |row, code, toggle| {
let Some(w) = weak.upgrade() else { return };
let Some(gesture) = LabelGesture::from_code(code, toggle) else {
return;
};
let Some(id) = image_at_row(&ctl.image_ids.borrow(), row) else {
return;
};
apply_label(&w, &ctl, &[id], gesture);
});
}
}
/// TRACES: FR-UI-5
/// What a judgement key applies to: the photograph under the pointer when it
/// is not part of the selection, and the selection otherwise.
///
/// Hovering one of the selected frames rates all of them, so the pointer
/// resting on the grid never shrinks a forty-frame judgement to one. With
/// the pointer off the grid, or over a cell the grid has since scrolled away
/// from, it is the selection as it always was.
fn judged_images(
selection: Vec<dr_types::ImageId>,
under_pointer: Option<dr_types::ImageId>,
) -> Vec<dr_types::ImageId> {
match under_pointer {
Some(id) if !selection.contains(&id) => vec![id],
_ => selection,
}
}
/// --- keywords (FR-CAT-5, FR-CAT-6) ------------------------------------
///
/// Three callbacks and no state of their own: the sheet's open/shut is local
/// to the `.slint` file, and what a keyword applies to is the grid selection
/// the collections controller already owns. A second copy of either here is
/// a second thing that can disagree with the first.
pub(super) fn wire_keywords(
window: &AppWindow,
ctl: &Rc<LibraryController>,
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
) {
{
let weak = window.as_weak();
let ctl = ctl.clone();
let coll_for_keywords = coll_ctl.clone();
window
.global::<Library>()
.on_library_keywords_opened(move || {
let Some(w) = weak.upgrade() else { return };
refresh_keywords(&w, &ctl, &coll_for_keywords.selected());
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window
.global::<Library>()
.on_library_assign_keyword(move |word| {
let Some(w) = weak.upgrade() else { return };
apply_keyword(&w, &ctl, word.as_str(), true);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window
.global::<Library>()
.on_library_unassign_keyword(move |word| {
let Some(w) = weak.upgrade() else { return };
apply_keyword(&w, &ctl, word.as_str(), false);
});
}
}
#[cfg(test)]
mod tests {
use super::*;
// --- judging the photograph open in develop (FR-UI-5) -----------------
/// TRACES: FR-UI-5
#[test]
fn a_row_names_one_photograph_and_no_row_names_none() {
let ids = [11, 22, 33];
assert_eq!(image_at_row(&ids, 1), Some(dr_types::ImageId(22)));
assert_eq!(image_at_row(&ids, 3), None);
// The open photograph outside the loaded window: nothing is judged,
// rather than whatever the grid last had selected.
assert_eq!(image_at_row(&ids, -1), None);
assert_eq!(image_at_row(&[], 0), None);
}
/// The `if (Keys.chord(event) == "<key>") { … }` line of develop's
/// key handler that binds `key`.
fn develop_binding(key: &str) -> &'static str {
let app = include_str!("../../ui/app.slint");
let scope = &app[app
.find("// KEYMAP: Develop\n key-pressed(event)")
.expect("app.slint no longer has develop's key-pressed handler")..];
let needle = format!("if (Keys.chord(event) == \"{key}\") {{");
let at = scope
.find(&needle)
.unwrap_or_else(|| panic!("develop does not bind `{key}`"));
scope[at..].lines().next().unwrap_or("")
}
/// TRACES: FR-UI-5 | FR-CULL-4
/// 0–5, P, X and U in develop judge the open photograph — its roll row —
/// and do nothing else: no step to the next frame, which belongs to
/// culling's auto-advance and not to the view where one frame is worked on.
#[test]
fn develop_judges_the_open_photograph_and_stays_on_it() {
for (key, call) in [
("0", "library-cell-rated(Library.library-roll-current, 0)"),
("1", "library-cell-rated(Library.library-roll-current, 1)"),
("2", "library-cell-rated(Library.library-roll-current, 2)"),
("3", "library-cell-rated(Library.library-roll-current, 3)"),
("4", "library-cell-rated(Library.library-roll-current, 4)"),
("5", "library-cell-rated(Library.library-roll-current, 5)"),
("P", "library-cell-flagged(Library.library-roll-current, 1)"),
("X", "library-cell-flagged(Library.library-roll-current, 2)"),
("U", "library-cell-flagged(Library.library-roll-current, 0)"),
] {
let line = develop_binding(key);
assert!(line.contains(call), "`{key}` in develop: {line}");
for step in ["step-photo", "roll-pick", "move-cursor"] {
assert!(
!line.contains(step),
"`{key}` in develop also steps: {line}"
);
}
}
}
// --- what a judgement key reaches (FR-UI-5) ----------------------------
#[test]
fn a_hovered_photograph_outside_the_selection_is_judged_alone() {
let (a, b, c) = (
dr_types::ImageId(1),
dr_types::ImageId(2),
dr_types::ImageId(3),
);
assert_eq!(judged_images(vec![a, b], Some(c)), vec![c]);
}
#[test]
fn hovering_a_selected_photograph_judges_the_whole_selection() {
let (a, b) = (dr_types::ImageId(1), dr_types::ImageId(2));
assert_eq!(judged_images(vec![a, b], Some(b)), vec![a, b]);
}
#[test]
fn with_nothing_hovered_the_selection_is_judged() {
let (a, b) = (dr_types::ImageId(1), dr_types::ImageId(2));
assert_eq!(judged_images(vec![a, b], None), vec![a, b]);
assert_eq!(judged_images(vec![], None), vec![]);
}
// --- colour labels (FR-CAT-5) -----------------------------------------
#[test]
fn a_label_key_toggles_and_the_picker_sets() {
use dr_types::ColourLabel::*;
assert_eq!(
LabelGesture::from_code(1, true),
Some(LabelGesture::Toggle(Red))
);
// A key with no colour behind it does nothing rather than clearing.
assert_eq!(LabelGesture::from_code(0, true), None);
assert_eq!(
LabelGesture::from_code(0, false),
Some(LabelGesture::Set(None))
);
assert_eq!(
LabelGesture::from_code(5, false),
Some(LabelGesture::Set(Some(Purple)))
);
}
#[test]
fn a_label_is_reported_by_name() {
// The status line names the label in words, never only by its mark.
assert_eq!(
label_summary(1, Some(dr_types::ColourLabel::Green)),
"Green label"
);
assert_eq!(label_summary(3, None), "3 images · no label");
}
// --- what the status line says about a keyword (FR-CAT-5) -------------
//
// Split out from the callback for the same reason `decide_drop` is: the
// sheet cannot be driven from a test, and this is the part that can
// actually mislead someone.
/// TRACES: FR-CAT-5
#[test]
fn a_partly_applied_keyword_reports_the_honest_count() {
// Nine of the twelve already had it. Claiming twelve is how a user
// learns that the counts are decorative.
assert_eq!(
keyword_summary("puffin", 3, 12, true),
"Added “puffin” to 3 of 12"
);
}
/// TRACES: FR-CAT-5
#[test]
fn a_keyword_that_changed_nothing_says_so_rather_than_claiming_success() {
assert_eq!(
keyword_summary("puffin", 0, 12, true),
"Every selected photograph already had “puffin”"
);
assert_eq!(
keyword_summary("puffin", 0, 12, false),
"None of the selected photographs had “puffin”"
);
}
/// TRACES: FR-CAT-5
#[test]
fn one_photograph_is_singular() {
// "Added to 1 photographs" is the kind of small wrongness that makes
// the rest of the interface look unfinished.
assert_eq!(
keyword_summary("puffin", 1, 1, true),
"Added “puffin” to 1 photograph"
);
assert_eq!(
keyword_summary("puffin", 2, 2, true),
"Added “puffin” to 2 photographs"
);
}
/// TRACES: FR-CAT-5
#[test]
fn removing_a_keyword_reads_as_removal() {
assert_eq!(
keyword_summary("blurry", 4, 4, false),
"Removed “blurry” from 4 photographs"
);
}
/// TRACES: FR-CAT-5
#[test]
fn typing_a_word_with_nothing_selected_says_what_it_did_do() {
// It builds the vocabulary, which is a legitimate thing to do ahead of
// a shoot — so it must not report itself as having keyworded nothing.
assert_eq!(
keyword_summary("puffin", 0, 0, true),
"Added “puffin” to the keyword list"
);
}
}