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;