From 220e9af2227113c4f96baf11b25091c14d45d00e Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Fri, 25 Sep 2026 23:20:07 -0400 Subject: [PATCH] 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. --- ui/dr-ui/src/collections_ui/mod.rs | 1 + ui/dr-ui/src/collections_ui/trash.rs | 3 + ui/dr-ui/src/duplicates.rs | 27 +- ui/dr-ui/src/duplicates_ui.rs | 863 ++++++++++++++++++++ ui/dr-ui/src/lib.rs | 33 + ui/dr-ui/src/library_ui/mod.rs | 2 +- ui/dr-ui/src/library_ui/open.rs | 3 + ui/dr-ui/src/library_ui/ratings_keywords.rs | 28 + ui/dr-ui/ui/app.slint | 12 + ui/dr-ui/ui/collections.slint | 45 + ui/dr-ui/ui/duplicates.slint | 360 ++++++++ ui/dr-ui/ui/settings.slint | 45 + 12 files changed, 1413 insertions(+), 9 deletions(-) create mode 100644 ui/dr-ui/src/duplicates_ui.rs create mode 100644 ui/dr-ui/ui/duplicates.slint diff --git a/ui/dr-ui/src/collections_ui/mod.rs b/ui/dr-ui/src/collections_ui/mod.rs index d74da4f..decb6bb 100644 --- a/ui/dr-ui/src/collections_ui/mod.rs +++ b/ui/dr-ui/src/collections_ui/mod.rs @@ -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; diff --git a/ui/dr-ui/src/collections_ui/trash.rs b/ui/dr-ui/src/collections_ui/trash.rs index 5152890..e923341 100644 --- a/ui/dr-ui/src/collections_ui/trash.rs +++ b/ui/dr-ui/src/collections_ui/trash.rs @@ -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); diff --git a/ui/dr-ui/src/duplicates.rs b/ui/dr-ui/src/duplicates.rs index 9506d8b..43a4a59 100644 --- a/ui/dr-ui/src/duplicates.rs +++ b/ui/dr-ui/src/duplicates.rs @@ -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, ) -> Receiver { 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, ) -> 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, + stop: Arc, ) -> Receiver { 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, + stop: &AtomicBool, tx: &Sender, ) -> 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 { diff --git a/ui/dr-ui/src/duplicates_ui.rs b/ui/dr-ui/src/duplicates_ui.rs new file mode 100644 index 0000000..7376003 --- /dev/null +++ b/ui/dr-ui/src/duplicates_ui.rs @@ -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>, + page: Cell, + ended: RefCell>, + /// Pictures for the page on screen, by `oc:fileid`. + thumbs: RefCell>, + running: Cell, + consolidating: Cell, + stop: RefCell>, + timer: RefCell>, + activity: Rc, + /// Survivors consolidated by the running job, whose sidecars are written + /// when it ends. + survivors: RefCell>, + counts: Cell<(usize, usize, usize)>, +} + +impl DuplicatesController { + pub fn new(activity: Rc) -> Rc { + 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::() + .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, + library: Rc, + after: Rc, +) { + let g = window.global::(); + + { + 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, library: &Rc) { + 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::() + .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::() + .set_error(format!("Reading duplicates: {e}").into()); + return; + } + } + }; + + let before: HashMap, Reviewed> = ctl + .review + .borrow_mut() + .drain(..) + .map(|r| (ids(&r), r)) + .collect(); + let review: Vec = 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::().set_error("".into()); + show(window, ctl, library); +} + +fn ids(r: &Reviewed) -> Vec { + 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, library: &Rc) { + let g = window.global::(); + 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 = 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::>(), + )), + 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, + 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::::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 = 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, library: &Rc) { + 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, + library: &Rc, + after: Rc, +) { + 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::() + .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::() + .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, + library: &Rc, + rx: Receiver, + job: Activity, + after: Option>, +) { + ctl.running.set(true); + let g = window.global::(); + 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::(); + 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::().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, + library: &Rc, + job: &Activity, + stopped: Option, + after: Option<&Rc>, +) { + ctl.running.set(false); + let g = w.global::(); + 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::().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"); + } +} diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index e030212..7d421e0 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -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::().invoke_close(); + return true; + } + let state = NavState { settings: w.get_active_page() == Page::Settings, launch: w.get_active_view() == View::Launch, diff --git a/ui/dr-ui/src/library_ui/mod.rs b/ui/dr-ui/src/library_ui/mod.rs index ca22e93..40a2640 100644 --- a/ui/dr-ui/src/library_ui/mod.rs +++ b/ui/dr-ui/src/library_ui/mod.rs @@ -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; diff --git a/ui/dr-ui/src/library_ui/open.rs b/ui/dr-ui/src/library_ui/open.rs index 01d1583..fdfdde8 100644 --- a/ui/dr-ui/src/library_ui/open.rs +++ b/ui/dr-ui/src/library_ui/open.rs @@ -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 diff --git a/ui/dr-ui/src/library_ui/ratings_keywords.rs b/ui/dr-ui/src/library_ui/ratings_keywords.rs index 20074c2..69aaf33 100644 --- a/ui/dr-ui/src/library_ui/ratings_keywords.rs +++ b/ui/dr-ui/src/library_ui/ratings_keywords.rs @@ -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, + 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 diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index 551bf22..7060d99 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -20,6 +20,7 @@ import { FocusMarks, FocusPanel, Peaking } from "peaking.slint"; import { SettingsPage } from "settings.slint"; import { ImportPage } from "import.slint"; import { MergePage, MergeFrameRow } from "merge.slint"; +import { DuplicatesPage, Duplicates, DuplicateRow, DuplicateCopy } from "duplicates.slint"; import { Capture, StatusBar, InfoPanel } from "develop.slint"; import { ToolRail } from "toolrail.slint"; import { Keys } from "keys.slint"; @@ -36,6 +37,7 @@ export { Develop, Capture, Levels, Peaking, Adjustments, Framing, Transfer, Mask export { Collections } export { Library } export { ExportOptions } +export { Duplicates, DuplicateRow, DuplicateCopy } /// Which top-level screen is on display. Exactly one at a time — replacing /// the `show-launch` / `show-library` / `show-identity` booleans, with @@ -74,6 +76,9 @@ export enum Page { settings, import, merge, + /// TRACES: FR-CAT-11a + /// The review of duplicate originals. + duplicates, } export component AppWindow inherits Window { @@ -1145,6 +1150,11 @@ in property panel-visible: true; close() => { root.merge-close(); } } + if root.active-page == Page.duplicates: DuplicatesPage { + width: 100%; + height: 100%; + } + if root.active-page == Page.settings: SettingsPage { width: 100%; height: 100%; @@ -1216,6 +1226,8 @@ in property panel-visible: true; thumbnail-library() => { Library.library-thumbnail-all(); } index-faces() => { root.identity-index(); } reindex-faces() => { root.identity-reindex(); } + duplicate-groups: Duplicates.group-count; + find-duplicates() => { Duplicates.open(); } // TRACES: NFR-OPS-1 diagnostics-preview: root.diagnostics-preview; diff --git a/ui/dr-ui/ui/collections.slint b/ui/dr-ui/ui/collections.slint index 12ad5a6..8314360 100644 --- a/ui/dr-ui/ui/collections.slint +++ b/ui/dr-ui/ui/collections.slint @@ -32,6 +32,7 @@ // release rather than the drop being silently discarded after it. import { Theme } from "theme.slint"; +import { Duplicates } from "duplicates.slint"; import { Keys } from "keys.slint"; import { Button, Icon } from "widgets.slint"; @@ -865,6 +866,50 @@ export component CollectionsPanel inherits Rectangle { clicked => { root.empty-trash(); } } + // TRACES: FR-CAT-11a + // GESTURE: Review duplicate originals + // where: Collections sidebar + // touch: Tap "Duplicate originals" under the trash + // pointer: Click "Duplicate originals" under the trash + // why: Under the trash because the trash is where the spare + // copies go, and only while the catalog holds any: a row + // that is always there and usually empty is noise. + // manual: duplicate-originals + if Duplicates.group-count > 0: Rectangle { + height: Theme.row-height; + background: dup-touch.has-hover ? Theme.hover : transparent; + border-radius: Theme.radius-sm; + + accessible-role: button; + accessible-label: "Duplicate originals, " + Duplicates.group-count; + accessible-action-default => { Duplicates.open(); } + + HorizontalLayout { + padding-left: Theme.gap-sm; + padding-right: Theme.gap-sm; + spacing: Theme.gap-sm; + + Text { + text: "Duplicate originals"; + color: Theme.ink-dim; + font-size: Theme.text; + vertical-alignment: center; + horizontal-stretch: 1; + } + + Text { + text: Duplicates.group-count; + color: Theme.ink-faint; + font-size: Theme.text-sm; + vertical-alignment: center; + } + } + + dup-touch := TouchArea { + clicked => { Duplicates.open(); } + } + } + // --- error -------------------------------------------------------- // // A refused drop or a failed rename says so here rather than only in diff --git a/ui/dr-ui/ui/duplicates.slint b/ui/dr-ui/ui/duplicates.slint new file mode 100644 index 0000000..4304fa4 --- /dev/null +++ b/ui/dr-ui/ui/duplicates.slint @@ -0,0 +1,360 @@ +// TRACES: FR-CAT-11a | FR-CAT-15 | FR-UI-4 +// The duplicate originals review: every group of copies of one file, which +// one stays, what the others bring to it, and the one button that moves the +// rest to the trash. +// +// Same shape as the merge page — a header with the one verb, a progress bar +// under it while a job runs, a scrolling body — because it is the same kind +// of page: a job proposes, the photographer confirms, and nothing moves +// before the button is pressed. Every row is a tap target the height of a +// finger, because the tablet uses this page too. + +import { Theme } from "theme.slint"; +import { Button, Caption, Value, ProgressBar } from "widgets.slint"; +import { Check } from "controls.slint"; + +/// One copy's line in a group. +export struct DuplicateCopy { + path: string, + /// The copy that stays in the library. + survivor: bool, +} + +/// One group of copies of one file. +export struct DuplicateRow { + /// "Canon EOS 6D · 24 Jun 2023 12:21 · 26.5 MB". + title: string, + thumb: image, + has-thumb: bool, + copies: [DuplicateCopy], + /// What the check found: "Same file", "Not checked yet", or why it was + /// left out. + status: string, + /// What the survivor gains, and any conflict. + detail: string, + /// Left out by the check, or failed. + warn: bool, + /// In the plan. + include: bool, + /// Whether the include box means anything: a group the check dropped + /// cannot be put back by hand. + can-include: bool, + /// Consolidated in this session. + done: bool, +} + +export global Duplicates { + /// The current page of groups. + in property <[DuplicateRow]> rows; + /// The dry run: "1,836 groups · 3,379 copies to move to the trash · 2 + /// skipped". + in property summary; + /// "Groups 1–30 of 1,836". + in property page-label; + in property has-prev: false; + in property has-next: false; + /// A check or a consolidation is running. + in property running: false; + in property fraction: 0; + in property progress-label; + /// The check's button and what it costs. + in property check-label; + in property check-cost; + in property can-check: false; + /// "Move 3,379 copies to trash". + in property confirm-label; + in property can-confirm: false; + in property error; + /// How many groups the library holds, for the entries into this page. + in property group-count: 0; + + callback open(); + callback close(); + callback check(); + callback stop(); + callback confirm(); + callback prev-page(); + callback next-page(); + /// A copy was chosen to stay: row on this page, copy in the group. + callback pick-survivor(int, int); + /// A group was put in or taken out of the plan, by row on this page. + callback toggle(int, bool); +} + +component CopyLine inherits Rectangle { + in property copy; + in property enabled: true; + callback picked(); + + height: Theme.touch-target; + border-radius: Theme.radius-sm; + background: root.copy.survivor ? Theme.surface-raised : transparent; + + accessible-role: button; + accessible-label: (root.copy.survivor ? "Stays: " : "To the trash: ") + root.copy.path; + accessible-action-default => { root.picked(); } + + HorizontalLayout { + padding-left: Theme.gap-sm; + padding-right: Theme.gap-sm; + spacing: Theme.gap-sm; + + Text { + width: 88px; + text: root.copy.survivor ? "Stays" : "To trash"; + color: root.copy.survivor ? Theme.ink : Theme.ink-faint; + font-size: Theme.text-sm; + font-weight: root.copy.survivor ? 600 : 400; + vertical-alignment: center; + } + + Text { + text: root.copy.path; + color: root.copy.survivor ? Theme.ink : Theme.ink-dim; + font-size: Theme.text-sm; + overflow: elide; + vertical-alignment: center; + horizontal-stretch: 1; + } + } + + TouchArea { + enabled: root.enabled; + clicked => { root.picked(); } + } +} + +component GroupCard inherits Rectangle { + in property group; + in property busy; + callback pick(int); + callback toggled(bool); + + background: Theme.surface; + border-radius: Theme.radius; + height: layout.preferred-height; + + layout := HorizontalLayout { + padding: Theme.gap; + spacing: Theme.gap; + + // One picture for the group: the copies are the same file. + Rectangle { + width: 96px; + height: 96px; + background: Theme.ground; + border-radius: Theme.radius-sm; + clip: true; + + if root.group.has-thumb: Image { + width: parent.width; + height: parent.height; + source: root.group.thumb; + image-fit: contain; + } + } + + VerticalLayout { + spacing: 2px; + horizontal-stretch: 1; + + HorizontalLayout { + spacing: Theme.gap-sm; + + Value { + text: root.group.title; + overflow: elide; + horizontal-stretch: 1; + } + + if root.group.can-include && !root.group.done: Check { + label: "Include"; + checked: root.group.include; + controlled: true; + toggled(on) => { root.toggled(on); } + } + } + + for copy[i] in root.group.copies: CopyLine { + copy: copy; + enabled: !root.busy && !root.group.done && root.group.can-include; + picked => { root.pick(i); } + } + + Caption { + text: root.group.status; + warn: root.group.warn; + wrap: word-wrap; + } + + if root.group.detail != "": Caption { + text: root.group.detail; + wrap: word-wrap; + } + } + } +} + +export component DuplicatesPage inherits Rectangle { + background: Theme.ground; + + FocusScope { + width: 0px; + height: 0px; + init => { self.focus(); } + } + + VerticalLayout { + Rectangle { + height: 44px; + background: Theme.surface; + + HorizontalLayout { + padding-left: Theme.gap; + padding-right: Theme.gap; + spacing: Theme.gap; + + Button { + text: "‹ Back"; + y: (parent.height - self.height) / 2; + clicked => { Duplicates.close(); } + } + + Value { text: "Duplicate originals"; } + + Rectangle { horizontal-stretch: 1; } + + if Duplicates.running: Button { + text: "Stop"; + y: (parent.height - self.height) / 2; + clicked => { Duplicates.stop(); } + } + + // GESTURE: Check duplicate originals are the same file + // where: Duplicate originals + // touch: Tap "Check" + // pointer: Click "Check" + // why: Nothing is moved on the catalog's say-so. The + // check reads the first and last megabyte of each + // copy and its sidecar, keeps what it read, and + // drops any group whose bytes or edits differ. + // manual: duplicate-originals + if !Duplicates.running && Duplicates.can-check: Button { + text: Duplicates.check-label; + y: (parent.height - self.height) / 2; + clicked => { Duplicates.check(); } + } + + // GESTURE: Move the spare copies to the trash + // where: Duplicate originals + // touch: Tap "Move N copies to trash" + // pointer: Click "Move N copies to trash" + // why: The one verb, named with its count, and off until + // a check has proved something. Each group is merged + // onto the copy that stays and the others go to the + // trash together or not at all; the trash view gives + // them back. + // manual: duplicate-originals + if !Duplicates.running: Button { + text: Duplicates.confirm-label; + primary: true; + enabled: Duplicates.can-confirm; + y: (parent.height - self.height) / 2; + clicked => { Duplicates.confirm(); } + } + } + + Rectangle { + y: parent.height - 1px; + height: 1px; + background: Theme.rule; + } + } + + if Duplicates.running: ProgressBar { + fraction: Duplicates.fraction; + label: Duplicates.progress-label; + } + + if Duplicates.error != "": Rectangle { + height: 32px; + background: Theme.surface; + HorizontalLayout { + padding-left: Theme.gap; + padding-right: Theme.gap; + Caption { text: Duplicates.error; warn: true; overflow: elide; } + } + } + + Flickable { + vertical-stretch: 1; + viewport-height: body.preferred-height; + + body := VerticalLayout { + width: 100%; + padding: Theme.gap-lg; + spacing: Theme.gap; + alignment: start; + + Caption { + text: Duplicates.summary; + wrap: word-wrap; + } + + if Duplicates.running: Caption { + text: Duplicates.progress-label; + } + + if Duplicates.check-cost != "": Caption { + text: Duplicates.check-cost; + wrap: word-wrap; + } + + // GESTURE: Choose which copy of a duplicate stays + // where: Duplicate originals + // touch: Tap the copy's path + // pointer: Click the copy's path + // why: The rule picks the copy outside a backup folder + // that still has the camera's name. The path is the + // evidence, so the path is what is tapped to + // overrule it. + // manual: duplicate-originals + // + // GESTURE: Leave a duplicate group as it is + // where: Duplicate originals + // touch: Untick "Include" on the group + // pointer: Untick "Include" on the group + // why: The plan is every group the check proved; a group + // you want to keep in two places is taken out of it + // rather than argued with. + // manual: duplicate-originals + for row[i] in Duplicates.rows: GroupCard { + group: row; + busy: Duplicates.running; + pick(copy) => { Duplicates.pick-survivor(i, copy); } + toggled(on) => { Duplicates.toggle(i, on); } + } + + if Duplicates.has-prev || Duplicates.has-next: HorizontalLayout { + spacing: Theme.gap; + alignment: center; + + Button { + text: "‹ Previous"; + enabled: Duplicates.has-prev; + clicked => { Duplicates.prev-page(); } + } + Caption { + text: Duplicates.page-label; + vertical-alignment: center; + } + Button { + text: "Next ›"; + enabled: Duplicates.has-next; + clicked => { Duplicates.next-page(); } + } + } + } + } + } +} diff --git a/ui/dr-ui/ui/settings.slint b/ui/dr-ui/ui/settings.slint index e37886e..acee2ae 100644 --- a/ui/dr-ui/ui/settings.slint +++ b/ui/dr-ui/ui/settings.slint @@ -105,6 +105,12 @@ export component SettingsPage inherits Rectangle { in property <[string]> face-detector-labels; in property face-detector-selected: 0; callback face-detector-picked(int); + /// TRACES: FR-CAT-11a + /// How many groups of duplicate originals the catalog holds, and the way + /// into their review. Here beside the other whole-library passes, and on + /// the sidebar under the trash, where the copies it moves will go. + in property duplicate-groups: 0; + callback find-duplicates(); // --- cache --------------------------------------------------------- in-out property original-budget; @@ -500,6 +506,45 @@ export component SettingsPage inherits Rectangle { } } + // TRACES: FR-CAT-11a + // Duplicate originals: the same file catalogued in + // several folders. Opens the review; nothing moves + // from here. + if root.library-open: Rectangle { + height: Theme.gap-sm; + } + + if root.library-open: Caption { + text: root.duplicate-groups > 0 + ? "Duplicate originals: " + root.duplicate-groups + + (root.duplicate-groups == 1 ? " file is" : " files are") + + " in the library more than once. The review " + + "checks each group is the same file, keeps one " + + "copy with every rating, keyword and collection " + + "merged onto it, and moves the rest to the trash." + : "Duplicate originals: none. The same file in two " + + "folders would be listed here."; + wrap: word-wrap; + } + + // GESTURE: Review duplicate originals + // where: Settings + // touch: Tap "Review duplicate originals" + // pointer: Click "Review duplicate originals" + // why: Beside the other whole-library passes, because + // it is one; the sidebar offers the same page + // under the trash. + // manual: duplicate-originals + if root.library-open && root.duplicate-groups > 0: Rectangle { + height: Theme.control-height; + + Button { + x: 0; + text: "Review duplicate originals"; + clicked => { root.find-duplicates(); } + } + } + if root.activity-kept > 0: Rectangle { height: Theme.control-height;