//! 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::().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 = counts.iter().map(|n| *n as i32).collect(); window .global::() .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::() .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 = counts.iter().map(|n| *n as i32).collect(); window .global::() .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), } 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 { 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 | R7 /// 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, images: &[dr_types::ImageId], gesture: LabelGesture, ) { if images.is_empty() { window .global::() .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::() .set_library_error(format!("recording label: {e}").into()); return; } window .global::() .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) -> 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, 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 = 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::() .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, 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::() .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::() .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::() .set_library_error(format!("{e}").into()); return; } }; window .global::() .set_library_error(slint::SharedString::new()); window .global::() .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}") } /// TRACES: R7 | FR-CULL-13 /// Apply a judgement to a set of images: catalog first, then sidecars. /// /// The judgement dispatch: every rating and flag a person gives passes /// through here, from an `on_*` callback below, and nothing else calls it. /// `tools/traceability/src/verdicts.rs` holds that to a list — a call from /// anywhere else, and in particular from anything that computes evidence /// about a frame, fails `cargo test -p traceability`. /// /// # 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, images: &[dr_types::ImageId], rating: Option, flag: Option, ) { 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::() .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::() .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::() .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 { 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, flag: Option) -> 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, 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::() .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::() .set_library_error("Could not find those images in the catalog.".into()); return; } let count = writes.len(); window.global::().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 { let placeholders = std::iter::repeat_n("?", images.len()) .collect::>() .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 = 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, // TRACES: FR-DEV-3f // The film travels where its sliders do: a stock is a look, // not a fact about one frame, and a preset carrying one is // how "Portra, printed" is applied to forty photographs. film: preset.film_for(scope).map(|f| f.cloned()), // 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 { let placeholders = std::iter::repeat_n("?", images.len()) .collect::>() .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 = 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, 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, 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 = { 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) { let Some((conn, _)) = ctl.session.borrow().clone() else { return; }; let paths: Vec = { 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, ctl: Rc, rx: std::sync::mpsc::Receiver, 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::().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) { 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 { 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, writes: Vec, ) { 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::().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::().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, coll_ctl: &Rc, ) { // 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::() .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::() .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::() .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); } }); } // TRACES: FR-CULL-5 | FR-CULL-13 // 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. The // choice is a grouping, not a verdict: `bursts::choose` moves the badge // and writes no rating, flag or label (FR-CULL-13). // // 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::() .on_library_burst_representative_chosen(move |row| { let Some(w) = weak.upgrade() else { return }; let ids: Vec = 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::() .on_library_judged(move |rating, flag, hovered| { let Some(w) = weak.upgrade() else { return }; let offset = w.global::().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::() .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::().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::() .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::() .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, under_pointer: Option, ) -> Vec { 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, coll_ctl: &Rc, ) { { let weak = window.as_weak(); let ctl = ctl.clone(); let coll_for_keywords = coll_ctl.clone(); window .global::() .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::() .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::() .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) == "") { … }` 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" ); } }