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
View File
@@ -55,6 +55,7 @@ mod wiring_tree;
pub use controller::CollectionsController;
pub use press::{cancel_press, commit_press, select_row};
pub use trash::refresh_trash;
pub use tree_sync::{refresh_tree, sync_badges, sync_selection};
pub use wiring_grid::wire;
+3
View File
@@ -247,6 +247,9 @@ pub(super) fn drain_trash(
if let Some(cat) = borrow.as_ref() {
refresh_trash(&w, cat);
refresh_tree(&w, &ctl_cb, cat);
// A copy trashed or restored by hand changes
// which files are in the library twice.
crate::duplicates_ui::refresh_count(&w, cat);
}
drop(borrow);
+19 -8
View File
@@ -39,7 +39,9 @@
//! there, and consolidating again treats it as already moved.
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, Sender};
use std::sync::Arc;
use dr_catalog::duplicates::{Copy, Group, Outcome, Probe, PROBE_WINDOW};
use dr_catalog::{trash, Catalog};
@@ -102,11 +104,6 @@ impl Reviewed {
check: Check::Unchecked,
}
}
/// Whether the group goes ahead when the button is pressed.
pub fn planned(&self) -> bool {
self.include && matches!(self.check, Check::Same(_))
}
}
/// The dry-run figures the review shows above the list.
@@ -481,10 +478,11 @@ pub fn spawn_check(
catalog_path: PathBuf,
cache_dir: PathBuf,
groups: Vec<(usize, Group)>,
stop: Arc<AtomicBool>,
) -> Receiver<DupMessage> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let stopped = run_check(&conn, &catalog_path, cache_dir, groups, &tx).err();
let stopped = run_check(&conn, &catalog_path, cache_dir, groups, &stop, &tx).err();
let _ = tx.send(DupMessage::Finished { stopped });
});
rx
@@ -495,6 +493,7 @@ fn run_check(
catalog_path: &std::path::Path,
cache_dir: PathBuf,
groups: Vec<(usize, Group)>,
stop: &AtomicBool,
tx: &Sender<DupMessage>,
) -> Result<(), String> {
let catalog = Catalog::open(catalog_path).map_err(|e| e.to_string())?;
@@ -504,6 +503,9 @@ fn run_check(
let backend = crate::remote::connect(conn).map_err(|e| e.to_string())?;
let total = groups.len();
for (done, (index, group)) in groups.into_iter().enumerate() {
if stop.load(Ordering::Relaxed) {
return Err("Stopped".into());
}
// The server going away ends the job: every group after this
// one would fail the same way, and none of them is a verdict.
let (check, probes) =
@@ -736,16 +738,18 @@ pub async fn consolidate_group(
/// Consolidate groups on a worker, one at a time.
///
/// Closing the review stops the job *between* groups, never inside one.
/// `stop` ends the job *between* groups, never inside one.
pub fn spawn_consolidate(
conn: Connection,
catalog_path: PathBuf,
cache_dir: PathBuf,
plans: Vec<Plan>,
stop: Arc<AtomicBool>,
) -> Receiver<DupMessage> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let stopped = run_consolidate(&conn, &catalog_path, cache_dir, plans, &tx).err();
let stopped =
run_consolidate(&conn, &catalog_path, cache_dir, plans, &stop, &tx).err();
let _ = tx.send(DupMessage::Finished { stopped });
});
rx
@@ -756,6 +760,7 @@ fn run_consolidate(
catalog_path: &std::path::Path,
cache_dir: PathBuf,
plans: Vec<Plan>,
stop: &AtomicBool,
tx: &Sender<DupMessage>,
) -> Result<(), String> {
let catalog = Catalog::open(catalog_path).map_err(|e| e.to_string())?;
@@ -765,6 +770,10 @@ fn run_consolidate(
let backend = crate::remote::connect(conn).map_err(|e| e.to_string())?;
let total = plans.len();
for (done, plan) in plans.iter().enumerate() {
// Between groups, never inside one.
if stop.load(Ordering::Relaxed) {
return Err("Stopped".into());
}
let msg = match consolidate_group(&*backend, &catalog, &cache, plan).await {
Ok(outcome) => DupMessage::Consolidated {
index: plan.index,
@@ -1002,6 +1011,7 @@ mod tests {
self.catalog_path.clone(),
self.cache.clone(),
groups,
Arc::default(),
);
for msg in rx {
match msg {
@@ -1019,6 +1029,7 @@ mod tests {
self.catalog_path.clone(),
self.cache.clone(),
plans,
Arc::default(),
);
let (mut done, mut failed) = (0, Vec::new());
for msg in rx {
+863
View File
@@ -0,0 +1,863 @@
//! TRACES: FR-CAT-11a | FR-CAT-15 | FR-UI-4 | NFR-P9
//! The duplicate originals review: which groups there are, which copy stays,
//! what the check found, and the button that consolidates them.
//!
//! The page is a view of [`Reviewed`] groups held here. The catalog is read
//! on the UI thread only for what one query answers — the groups themselves,
//! and a merge preview for the thirty rows on the page — and every read of a
//! file happens on a worker (`crate::duplicates`), which reports per group.
use std::cell::{Cell, RefCell};
use std::collections::HashMap;
use std::rc::Rc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::Receiver;
use std::sync::Arc;
use dr_catalog::duplicates::Outcome;
use dr_catalog::Catalog;
use dr_types::ImageId;
use slint::{ComponentHandle, ModelRc, VecModel};
use crate::activity::{Activity, ActivityLog};
use crate::duplicates::{self as dups, Check, DupMessage, Reviewed};
use crate::library_ui::LibraryController;
use crate::{AppWindow, DuplicateCopy, DuplicateRow, Duplicates, Library, Page};
/// Groups on one page of the review. A page is what gets a thumbnail and a
/// merge preview, so this bounds the work a redraw does whatever the size
/// of the library.
pub const PAGE: usize = 30;
/// How a group ended in this session.
#[derive(Debug, Clone)]
enum Ended {
Done(String),
Failed(String),
}
/// What the review holds between redraws.
pub struct DuplicatesController {
review: RefCell<Vec<Reviewed>>,
page: Cell<usize>,
ended: RefCell<HashMap<usize, Ended>>,
/// Pictures for the page on screen, by `oc:fileid`.
thumbs: RefCell<HashMap<u64, slint::Image>>,
running: Cell<bool>,
consolidating: Cell<bool>,
stop: RefCell<Arc<AtomicBool>>,
timer: RefCell<Option<slint::Timer>>,
activity: Rc<ActivityLog>,
/// Survivors consolidated by the running job, whose sidecars are written
/// when it ends.
survivors: RefCell<Vec<ImageId>>,
counts: Cell<(usize, usize, usize)>,
}
impl DuplicatesController {
pub fn new(activity: Rc<ActivityLog>) -> Rc<Self> {
Rc::new(Self {
review: RefCell::default(),
page: Cell::new(0),
ended: RefCell::default(),
thumbs: RefCell::default(),
running: Cell::new(false),
consolidating: Cell::new(false),
stop: RefCell::default(),
timer: RefCell::default(),
activity,
survivors: RefCell::default(),
counts: Cell::new((0, 0, 0)),
})
}
}
/// TRACES: FR-CAT-11a
/// Put the number of duplicate groups where the sidebar and settings show
/// it. One `COUNT` query; called when the catalog is opened, after a scan,
/// and after anything moves files in or out of the trash — the events that
/// can change the answer.
pub fn refresh_count(window: &AppWindow, catalog: &Catalog) {
let (groups, _) = dr_catalog::duplicates::count(catalog.connection()).unwrap_or((0, 0));
window
.global::<Duplicates>()
.set_group_count(groups as i32);
}
/// Wire the page. `after` runs when a consolidation ends, to bring the grid,
/// the sidebar and the trash up to date.
pub fn wire(
window: &AppWindow,
ctl: Rc<DuplicatesController>,
library: Rc<LibraryController>,
after: Rc<dyn Fn(&AppWindow)>,
) {
let g = window.global::<Duplicates>();
{
let (weak, ctl, library) = (window.as_weak(), ctl.clone(), library.clone());
g.on_open(move || {
let Some(w) = weak.upgrade() else { return };
open(&w, &ctl, &library);
});
}
{
let (weak, ctl, library) = (window.as_weak(), ctl.clone(), library.clone());
g.on_close(move || {
let Some(w) = weak.upgrade() else { return };
// A running job carries on and reports to the register; the
// page can be opened again to watch it.
w.set_active_page(Page::None);
if !ctl.running.get() {
ctl.thumbs.borrow_mut().clear();
if let Some(cat) = library.catalog().borrow().as_ref() {
refresh_count(&w, cat);
}
}
});
}
{
let (weak, ctl, library) = (window.as_weak(), ctl.clone(), library.clone());
g.on_check(move || {
let Some(w) = weak.upgrade() else { return };
start_check(&w, &ctl, &library);
});
}
{
let ctl = ctl.clone();
g.on_stop(move || {
ctl.stop.borrow().store(true, Ordering::Relaxed);
});
}
{
let (weak, ctl, library, after) =
(window.as_weak(), ctl.clone(), library.clone(), after.clone());
g.on_confirm(move || {
let Some(w) = weak.upgrade() else { return };
start_consolidate(&w, &ctl, &library, after.clone());
});
}
{
let (weak, ctl, library) = (window.as_weak(), ctl.clone(), library.clone());
g.on_prev_page(move || {
let Some(w) = weak.upgrade() else { return };
ctl.page.set(ctl.page.get().saturating_sub(1));
ctl.thumbs.borrow_mut().clear();
show(&w, &ctl, &library);
});
}
{
let (weak, ctl, library) = (window.as_weak(), ctl.clone(), library.clone());
g.on_next_page(move || {
let Some(w) = weak.upgrade() else { return };
let last = ctl.review.borrow().len().saturating_sub(1) / PAGE;
ctl.page.set((ctl.page.get() + 1).min(last));
ctl.thumbs.borrow_mut().clear();
show(&w, &ctl, &library);
});
}
{
let (weak, ctl, library) = (window.as_weak(), ctl.clone(), library.clone());
g.on_pick_survivor(move |row, copy| {
let Some(w) = weak.upgrade() else { return };
if ctl.running.get() {
return;
}
let index = ctl.page.get() * PAGE + row.max(0) as usize;
{
let mut review = ctl.review.borrow_mut();
let Some(r) = review.get_mut(index) else { return };
if (copy as usize) < r.group.copies.len() {
r.survivor = copy as usize;
}
}
show(&w, &ctl, &library);
});
}
{
let (weak, ctl, library) = (window.as_weak(), ctl.clone(), library.clone());
g.on_toggle(move |row, on| {
let Some(w) = weak.upgrade() else { return };
if ctl.running.get() {
return;
}
let index = ctl.page.get() * PAGE + row.max(0) as usize;
if let Some(r) = ctl.review.borrow_mut().get_mut(index) {
r.include = on;
}
show(&w, &ctl, &library);
});
}
}
/// Open the page on a fresh read of the groups.
///
/// What was decided about a group that is still the same group — its check,
/// the copy chosen to stay, whether it is in — is carried over, so leaving
/// the page and coming back loses nothing.
fn open(window: &AppWindow, ctl: &Rc<DuplicatesController>, library: &Rc<LibraryController>) {
window.set_active_page(Page::Duplicates);
if ctl.running.get() {
show(window, ctl, library);
return;
}
let groups = {
let catalog = library.catalog();
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else {
window
.global::<Duplicates>()
.set_error("Open a library first.".into());
return;
};
refresh_count(window, cat);
match dr_catalog::duplicates::candidates(cat.connection()) {
Ok(g) => g,
Err(e) => {
window
.global::<Duplicates>()
.set_error(format!("Reading duplicates: {e}").into());
return;
}
}
};
let before: HashMap<Vec<ImageId>, Reviewed> = ctl
.review
.borrow_mut()
.drain(..)
.map(|r| (ids(&r), r))
.collect();
let review: Vec<Reviewed> = groups
.into_iter()
.map(|g| {
let fresh = Reviewed::new(g);
match before.get(&ids(&fresh)) {
Some(old) => Reviewed {
group: fresh.group,
survivor: old.survivor,
include: old.include,
check: old.check.clone(),
},
None => fresh,
}
})
.collect();
*ctl.review.borrow_mut() = review;
ctl.ended.borrow_mut().clear();
ctl.thumbs.borrow_mut().clear();
ctl.page.set(0);
window.global::<Duplicates>().set_error("".into());
show(window, ctl, library);
}
fn ids(r: &Reviewed) -> Vec<ImageId> {
r.group.copies.iter().map(|c| c.image).collect()
}
/// Megabytes or gigabytes, as a transfer is quoted.
fn quote_bytes(bytes: u64) -> String {
let mb = bytes as f64 / (1024.0 * 1024.0);
if mb >= 1024.0 {
format!("{:.1} GB", mb / 1024.0)
} else {
format!("{mb:.0} MB")
}
}
fn plural(n: usize, one: &str, many: &str) -> String {
format!("{n} {}", if n == 1 { one } else { many })
}
/// Redraw the page from what the controller holds.
fn show(window: &AppWindow, ctl: &Rc<DuplicatesController>, library: &Rc<LibraryController>) {
let g = window.global::<Duplicates>();
let review = ctl.review.borrow();
let summary = dups::summarise(&review);
let total = review.len();
// The dry run, and what the check would cost.
let text = if total == 0 {
"No duplicate originals: every file is in the library once.".to_string()
} else if summary.unchecked == total {
format!(
"{} of the same file in more than one folder, {} spare. \
Check them before anything moves.",
plural(total, "group", "groups"),
plural(
review.iter().map(|r| r.group.copies.len() - 1).sum(),
"copy",
"copies"
),
)
} else {
let mut t = format!(
"{}, {} to trash, {} skipped",
plural(summary.groups, "group", "groups"),
plural(summary.files, "file", "files"),
summary.skipped
);
if summary.excluded > 0 {
t.push_str(&format!(", {} left out", summary.excluded));
}
if summary.unchecked > 0 {
t.push_str(&format!(", {} not checked yet", summary.unchecked));
}
t.push('.');
t
};
g.set_summary(text.into());
let unread: Vec<&Reviewed> = review
.iter()
.filter(|r| r.check == Check::Unchecked)
.collect();
let to_read: usize = unread
.iter()
.filter(|r| !r.group.copies.iter().all(|c| c.content_hash.is_some()))
.flat_map(|r| r.group.copies.iter())
.filter(|c| c.probe.is_none())
.map(|c| c.file_size.min(2 * dr_catalog::duplicates::PROBE_WINDOW) as usize)
.sum();
g.set_can_check(!unread.is_empty() && !ctl.running.get());
g.set_check_label(format!("Check {}", plural(unread.len(), "group", "groups")).into());
g.set_check_cost(if unread.is_empty() {
"".into()
} else if to_read == 0 {
"The check reads each copy's sidecar; the files were read before.".into()
} else {
format!(
"The check reads the first and last megabyte of each copy not read \
before — about {} — and each copy's sidecar. What it reads is kept, \
so a second check costs nothing.",
quote_bytes(to_read as u64)
)
.into()
});
g.set_can_confirm(summary.files > 0 && !ctl.running.get());
g.set_confirm_label(
if summary.files == 1 {
"Move 1 copy to trash".to_string()
} else {
format!("Move {} copies to trash", summary.files)
}
.into(),
);
// The page.
let pages = total.div_ceil(PAGE).max(1);
let page = ctl.page.get().min(pages - 1);
let first = page * PAGE;
let last = (first + PAGE).min(total);
g.set_has_prev(page > 0);
g.set_has_next(page + 1 < pages);
g.set_page_label(if total == 0 {
"".into()
} else {
format!("Groups {}–{} of {}", first + 1, last, total).into()
});
let catalog = library.catalog();
let borrow = catalog.borrow();
let store = library
.session()
.and_then(|c| dr_thumbs::ThumbStore::open(&crate::library::thumbs_dir(&c.account)).ok());
let ended = ctl.ended.borrow();
let mut rows = Vec::with_capacity(last - first);
for (index, r) in review.iter().enumerate().take(last).skip(first) {
let (thumb, has_thumb) = thumbnail(ctl, store.as_ref(), r);
let done = matches!(ended.get(&index), Some(Ended::Done(_)));
let (status, warn) = match (ended.get(&index), &r.check) {
(Some(Ended::Done(s)), _) => (s.clone(), false),
(Some(Ended::Failed(s)), _) => (format!("Left as it was: {s}"), true),
(None, Check::Unchecked) => ("Not checked yet".to_string(), false),
(None, Check::Skip(why)) => (format!("Skipped: {why}"), true),
(None, Check::Same(edits)) => (
if edits.edited.iter().any(|e| *e) {
"The same file, with one edit between them".to_string()
} else {
"The same file".to_string()
},
false,
),
};
let detail = match (&r.check, borrow.as_ref()) {
(Check::Same(edits), Some(cat)) if r.include && !done => {
let survivor = r.group.copies[r.survivor].image;
let others: Vec<ImageId> = r
.group
.copies
.iter()
.enumerate()
.filter(|(i, _)| *i != r.survivor)
.map(|(_, c)| c.image)
.collect();
let carried = edits
.source_for(r.survivor)
.map(|i| r.group.copies[i].file_name().to_string());
match dr_catalog::duplicates::preview(cat.connection(), survivor, &others) {
Ok(o) => describe(&o, carried.as_deref()),
Err(e) => format!("Cannot preview the merge: {e}"),
}
}
_ => String::new(),
};
let title = format!(
"{} · {} · {:.1} MB",
r.group.camera,
dr_types::format_date(r.group.captured_at),
r.group.file_size as f64 / (1024.0 * 1024.0)
);
rows.push(DuplicateRow {
title: title.into(),
thumb,
has_thumb,
copies: ModelRc::new(VecModel::from(
r.group
.copies
.iter()
.enumerate()
.map(|(i, c)| DuplicateCopy {
path: c.source_ref.clone().into(),
survivor: i == r.survivor,
})
.collect::<Vec<_>>(),
)),
status: status.into(),
detail: detail.into(),
warn,
include: r.include,
can_include: matches!(r.check, Check::Same(_) | Check::Unchecked),
done,
});
}
g.set_rows(ModelRc::new(VecModel::from(rows)));
}
/// One picture for a group, from the first copy the thumbnail store holds.
fn thumbnail(
ctl: &Rc<DuplicatesController>,
store: Option<&dr_thumbs::ThumbStore>,
r: &Reviewed,
) -> (slint::Image, bool) {
let mut thumbs = ctl.thumbs.borrow_mut();
for id in r.group.copies.iter().filter_map(|c| c.file_id) {
if let Some(img) = thumbs.get(&id) {
return (img.clone(), true);
}
}
let Some(store) = store else {
return (slint::Image::default(), false);
};
for id in r.group.copies.iter().filter_map(|c| c.file_id) {
let Ok(Some(t)) = store.get(id, dr_thumbs::ThumbSize::Grid) else {
continue;
};
let Ok((w, h, rgba)) = dr_thumbs::codec::decode_rgba(&t.bytes) else {
continue;
};
let mut buf = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::new(w, h);
let n = buf.make_mut_bytes().len().min(rgba.len());
buf.make_mut_bytes()[..n].copy_from_slice(&rgba[..n]);
let img = slint::Image::from_rgba8(buf);
thumbs.insert(id, img.clone());
return (img, true);
}
(slint::Image::default(), false)
}
/// What a consolidation brings to the survivor, in words.
pub fn describe(o: &Outcome, carried_edit: Option<&str>) -> String {
let mut parts: Vec<String> = Vec::new();
if o.rating.1 != o.rating.0 {
parts.push(format!("rating {}★ (was {}★)", o.rating.1, o.rating.0));
}
if o.flag.1 != o.flag.0 {
parts.push(
match o.flag.1 {
1 => "the pick flag",
2 => "the reject flag",
_ => "no flag",
}
.to_string(),
);
}
if o.label.1 != o.label.0 {
parts.push("a colour label".into());
}
if o.keywords_added > 0 {
parts.push(plural(o.keywords_added, "keyword", "keywords"));
}
if o.collections_joined > 0 {
parts.push(plural(o.collections_joined, "collection", "collections"));
}
if o.faces.moved > 0 {
parts.push(plural(o.faces.moved, "face", "faces"));
}
if o.faces.named > 0 {
parts.push(plural(o.faces.named, "name", "names"));
}
if let Some(from) = carried_edit {
parts.push(format!("the edit from {from}"));
}
let mut out = if parts.is_empty() {
"Nothing to merge: the copy that stays already has it all.".to_string()
} else {
format!("The copy that stays gains {}.", parts.join(", "))
};
let mut conflicts = Vec::new();
if o.flag_conflict {
conflicts.push("flags");
}
if o.label_conflict {
conflicts.push("colour labels");
}
if !conflicts.is_empty() {
out.push_str(&format!(
" The copies disagree on {}; the one that stays keeps its own.",
conflicts.join(" and ")
));
}
if o.faces.conflicts > 0 {
out.push_str(&format!(
" {} named differently; the one that stays keeps its names.",
plural(o.faces.conflicts, "face is", "faces are")
));
}
if o.faces.unmatched_named > 0 {
out.push_str(&format!(
" {} stay with the copies in the trash.",
plural(o.faces.unmatched_named, "named face", "named faces")
));
}
out
}
fn start_check(window: &AppWindow, ctl: &Rc<DuplicatesController>, library: &Rc<LibraryController>) {
if ctl.running.get() {
return;
}
let Some(conn) = library.session() else {
return;
};
let Some(cache_dir) = library.sidecar_cache_dir() else {
return;
};
let groups: Vec<(usize, dr_catalog::duplicates::Group)> = ctl
.review
.borrow()
.iter()
.enumerate()
.filter(|(_, r)| r.check == Check::Unchecked)
.map(|(i, r)| (i, r.group.clone()))
.collect();
if groups.is_empty() {
return;
}
let stop = Arc::new(AtomicBool::new(false));
*ctl.stop.borrow_mut() = stop.clone();
let count = groups.len();
let rx = dups::spawn_check(
conn.clone(),
crate::library::catalog_path(&conn.account),
cache_dir,
groups,
stop,
);
let job = ctl.activity.begin(
crate::activity::Kind::Download,
format!("Checking {}", plural(count, "duplicate group", "duplicate groups")),
);
ctl.consolidating.set(false);
ctl.counts.set((0, 0, 0));
drain(window, ctl, library, rx, job, None);
}
fn start_consolidate(
window: &AppWindow,
ctl: &Rc<DuplicatesController>,
library: &Rc<LibraryController>,
after: Rc<dyn Fn(&AppWindow)>,
) {
if ctl.running.get() {
return;
}
let Some(conn) = library.session() else {
return;
};
let Some(cache_dir) = library.sidecar_cache_dir() else {
return;
};
let plans = {
let catalog = library.catalog();
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
match dups::plan(cat, &conn.account.root, &ctl.review.borrow()) {
Ok(p) => p,
Err(e) => {
window
.global::<Duplicates>()
.set_error(format!("Planning: {e}").into());
return;
}
}
};
if plans.is_empty() {
return;
}
let files: usize = plans.iter().map(|p| p.copies.len()).sum();
log::info!(
"duplicates: consolidating {} group(s), {files} copies to the trash",
plans.len()
);
let stop = Arc::new(AtomicBool::new(false));
*ctl.stop.borrow_mut() = stop.clone();
let rx = dups::spawn_consolidate(
conn.clone(),
crate::library::catalog_path(&conn.account),
cache_dir,
plans,
stop,
);
let job = ctl.activity.begin(
crate::activity::Kind::Trash,
format!(
"Moving {} to the trash",
plural(files, "duplicate copy", "duplicate copies")
),
);
ctl.consolidating.set(true);
ctl.survivors.borrow_mut().clear();
ctl.counts.set((0, 0, 0));
window
.global::<Library>()
.set_library_status(format!("Moving {files} duplicate copies to the trash…").into());
drain(window, ctl, library, rx, job, Some(after));
}
/// Take a worker's reports on the UI thread, as every job here does: a
/// channel polled by a timer, so nothing waits on a file.
fn drain(
window: &AppWindow,
ctl: &Rc<DuplicatesController>,
library: &Rc<LibraryController>,
rx: Receiver<DupMessage>,
job: Activity,
after: Option<Rc<dyn Fn(&AppWindow)>>,
) {
ctl.running.set(true);
let g = window.global::<Duplicates>();
g.set_running(true);
g.set_fraction(0.0);
g.set_progress_label(
if ctl.consolidating.get() {
"Consolidating…"
} else {
"Checking…"
}
.into(),
);
show(window, ctl, library);
let (weak, ctl_cb, library) = (window.as_weak(), ctl.clone(), library.clone());
let timer = slint::Timer::default();
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(150),
move || {
let Some(w) = weak.upgrade() else { return };
let ctl = &ctl_cb;
let mut changed = false;
loop {
let msg = match rx.try_recv() {
Ok(m) => m,
Err(std::sync::mpsc::TryRecvError::Empty) => break,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
finish(&w, ctl, &library, &job, Some("stopped without finishing".into()), after.as_ref());
return;
}
};
changed = true;
match msg {
DupMessage::Checked { index, check } => {
if let Some(r) = ctl.review.borrow_mut().get_mut(index) {
r.check = check;
}
}
DupMessage::Consolidated {
index,
survivor,
outcome,
} => {
ctl.survivors.borrow_mut().push(survivor);
let (ok, files, bad) = ctl.counts.get();
ctl.counts.set((ok + 1, files + outcome.trashed, bad));
ctl.ended.borrow_mut().insert(
index,
Ended::Done(format!(
"Consolidated: {} in the trash",
plural(outcome.trashed, "copy", "copies")
)),
);
}
DupMessage::Failed { index, reason } => {
let (ok, files, bad) = ctl.counts.get();
ctl.counts.set((ok, files, bad + 1));
ctl.ended.borrow_mut().insert(index, Ended::Failed(reason));
}
DupMessage::Progress { done, total } => {
job.progress(done, total);
let g = w.global::<Duplicates>();
g.set_fraction(done as f32 / total.max(1) as f32);
g.set_progress_label(
format!(
"{} {done} of {total} groups",
if ctl.consolidating.get() {
"Consolidated"
} else {
"Checked"
}
)
.into(),
);
if ctl.consolidating.get() {
let (_, files, _) = ctl.counts.get();
w.global::<Library>().set_library_status(
format!("Duplicates: {done} of {total} groups · {files} copies in the trash")
.into(),
);
}
}
DupMessage::Finished { stopped } => {
finish(&w, ctl, &library, &job, stopped, after.as_ref());
return;
}
}
}
// One redraw per tick, and only of the page on screen.
if changed && w.get_active_page() == Page::Duplicates {
show(&w, ctl, &library);
}
},
);
*ctl.timer.borrow_mut() = Some(timer);
}
fn finish(
w: &AppWindow,
ctl: &Rc<DuplicatesController>,
library: &Rc<LibraryController>,
job: &Activity,
stopped: Option<String>,
after: Option<&Rc<dyn Fn(&AppWindow)>>,
) {
ctl.running.set(false);
let g = w.global::<Duplicates>();
g.set_running(false);
if ctl.consolidating.get() {
let (ok, files, bad) = ctl.counts.get();
let skipped = dups::summarise(&ctl.review.borrow()).skipped;
let mut status = format!(
"Duplicates: {} consolidated, {} in the trash, {} skipped",
plural(ok, "group", "groups"),
plural(files, "copy", "copies"),
skipped + bad
);
if let Some(why) = &stopped {
status.push_str(&format!(" · {why}"));
}
if bad > 0 || stopped.is_some() {
job.fail(status.clone());
} else {
job.finish(status.clone());
}
w.global::<Library>().set_library_status(status.into());
// The consolidated groups leave the review: they are no longer
// duplicates, and their rows would only offer to do it again. A
// failed group stays, with its reason, under its new index.
{
let old_ended = std::mem::take(&mut *ctl.ended.borrow_mut());
let old = std::mem::take(&mut *ctl.review.borrow_mut());
let mut review = Vec::with_capacity(old.len());
let mut ended = HashMap::new();
for (i, r) in old.into_iter().enumerate() {
match old_ended.get(&i) {
Some(Ended::Done(_)) => continue,
Some(e) => {
ended.insert(review.len(), e.clone());
}
None => {}
}
review.push(r);
}
*ctl.review.borrow_mut() = review;
*ctl.ended.borrow_mut() = ended;
}
let survivors = std::mem::take(&mut *ctl.survivors.borrow_mut());
crate::library_ui::save_judgements(w, library, &survivors);
if let Some(after) = after {
after(w);
}
if let Some(cat) = library.catalog().borrow().as_ref() {
refresh_count(w, cat);
}
} else {
let s = dups::summarise(&ctl.review.borrow());
let note = format!(
"{} the same, {} skipped",
plural(s.groups + s.excluded, "group", "groups"),
s.skipped
);
match &stopped {
Some(why) => {
job.fail(format!("{note} · {why}"));
g.set_error(why.as_str().into());
}
None => job.finish(note),
}
}
if let Some(t) = ctl.timer.borrow().as_ref() {
t.stop();
}
ctl.thumbs.borrow_mut().clear();
show(w, ctl, library);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_merge_is_described_in_words_with_its_conflicts() {
let mut o = Outcome {
rating: (2, 4),
keywords_added: 1,
collections_joined: 2,
flag_conflict: true,
..Default::default()
};
o.faces.named = 1;
let s = describe(&o, Some("_MG_4623.CR2"));
assert!(s.contains("rating 4★ (was 2★)"), "{s}");
assert!(s.contains("1 keyword,"), "{s}");
assert!(s.contains("2 collections"), "{s}");
assert!(s.contains("1 name"), "{s}");
assert!(s.contains("the edit from _MG_4623.CR2"), "{s}");
assert!(s.contains("disagree on flags"), "{s}");
}
#[test]
fn nothing_to_merge_says_so() {
let s = describe(&Outcome::default(), None);
assert!(s.starts_with("Nothing to merge"), "{s}");
}
#[test]
fn transfers_are_quoted_in_the_unit_a_person_reads() {
assert_eq!(quote_bytes(10 * 1024 * 1024 * 1024), "10.0 GB");
assert_eq!(quote_bytes(6 * 1024 * 1024), "6 MB");
}
}
+33
View File
@@ -27,6 +27,7 @@ mod collections_ui;
#[cfg(test)]
mod decoder_seam;
mod duplicates;
mod duplicates_ui;
mod derived_sync;
mod develop;
mod develop_ui;
@@ -1816,6 +1817,30 @@ fn wire_import_and_merge(
// through (`dr_plat::imports_supported`).
window.set_import_supported(dr_plat::imports_supported());
// TRACES: FR-CAT-11a
// The duplicate originals review. What a consolidation changes is the
// grid (copies leave it), the sidebar's collections and trash, and the
// count the sidebar and settings show — so that is what it refreshes.
{
let duplicates = duplicates_ui::DuplicatesController::new(activity.clone());
let lib = library.clone();
let coll = collections.clone();
duplicates_ui::wire(
window,
duplicates,
library.clone(),
Rc::new(move |w: &AppWindow| {
library_ui::reload(w, &lib);
let catalog = lib.catalog();
let borrow = catalog.borrow();
if let Some(cat) = borrow.as_ref() {
collections_ui::refresh_tree(w, &coll, cat);
collections_ui::refresh_trash(w, cat);
}
}),
);
}
// TRACES: FR-MRG-1
{
let merge = merge_ui::MergeController::new(activity.clone());
@@ -3418,6 +3443,14 @@ fn back_one_step(w: &AppWindow) -> bool {
return true;
}
// TRACES: FR-CAT-11a | FR-UI-5
// The duplicates review is a page over whatever view opened it, and its
// own Back button knows how to leave it; back takes the same route.
if w.get_active_page() == Page::Duplicates {
w.global::<Duplicates>().invoke_close();
return true;
}
let state = NavState {
settings: w.get_active_page() == Page::Settings,
launch: w.get_active_view() == View::Launch,
+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