Count duplicate originals once the sweep has dated them

A copy becomes a duplicate only once its capture time is read, and on a
fresh library that is the sweep, not the scan: the sidebar row stayed
hidden until the next launch. The count is refreshed when a sweep that
dated anything finishes.

Also says on a group left out of the plan that nothing will move, drops
an unused method, and names the review's completion callback type for
clippy.
This commit is contained in:
2026-09-26 07:19:13 -04:00
parent 220e9af222
commit 54aee50539
4 changed files with 83 additions and 30 deletions
+42 -15
View File
@@ -139,7 +139,10 @@ pub fn summarise(review: &[Reviewed]) -> Summary {
#[derive(Debug)] #[derive(Debug)]
pub enum DupMessage { pub enum DupMessage {
/// One group checked, by its index in the review. /// One group checked, by its index in the review.
Checked { index: usize, check: Check }, Checked {
index: usize,
check: Check,
},
/// One group consolidated. /// One group consolidated.
Consolidated { Consolidated {
index: usize, index: usize,
@@ -147,11 +150,19 @@ pub enum DupMessage {
outcome: Outcome, outcome: Outcome,
}, },
/// One group could not be consolidated and was left as it was. /// One group could not be consolidated and was left as it was.
Failed { index: usize, reason: String }, Failed {
Progress { done: usize, total: usize }, index: usize,
reason: String,
},
Progress {
done: usize,
total: usize,
},
/// The job ended. `stopped` when the server went away or the review /// The job ended. `stopped` when the server went away or the review
/// was closed. /// was closed.
Finished { stopped: Option<String> }, Finished {
stopped: Option<String>,
},
} }
// ── the edit ───────────────────────────────────────────────────────────── // ── the edit ─────────────────────────────────────────────────────────────
@@ -748,8 +759,7 @@ pub fn spawn_consolidate(
) -> Receiver<DupMessage> { ) -> Receiver<DupMessage> {
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || { std::thread::spawn(move || {
let stopped = let stopped = run_consolidate(&conn, &catalog_path, cache_dir, plans, &stop, &tx).err();
run_consolidate(&conn, &catalog_path, cache_dir, plans, &stop, &tx).err();
let _ = tx.send(DupMessage::Finished { stopped }); let _ = tx.send(DupMessage::Finished { stopped });
}); });
rx rx
@@ -922,7 +932,6 @@ mod tests {
// ── end to end, on a folder library ──────────────────────────────── // ── end to end, on a folder library ────────────────────────────────
/// A folder library holding real files, and a catalog describing them. /// A folder library holding real files, and a catalog describing them.
struct Library { struct Library {
dir: PathBuf, dir: PathBuf,
@@ -1092,7 +1101,11 @@ mod tests {
assert_eq!(review.len(), 3); assert_eq!(review.len(), 3);
lib.check(&mut review); lib.check(&mut review);
assert!(matches!(review[0].check, Check::Same(_)), "{:?}", review[0].check); assert!(
matches!(review[0].check, Check::Same(_)),
"{:?}",
review[0].check
);
assert_eq!( assert_eq!(
review[1].check, review[1].check,
Check::Skip("The files differ in their first or last megabyte".into()) Check::Skip("The files differ in their first or last megabyte".into())
@@ -1114,7 +1127,11 @@ mod tests {
); );
// What was read is kept: a second review needs no bytes. // What was read is kept: a second review needs no bytes.
assert!(lib.review()[0].group.copies.iter().all(|c| c.probe.is_some())); assert!(lib.review()[0]
.group
.copies
.iter()
.all(|c| c.probe.is_some()));
let (done, failed) = lib.consolidate(&review); let (done, failed) = lib.consolidate(&review);
assert_eq!((done, failed), (1, Vec::<String>::new())); assert_eq!((done, failed), (1, Vec::<String>::new()));
@@ -1137,9 +1154,8 @@ mod tests {
assert_eq!(trashed(&cat), 2); assert_eq!(trashed(&cat), 2);
// The edit the backup copy carried now lives beside the survivor. // The edit the backup copy carried now lives beside the survivor.
let carried = std::fs::read_to_string( let carried =
lib.lib.join(sidecar_path("2023/2023-06-24/_MG_4623.CR2")), std::fs::read_to_string(lib.lib.join(sidecar_path("2023/2023-06-24/_MG_4623.CR2")))
)
.unwrap(); .unwrap();
let carried = Sidecar::parse(&carried).unwrap(); let carried = Sidecar::parse(&carried).unwrap();
assert_eq!( assert_eq!(
@@ -1165,7 +1181,10 @@ mod tests {
assert_eq!(restored, 2); assert_eq!(restored, 2);
assert!(lib.exists("2023/bck/_MG_4623.CR2")); assert!(lib.exists("2023/bck/_MG_4623.CR2"));
assert!(lib.exists("alps trip/Raw/20230628_0642.CR2")); assert!(lib.exists("alps trip/Raw/20230628_0642.CR2"));
assert_eq!(std::fs::read(lib.lib.join("2023/bck/_MG_4623.CR2")).unwrap(), frame); assert_eq!(
std::fs::read(lib.lib.join("2023/bck/_MG_4623.CR2")).unwrap(),
frame
);
assert_eq!(trashed(&lib.catalog()), 0); assert_eq!(trashed(&lib.catalog()), 0);
} }
@@ -1193,7 +1212,11 @@ mod tests {
assert_eq!(done, 0); assert_eq!(done, 0);
assert_eq!(failed.len(), 1, "{failed:?}"); assert_eq!(failed.len(), 1, "{failed:?}");
for path in ["2023/bck/IMG_0001.CR2", "2023/a/IMG_0001.CR2", "2023/b/IMG_0001-2.CR2"] { for path in [
"2023/bck/IMG_0001.CR2",
"2023/a/IMG_0001.CR2",
"2023/b/IMG_0001-2.CR2",
] {
assert!(lib.exists(path), "{path} is back where it was"); assert!(lib.exists(path), "{path} is back where it was");
} }
assert!(!lib.exists(".darkroom-trash/1-IMG_0001.CR2")); assert!(!lib.exists(".darkroom-trash/1-IMG_0001.CR2"));
@@ -1217,7 +1240,11 @@ mod tests {
let mut review = lib.review(); let mut review = lib.review();
lib.check(&mut review); lib.check(&mut review);
assert!(matches!(review[0].check, Check::Same(_)), "{:?}", review[0].check); assert!(
matches!(review[0].check, Check::Same(_)),
"{:?}",
review[0].check
);
let (done, failed) = lib.consolidate(&review); let (done, failed) = lib.consolidate(&review);
assert_eq!((done, failed), (1, Vec::<String>::new())); assert_eq!((done, failed), (1, Vec::<String>::new()));
assert_eq!(trashed(&lib.catalog()), 1); assert_eq!(trashed(&lib.catalog()), 1);
+31 -12
View File
@@ -29,6 +29,10 @@ use crate::{AppWindow, DuplicateCopy, DuplicateRow, Duplicates, Library, Page};
/// of the library. /// of the library.
pub const PAGE: usize = 30; pub const PAGE: usize = 30;
/// What runs when a consolidation ends: the grid, the sidebar and the trash
/// brought up to date.
pub type After = Rc<dyn Fn(&AppWindow)>;
/// How a group ended in this session. /// How a group ended in this session.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
enum Ended { enum Ended {
@@ -79,9 +83,7 @@ impl DuplicatesController {
/// can change the answer. /// can change the answer.
pub fn refresh_count(window: &AppWindow, catalog: &Catalog) { pub fn refresh_count(window: &AppWindow, catalog: &Catalog) {
let (groups, _) = dr_catalog::duplicates::count(catalog.connection()).unwrap_or((0, 0)); let (groups, _) = dr_catalog::duplicates::count(catalog.connection()).unwrap_or((0, 0));
window window.global::<Duplicates>().set_group_count(groups as i32);
.global::<Duplicates>()
.set_group_count(groups as i32);
} }
/// Wire the page. `after` runs when a consolidation ends, to bring the grid, /// Wire the page. `after` runs when a consolidation ends, to bring the grid,
@@ -90,7 +92,7 @@ pub fn wire(
window: &AppWindow, window: &AppWindow,
ctl: Rc<DuplicatesController>, ctl: Rc<DuplicatesController>,
library: Rc<LibraryController>, library: Rc<LibraryController>,
after: Rc<dyn Fn(&AppWindow)>, after: After,
) { ) {
let g = window.global::<Duplicates>(); let g = window.global::<Duplicates>();
@@ -130,8 +132,12 @@ pub fn wire(
}); });
} }
{ {
let (weak, ctl, library, after) = let (weak, ctl, library, after) = (
(window.as_weak(), ctl.clone(), library.clone(), after.clone()); window.as_weak(),
ctl.clone(),
library.clone(),
after.clone(),
);
g.on_confirm(move || { g.on_confirm(move || {
let Some(w) = weak.upgrade() else { return }; let Some(w) = weak.upgrade() else { return };
start_consolidate(&w, &ctl, &library, after.clone()); start_consolidate(&w, &ctl, &library, after.clone());
@@ -166,7 +172,9 @@ pub fn wire(
let index = ctl.page.get() * PAGE + row.max(0) as usize; let index = ctl.page.get() * PAGE + row.max(0) as usize;
{ {
let mut review = ctl.review.borrow_mut(); let mut review = ctl.review.borrow_mut();
let Some(r) = review.get_mut(index) else { return }; let Some(r) = review.get_mut(index) else {
return;
};
if (copy as usize) < r.group.copies.len() { if (copy as usize) < r.group.copies.len() {
r.survivor = copy as usize; r.survivor = copy as usize;
} }
@@ -372,6 +380,10 @@ fn show(window: &AppWindow, ctl: &Rc<DuplicatesController>, library: &Rc<Library
(Some(Ended::Failed(s)), _) => (format!("Left as it was: {s}"), true), (Some(Ended::Failed(s)), _) => (format!("Left as it was: {s}"), true),
(None, Check::Unchecked) => ("Not checked yet".to_string(), false), (None, Check::Unchecked) => ("Not checked yet".to_string(), false),
(None, Check::Skip(why)) => (format!("Skipped: {why}"), true), (None, Check::Skip(why)) => (format!("Skipped: {why}"), true),
(None, Check::Same(_)) if !r.include => (
"The same file · left out: nothing will move".to_string(),
false,
),
(None, Check::Same(edits)) => ( (None, Check::Same(edits)) => (
if edits.edited.iter().any(|e| *e) { if edits.edited.iter().any(|e| *e) {
"The same file, with one edit between them".to_string() "The same file, with one edit between them".to_string()
@@ -533,7 +545,11 @@ pub fn describe(o: &Outcome, carried_edit: Option<&str>) -> String {
out out
} }
fn start_check(window: &AppWindow, ctl: &Rc<DuplicatesController>, library: &Rc<LibraryController>) { fn start_check(
window: &AppWindow,
ctl: &Rc<DuplicatesController>,
library: &Rc<LibraryController>,
) {
if ctl.running.get() { if ctl.running.get() {
return; return;
} }
@@ -566,7 +582,10 @@ fn start_check(window: &AppWindow, ctl: &Rc<DuplicatesController>, library: &Rc<
); );
let job = ctl.activity.begin( let job = ctl.activity.begin(
crate::activity::Kind::Download, crate::activity::Kind::Download,
format!("Checking {}", plural(count, "duplicate group", "duplicate groups")), format!(
"Checking {}",
plural(count, "duplicate group", "duplicate groups")
),
); );
ctl.consolidating.set(false); ctl.consolidating.set(false);
ctl.counts.set((0, 0, 0)); ctl.counts.set((0, 0, 0));
@@ -577,7 +596,7 @@ fn start_consolidate(
window: &AppWindow, window: &AppWindow,
ctl: &Rc<DuplicatesController>, ctl: &Rc<DuplicatesController>,
library: &Rc<LibraryController>, library: &Rc<LibraryController>,
after: Rc<dyn Fn(&AppWindow)>, after: After,
) { ) {
if ctl.running.get() { if ctl.running.get() {
return; return;
@@ -643,7 +662,7 @@ fn drain(
library: &Rc<LibraryController>, library: &Rc<LibraryController>,
rx: Receiver<DupMessage>, rx: Receiver<DupMessage>,
job: Activity, job: Activity,
after: Option<Rc<dyn Fn(&AppWindow)>>, after: Option<After>,
) { ) {
ctl.running.set(true); ctl.running.set(true);
let g = window.global::<Duplicates>(); let g = window.global::<Duplicates>();
@@ -749,7 +768,7 @@ fn finish(
library: &Rc<LibraryController>, library: &Rc<LibraryController>,
job: &Activity, job: &Activity,
stopped: Option<String>, stopped: Option<String>,
after: Option<&Rc<dyn Fn(&AppWindow)>>, after: Option<&After>,
) { ) {
ctl.running.set(false); ctl.running.set(false);
let g = w.global::<Duplicates>(); let g = w.global::<Duplicates>();
+2 -2
View File
@@ -26,12 +26,12 @@ mod bursts;
mod collections_ui; mod collections_ui;
#[cfg(test)] #[cfg(test)]
mod decoder_seam; mod decoder_seam;
mod duplicates;
mod duplicates_ui;
mod derived_sync; mod derived_sync;
mod develop; mod develop;
mod develop_ui; mod develop_ui;
mod display_ui; mod display_ui;
mod duplicates;
mod duplicates_ui;
mod export; mod export;
pub mod faces; pub mod faces;
pub use library::render_native; pub use library::render_native;
+7
View File
@@ -357,6 +357,13 @@ pub(super) fn start_sweep(window: &AppWindow, ctl: &Rc<LibraryController>) {
let borrow = ctl_cb.catalog.borrow(); let borrow = ctl_cb.catalog.borrow();
if let Some(catalog) = borrow.as_ref() { if let Some(catalog) = borrow.as_ref() {
refresh_timeline(&w, catalog, &ctl_cb); refresh_timeline(&w, catalog, &ctl_cb);
// A capture time is half of what makes two
// files one photograph, and the sweep is
// what reads it: a copy is a duplicate only
// once it is dated.
if dated > 0 {
crate::duplicates_ui::refresh_count(&w, catalog);
}
} }
} }
// TRACES: NFR-R2 // TRACES: NFR-R2