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.
This commit is contained in:
2026-09-26 07:19:13 -04:00
parent 8d4ecb75c1
commit 220e9af222
12 changed files with 1413 additions and 9 deletions
+1 -1
View File
@@ -51,6 +51,6 @@ pub use grid::wire;
pub(crate) use open::{forget_catalog, show_catalog_now, start_rescan};
pub use open::{open, reload};
pub use ratings_keywords::paste_settings_to_selection;
pub(crate) use ratings_keywords::start_sidecar_writes;
pub(crate) use ratings_keywords::{save_judgements, start_sidecar_writes};
pub use timeline::format_date;
pub use window::refresh_thumbnail;
+3
View File
@@ -491,6 +491,8 @@ fn drain_scan(
let borrow = ctl.catalog.borrow();
if let Some(cat) = borrow.as_ref() {
crate::collections_ui::refresh_tree(&w, &coll_ctl, cat);
// A scan is what finds a second copy.
crate::duplicates_ui::refresh_count(&w, cat);
}
}
load_window(&w, ctl);
@@ -831,6 +833,7 @@ fn adopt_catalog(
// The sidebar before the grid, because the grid's badges read collection
// membership — the same order the scan's completion uses.
crate::collections_ui::refresh_tree(window, coll_ctl, &cat);
crate::duplicates_ui::refresh_count(window, &cat);
*ctl.catalog.borrow_mut() = Some(cat);
// TRACES: FR-UI-8
@@ -661,6 +661,34 @@ fn collect_sidecar_writes(
}
}
/// 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