The scan/opening/status/error lines, offline mode and its retry, pinning a collection offline, the sync and thumbnail-sweep state, and the callbacks that route the grid to a rescan, a sync, another library, a panorama merge or an export — the last of what AppWindow still carried under the library- prefix — move onto the `Library` global started earlier on this branch. Rust reaches them through window.global::<Library>() rather than window.set_/get_/on_/invoke_ on the root. library-visible is the one name that stays: it is computed from active-page and active-view, the shell's own routing state, which a global cannot read. AppWindow now declares no other library- property or callback.
1071 lines
40 KiB
Rust
1071 lines
40 KiB
Rust
//! 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));
|
|
|
|
if let Some(mut cell) = model.row_data(row) {
|
|
if cell.rating != rating || cell.flag != flag {
|
|
cell.rating = rating;
|
|
cell.flag = flag;
|
|
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);
|
|
}
|
|
|
|
// --- 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);
|
|
}
|
|
|
|
/// 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
|
|
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,
|
|
},
|
|
})
|
|
});
|
|
|
|
match rows {
|
|
Ok(rows) => rows.flatten().collect(),
|
|
Err(e) => {
|
|
log::debug!("collecting sidecar writes: {e}");
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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_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 id = ctl
|
|
.image_ids
|
|
.borrow()
|
|
.get(row as usize)
|
|
.map(|id| dr_types::ImageId(*id as u64));
|
|
let Some(id) = id else { return };
|
|
|
|
apply_judgement(&w, &ctl, &[id], Some(stars.clamp(0, 5) as u8), None);
|
|
});
|
|
}
|
|
|
|
// 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.
|
|
{
|
|
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| {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
let chosen = coll_for_keys.selected();
|
|
|
|
// 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)));
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/// --- 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::*;
|
|
|
|
// --- 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"
|
|
);
|
|
}
|
|
}
|