feat(core,fuse,gui): find photos that look alike

The duplicate finder gains an Exact / Similar switch. Similar asks the
daemon for PhotoSimilar, which starts a background pass that hashes the
thumbnail of every shot not hashed yet and replies at once with the sets
found so far and how far hashing has got. The dialog shows a progress bar
and asks again until the pass is done, then lists the sets.

The pass takes thumbnails from the server only: a photo without one is
skipped for the run instead of downloaded whole, and the dialog says how
many photos could not be compared. Decoding and hashing are spread over
the machine's cores.

Every similar photo starts ticked Keep, since photos that look alike can
be different shots. A similar photo stands for its whole shot, so one not
kept goes to Trash with the rest of its shot.
This commit is contained in:
nvrl committed 2026-09-28 09:16:54 +02:00
1 parent c98ed0d83b
commit a90eeecda9
16 files changed
+1276 -250

No files matched your search

+19 -1
View File
@@ -91,6 +91,12 @@ pub enum Request {
/// Sets of photos that are byte-identical copies of each other, going by
/// the server's content hash. Replies with [`Response::PhotoDuplicates`].
PhotoDuplicates,
/// Sets of shots that look alike going by their thumbnails: resized or
/// re-encoded copies, and bursts of one moment. Starts hashing the
/// thumbnails not hashed yet in the background and replies at once with
/// the sets found so far; a front-end asks again until the reply's
/// `hashing` is `None`. Replies with [`Response::PhotoSimilar`].
PhotoSimilar,
/// The towns the photos were taken in, the one with the most photos first.
/// Only photos whose uploader recorded a location count. Replies with
/// [`Response::Places`].
@@ -1481,7 +1487,8 @@ pub struct PhotoLocation {
}
/// How many of the timeline's photos a running refresh has read the metadata
/// (and so the location) of, out of how many. Part of [`Response::Places`].
/// (and so the location) of, out of how many. Part of [`Response::Places`],
/// and of [`Response::PhotoSimilar`] for how many shots have been hashed.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub struct MappingProgress {
pub done: usize,
@@ -1849,6 +1856,17 @@ pub enum Response {
/// The duplicate sets (reply to [`Request::PhotoDuplicates`]), newest
/// first. Each set lists the copy worth keeping first.
PhotoDuplicates { sets: Vec<Vec<PhotoItem>> },
/// The sets of alike shots (reply to [`Request::PhotoSimilar`]), newest
/// first, each in capture order; and how far hashing has got, `None`
/// once no pass is running. `uncompared` counts the shots left out
/// because no thumbnail could be read for them.
PhotoSimilar {
sets: Vec<Vec<PhotoItem>>,
#[serde(default)]
hashing: Option<MappingProgress>,
#[serde(default)]
uncompared: usize,
},
/// The towns the photos were taken in (reply to [`Request::PhotoPlaces`]),
/// and how far a running timeline refresh has got reading photo locations
/// — `None` when none is reading any.
+8
View File
@@ -465,6 +465,14 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
Ok(sets) => CtlResponse::PhotoDuplicates { sets },
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::PhotoSimilar) => match core.photo_similar() {
Ok((sets, hashing, uncompared)) => CtlResponse::PhotoSimilar {
sets,
hashing,
uncompared,
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::PhotoPlaces) => match core.photo_places() {
Ok(items) => CtlResponse::Places {
items,
+3
View File
@@ -557,6 +557,9 @@ struct Core {
/// The same, for the album listing.
albums_refreshing: Arc<AtomicBool>,
trash_refreshing: Arc<AtomicBool>,
/// True while the similar-photo pass hashes thumbnails (see
/// [`Core::photo_similar`]), so asking again doesn't start a second one.
similar_hashing: Arc<AtomicBool>,
/// Fires whenever a trash refresh persists a batch or finishes, so a
/// `ListTrash` request waiting on a first-ever refresh wakes on progress
/// instead of polling or blocking for the whole run. See
+1
View File
@@ -392,6 +392,7 @@ pub fn mount(
upload_cancel: Arc::new(Mutex::new(HashMap::new())),
timeline_refreshing: Arc::new(AtomicBool::new(false)),
timeline_progress: Arc::new(Mutex::new(None)),
similar_hashing: Arc::new(AtomicBool::new(false)),
albums_refreshing: Arc::new(AtomicBool::new(false)),
trash_refreshing: Arc::new(AtomicBool::new(false)),
trash_progress: Arc::new(tokio::sync::Notify::new()),
+169 -2
View File
@@ -43,6 +43,8 @@ const THUMB_QUALITY: u8 = 82;
/// thumbnails. Bounded: a screenful of 20 MB digicam JPEGs would otherwise
/// saturate the link and starve the rest of the daemon.
pub(crate) const THUMB_GEN_CONCURRENCY: usize = 4;
/// Shots the similar-photo pass fetches thumbnails for at a time.
const SIMILAR_CHUNK: usize = 64;
/// Images processed between recursive-build progress updates.
const THUMB_BUILD_CHUNK: usize = 16;
/// A recursive build may wait briefly for an opportunistic tile job that
@@ -81,6 +83,46 @@ fn ratio_of(bytes: &[u8]) -> Option<f64> {
(height > 0).then(|| f64::from(width) / f64::from(height))
}
/// The difference hash of each cached thumbnail in `paths`, keyed like the
/// input, or [`pdfs_core::similar::NO_HASH`] for one that can't be decoded or
/// shows no picture. Decoding is spread over the machine's cores.
fn similar_hashes(paths: Vec<(String, PathBuf)>) -> Vec<(String, u64)> {
fn hash(path: &Path) -> u64 {
image::ImageReader::open(path)
.ok()
.and_then(|reader| reader.with_guessed_format().ok())
.and_then(|reader| reader.decode().ok())
.and_then(|image| {
let luma = image.to_luma8();
pdfs_core::similar::difference_hash(
luma.width() as usize,
luma.height() as usize,
luma.as_raw(),
)
})
.unwrap_or(pdfs_core::similar::NO_HASH)
}
let threads = std::thread::available_parallelism().map_or(1, |n| n.get());
let per_thread = paths.len().div_ceil(threads).max(1);
std::thread::scope(|scope| {
let workers: Vec<_> = paths
.chunks(per_thread)
.map(|chunk| {
scope.spawn(move || {
chunk
.iter()
.map(|(uid, path)| (uid.clone(), hash(path)))
.collect::<Vec<_>>()
})
})
.collect();
workers
.into_iter()
.flat_map(|worker| worker.join().unwrap_or_default())
.collect()
})
}
/// A thumbnail the daemon made itself, and the aspect ratio of the photo it was
/// made from (free at that point — the full image had to be decoded anyway).
struct GeneratedThumb {
@@ -751,6 +793,83 @@ impl Core {
.collect())
}
/// The sets of shots that look alike, as wire items (see
/// [`db::Db::photo_similar`]), how far hashing has got while it runs, and
/// how many shots couldn't be compared once it's done. Starts a hashing
/// pass for the shots not hashed yet unless one is running.
pub(crate) fn photo_similar(
&self,
) -> CoreResult<(Vec<Vec<PhotoItem>>, Option<MappingProgress>, usize)> {
self.spawn_similar_hashing();
let sets = self.db.photo_similar().map_err(CoreError::from)?;
let (done, total) = self.db.similar_progress().map_err(CoreError::from)?;
let hashing = self
.similar_hashing
.load(Ordering::SeqCst)
.then_some(MappingProgress { done, total });
let uncompared = if hashing.is_some() { 0 } else { total - done };
let sets = sets
.into_iter()
.map(|set| set.into_iter().map(|p| self.photo_item(p)).collect())
.collect();
Ok((sets, hashing, uncompared))
}
fn spawn_similar_hashing(&self) {
if self.similar_hashing.swap(true, Ordering::SeqCst) {
return;
}
let core = self.clone();
let spawned = std::thread::Builder::new()
.name("pdfs-similar".into())
.spawn(move || {
if let Err(e) = core.hash_similar() {
warn!(error = %e, "similar-photo hashing failed");
}
core.similar_hashing.store(false, Ordering::SeqCst);
});
if let Err(e) = spawned {
warn!(error = %e, "could not start similar-photo hashing");
self.similar_hashing.store(false, Ordering::SeqCst);
}
}
/// Hash the look of every shot not hashed yet, a chunk at a time. Only
/// thumbnails the server has are used: a photo without one is skipped
/// rather than downloaded whole, and tried again on the next pass.
fn hash_similar(&self) -> CoreResult<()> {
let mut tried: HashSet<String> = HashSet::new();
loop {
if self.shutdown.is_stopping() {
return Ok(());
}
// Shots tried this pass without a thumbnail are still unhashed and
// come back first, so ask past them.
let chunk: Vec<NodeUid> = self
.db
.photos_unhashed(tried.len() + SIMILAR_CHUNK)
.map_err(CoreError::from)?
.into_iter()
.filter(|photo| tried.insert(photo.uid.clone()))
.take(SIMILAR_CHUNK)
.filter_map(|photo| parse_uid(&photo.uid))
.collect();
if chunk.is_empty() {
return Ok(());
}
let paths: Vec<(String, PathBuf)> = self
.photo_thumbs_with(&chunk, false)
.into_iter()
.filter_map(|thumb| Some((thumb.uid, PathBuf::from(thumb.path?))))
.collect();
for (uid, hash) in similar_hashes(paths) {
self.db
.photo_set_similar_hash(&uid, hash)
.map_err(CoreError::from)?;
}
}
}
/// The towns the photos were taken in, as wire items. A town the bundled
/// table no longer knows (the stored id came from an older table) is left
/// out rather than shown without a name.
@@ -961,6 +1080,13 @@ impl Core {
/// milliseconds where the raw costs a full download and a raw decode. The
/// tile still answers under the uid that was asked for.
pub(crate) fn photo_thumbs(&self, requested: &[NodeUid]) -> Vec<PhotoThumb> {
self.photo_thumbs_with(requested, true)
}
/// [`Core::photo_thumbs`], with local generation only when `generate` is
/// set. Without it, a photo the server has no thumbnail for is left
/// without one rather than downloaded whole.
fn photo_thumbs_with(&self, requested: &[NodeUid], generate: bool) -> Vec<PhotoThumb> {
let ttype = ThumbnailType::Thumbnail.as_i32();
// Only a raw can be stood in for, and that is one batched read; the
// per-shot group lookup is then paid for raws alone, not for a page of
@@ -1061,7 +1187,7 @@ impl Core {
// that never generated one. Off the request path: a full-size photo
// takes far longer to fetch than the whole rest of the batch, and the
// thumbnails that *are* ready must not wait behind it.
if !missing.is_empty() {
if generate && !missing.is_empty() {
self.spawn_generate_thumbs(missing, &tags, &names, ThumbJob::Photos);
}
}
@@ -1964,10 +2090,11 @@ mod thumb_tests {
use super::{
RawTempFile, ScaleAttempt, THUMB_EDGE, UPLOAD_THUMB_MAX_BYTES, exiftool_binary,
fit_upload_thumb, is_video_name, ratio_of, scale_thumbnail, scale_thumbnail_with_exiftool,
thumbnail_build_may_start, upload_thumbnails_with, video_thumbnail,
similar_hashes, thumbnail_build_may_start, upload_thumbnails_with, video_thumbnail,
};
use pdfs_core::control::{ThumbnailBuildStatus, is_thumbnail_image_name};
use proton_drive_rs::ThumbnailType;
use std::collections::HashMap;
use std::ffi::OsStr;
use std::os::unix::fs::PermissionsExt as _;
use std::time::Duration;
@@ -2241,6 +2368,46 @@ mod thumb_tests {
assert!(thumbnail_build_may_start(&idle, "pictures/second").unwrap());
}
#[test]
fn thumbnails_hash_alike_across_sizes_and_undecodable_ones_get_no_hash() {
let tmp = scratch("similar-hashes");
std::fs::create_dir_all(&tmp).unwrap();
// One picture at two sizes: a diagonal gradient with a bright corner.
let picture = |width: u32, height: u32| {
let image = image::GrayImage::from_fn(width, height, |x, y| {
let (fx, fy) = (x * 4 / width, y * 4 / height);
image::Luma([if fx + fy == 0 {
250
} else {
(fx * 40 + fy * 20) as u8
}])
});
let mut bytes = Vec::new();
image::codecs::jpeg::JpegEncoder::new(&mut bytes)
.encode_image(&image)
.unwrap();
bytes
};
std::fs::write(tmp.join("big"), picture(512, 384)).unwrap();
std::fs::write(tmp.join("small"), picture(256, 192)).unwrap();
std::fs::write(tmp.join("torn"), b"not a picture").unwrap();
let hashes: HashMap<String, u64> = similar_hashes(
["big", "small", "torn", "gone"]
.iter()
.map(|name| (name.to_string(), tmp.join(name)))
.collect(),
)
.into_iter()
.collect();
let _ = std::fs::remove_dir_all(&tmp);
assert_eq!(hashes.len(), 4);
assert_ne!(hashes["big"], pdfs_core::similar::NO_HASH);
assert!(pdfs_core::similar::alike(hashes["big"], hashes["small"]));
assert_eq!(hashes["torn"], pdfs_core::similar::NO_HASH);
assert_eq!(hashes["gone"], pdfs_core::similar::NO_HASH);
}
#[test]
fn an_uploaded_photo_carries_its_thumbnail() {
let tmp = scratch("upload-photo");
+277 -47
View File
@@ -6,14 +6,58 @@ const COPY_SIZE: i32 = 150;
/// Each set's copies with their Keep toggles.
type SetToggles = Vec<Vec<(PhotoItem, gtk4::ToggleButton)>>;
/// How often the dialog asks again while the daemon hashes thumbnails.
const HASHING_POLL: std::time::Duration = std::time::Duration::from_secs(1);
/// What the finder looks for.
#[derive(Clone, Copy, PartialEq, Eq)]
enum Finder {
/// Byte-identical copies, going by the server's content hash.
Exact,
/// Shots that look alike, going by their thumbnails.
Similar,
}
/// The dialog's parts that a search fills in.
struct FinderView {
ui: Rc<Ui>,
status: adw::StatusPage,
progress: gtk4::ProgressBar,
note: gtk4::Label,
stack: gtk4::Stack,
trash: gtk4::Button,
toolbar: adw::ToolbarView,
/// Bumped by every new search and when the dialog closes, so a reply or a
/// poll for an older one is dropped.
generation: Cell<u32>,
/// The Trash button's handler for the review on show.
clicked: RefCell<Option<glib::SignalHandlerId>>,
}
/// Open the duplicate finder: every set of photos stored more than once, side
/// by side, with the copy worth keeping ticked. The copies left unticked go to
/// Trash — one file each, never the rest of their shot.
/// Trash — one file each, never the rest of their shot. The Similar switch
/// looks for shots that look alike instead.
pub(crate) fn show_duplicates(ui: &Rc<Ui>) {
let progress = gtk4::ProgressBar::builder()
.show_text(true)
.width_request(280)
.halign(gtk4::Align::Center)
.visible(false)
.build();
let note = gtk4::Label::builder()
.wrap(true)
.justify(gtk4::Justification::Center)
.visible(false)
.build();
note.add_css_class("dim-label");
let status_extra = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
status_extra.append(&progress);
status_extra.append(&note);
let status = adw::StatusPage::builder()
.icon_name("edit-copy-symbolic")
.title(gettext("Looking for duplicates…"))
.vexpand(true)
.child(&status_extra)
.build();
status.add_css_class("compact");
@@ -21,7 +65,6 @@ pub(crate) fn show_duplicates(ui: &Rc<Ui>) {
stack.add_named(&status, Some("status"));
let trash = gtk4::Button::builder()
.label(gettext("Move to Trash"))
.halign(gtk4::Align::End)
.sensitive(false)
.build();
@@ -32,8 +75,25 @@ pub(crate) fn show_duplicates(ui: &Rc<Ui>) {
bottom.set_halign(gtk4::Align::End);
bottom.append(&trash);
let exact = gtk4::ToggleButton::builder()
.label(pgettext("duplicates", "Exact"))
.tooltip_text(gettext("Photos stored more than once"))
.active(true)
.build();
let similar = gtk4::ToggleButton::builder()
.label(pgettext("duplicates", "Similar"))
.tooltip_text(gettext("Photos that look alike"))
.group(&exact)
.build();
let switch = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
switch.add_css_class("linked");
switch.append(&exact);
switch.append(&similar);
let header = adw::HeaderBar::new();
header.set_title_widget(Some(&switch));
let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&adw::HeaderBar::new());
toolbar.add_top_bar(&header);
toolbar.set_content(Some(&stack));
toolbar.add_bottom_bar(&bottom);
toolbar.set_reveal_bottom_bars(false);
@@ -46,47 +106,189 @@ pub(crate) fn show_duplicates(ui: &Rc<Ui>) {
.build();
dialog.present(ui_window(ui).as_ref());
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoDuplicates);
let ui = ui.clone();
let view = Rc::new(FinderView {
ui: ui.clone(),
status,
progress,
note,
stack,
trash,
toolbar,
generation: Cell::new(0),
clicked: RefCell::new(None),
});
let view_closed = view.clone();
dialog.connect_closed(move |_| {
view_closed
.generation
.set(view_closed.generation.get().wrapping_add(1));
});
let view_switch = view.clone();
similar.connect_toggled(move |similar| {
let finder = if similar.is_active() {
Finder::Similar
} else {
Finder::Exact
};
search(&view_switch, finder);
});
search(&view, Finder::Exact);
}
/// Drop what the dialog shows and look again, for `finder`.
fn search(view: &Rc<FinderView>, finder: Finder) {
let generation = view.generation.get().wrapping_add(1);
view.generation.set(generation);
if let Some(review) = view.stack.child_by_name("review") {
view.stack.remove(&review);
}
if let Some(handler) = view.clicked.take() {
view.trash.disconnect(handler);
}
view.toolbar.set_reveal_bottom_bars(false);
view.status.set_icon_name(Some("edit-copy-symbolic"));
view.status.set_title(&match finder {
Finder::Exact => gettext("Looking for duplicates…"),
Finder::Similar => gettext("Looking for similar photos…"),
});
view.status.set_description(None);
view.progress.set_visible(false);
view.note.set_visible(false);
view.stack.set_visible_child_name("status");
ask(view, finder, generation);
}
/// Ask the daemon for `finder`'s sets and show them, or, while it is still
/// hashing thumbnails, how far it has got — and ask again in a moment.
fn ask(view: &Rc<FinderView>, finder: Finder, generation: u32) {
let request = match finder {
Finder::Exact => Request::PhotoDuplicates,
Finder::Similar => Request::PhotoSimilar,
};
let rx = spawn_request(view.ui.dirs.control_socket(), request);
let view = view.clone();
glib::spawn_future_local(async move {
let sets = match rx.recv().await {
Ok(Ok(Response::PhotoDuplicates { sets })) => sets,
let reply = rx.recv().await;
if view.generation.get() != generation {
return;
}
let failed = match finder {
Finder::Exact => gettext("Couldn't look for duplicates"),
Finder::Similar => gettext("Couldn't look for similar photos"),
};
let (sets, uncompared) = match reply {
Ok(Ok(Response::PhotoDuplicates { sets })) => (sets, 0),
Ok(Ok(Response::PhotoSimilar {
hashing: Some(MappingProgress { done, total }),
..
})) => {
view.status.set_title(&gettext("Comparing photos…"));
view.status.set_description(Some(&gettext(
"Photos are compared by their thumbnails. The first time takes a while.",
)));
view.progress.set_fraction(if total == 0 {
0.0
} else {
done as f64 / total as f64
});
view.progress.set_text(Some(&gettext_f(
// Translators: {done} and {total} are numbers of photos.
"{done} of {total}",
&[("done", &thousands(done)), ("total", &thousands(total))],
)));
view.progress.set_visible(true);
glib::timeout_add_local_once(HASHING_POLL, move || {
if view.generation.get() == generation {
ask(&view, finder, generation);
}
});
return;
}
Ok(Ok(Response::PhotoSimilar {
sets,
hashing: None,
uncompared,
})) => (sets, uncompared),
Ok(Ok(Response::Error { message, .. })) => {
status.set_icon_name(Some("dialog-warning-symbolic"));
status.set_title(&gettext("Couldn't look for duplicates"));
status.set_description(Some(&message));
view.status.set_icon_name(Some("dialog-warning-symbolic"));
view.status.set_title(&failed);
view.status.set_description(Some(&message));
view.progress.set_visible(false);
return;
}
_ => {
status.set_icon_name(Some("dialog-warning-symbolic"));
status.set_title(&gettext("Couldn't look for duplicates"));
status.set_description(Some(&gettext("The mount service didn't respond.")));
view.status.set_icon_name(Some("dialog-warning-symbolic"));
view.status.set_title(&failed);
view.status
.set_description(Some(&gettext("The mount service didn't respond.")));
view.progress.set_visible(false);
return;
}
};
view.progress.set_visible(false);
if sets.is_empty() {
status.set_icon_name(Some("emblem-ok-symbolic"));
status.set_title(&gettext("No duplicates"));
status.set_description(Some(&gettext(
"Every photo in your library is stored once.",
)));
view.status.set_icon_name(Some("emblem-ok-symbolic"));
match finder {
Finder::Exact => {
view.status.set_title(&gettext("No duplicates"));
view.status.set_description(Some(&gettext(
"Every photo in your library is stored once.",
)));
}
Finder::Similar => {
view.status.set_title(&gettext("No similar photos"));
view.status.set_description(Some(&gettext(
"No two photos in your library look alike.",
)));
}
}
if uncompared > 0 {
view.note.set_label(&uncompared_note(uncompared));
view.note.set_visible(true);
}
return;
}
let review = duplicate_review(&ui, sets, &trash);
stack.add_named(&review, Some("review"));
stack.set_visible_child_name("review");
toolbar.set_reveal_bottom_bars(true);
schedule_thumbs(&ui);
let (review, handler) = duplicate_review(&view.ui, sets, &view.trash, finder, uncompared);
view.clicked.replace(Some(handler));
view.stack.add_named(&review, Some("review"));
view.stack.set_visible_child_name("review");
view.toolbar.set_reveal_bottom_bars(true);
schedule_thumbs(&view.ui);
});
}
/// The review list: one section per set, each copy a thumbnail with a Keep
/// toggle. Wires `trash` to move every copy not kept to Trash.
fn duplicate_review(ui: &Rc<Ui>, sets: Vec<Vec<PhotoItem>>, trash: &gtk4::Button) -> gtk4::Widget {
/// Says how many photos the similar finder left out for want of a thumbnail.
fn uncompared_note(n: usize) -> String {
ngettext_f(
"{n} photo couldn't be compared because it has no thumbnail yet.",
"{n} photos couldn't be compared because they have no thumbnail yet.",
n as u64,
&[],
)
}
/// The review list: one section per set, each photo a thumbnail with a Keep
/// toggle. Wires `trash` to move every photo not kept to Trash, and returns
/// that handler so a new search can take it off again.
///
/// Exact copies start with only the first of each set kept. Similar photos
/// all start kept: photos that look alike can still be different shots.
fn duplicate_review(
ui: &Rc<Ui>,
sets: Vec<Vec<PhotoItem>>,
trash: &gtk4::Button,
finder: Finder,
uncompared: usize,
) -> (gtk4::Widget, glib::SignalHandlerId) {
let intro = gtk4::Label::builder()
.label(gettext(
"These photos are stored more than once. The copies you don't keep move to Trash, where you can restore them.",
))
.label(match finder {
Finder::Exact => gettext(
"These photos are stored more than once. The copies you don't keep move to Trash, where you can restore them.",
),
Finder::Similar => gettext(
"These photos look alike. They can be copies of one photo or different shots of one moment, so all of them are kept until you untick them. The photos you don't keep move to Trash, where you can restore them.",
),
})
.wrap(true)
.xalign(0.0)
.build();
@@ -98,6 +300,15 @@ fn duplicate_review(ui: &Rc<Ui>, sets: Vec<Vec<PhotoItem>>, trash: &gtk4::Button
content.set_margin_start(18);
content.set_margin_end(18);
content.append(&intro);
if uncompared > 0 {
let note = gtk4::Label::builder()
.label(uncompared_note(uncompared))
.wrap(true)
.xalign(0.0)
.build();
note.add_css_class("dim-label");
content.append(&note);
}
// Every copy's toggle, by set, so the button can count what goes.
let toggles: Rc<RefCell<SetToggles>> = Rc::new(RefCell::new(Vec::new()));
@@ -106,19 +317,28 @@ fn duplicate_review(ui: &Rc<Ui>, sets: Vec<Vec<PhotoItem>>, trash: &gtk4::Button
continue;
};
let date = section_heading(first.capture_time, Grouping::Day);
// Translators: heading of one set in the duplicates dialog; {date} is the day the photo was taken.
let title = ngettext_f(
"{date}, {n} copy",
"{date}, {n} copies",
set.len() as u64,
&[("date", &date)],
);
let title = match finder {
// Translators: heading of one set in the duplicates dialog; {date} is the day the photo was taken.
Finder::Exact => ngettext_f(
"{date}, {n} copy",
"{date}, {n} copies",
set.len() as u64,
&[("date", &date)],
),
// Translators: heading of one set of photos that look alike; {date} is the day the first was taken.
Finder::Similar => ngettext_f(
"{date}, {n} photo",
"{date}, {n} photos",
set.len() as u64,
&[("date", &date)],
),
};
let heading = gtk4::Label::builder().label(title).xalign(0.0).build();
heading.add_css_class("heading");
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
let mut set_toggles = Vec::new();
for (index, photo) in set.into_iter().enumerate() {
let (card, keep) = copy_card(ui, &photo, index == 0);
let (card, keep) = copy_card(ui, &photo, index == 0 || finder == Finder::Similar);
row.append(&card);
set_toggles.push((photo, keep));
}
@@ -139,12 +359,20 @@ fn duplicate_review(ui: &Rc<Ui>, sets: Vec<Vec<PhotoItem>>, trash: &gtk4::Button
let trash = trash.clone();
move || {
let n = doomed(&toggles.borrow()).len();
trash.set_label(&ngettext_f(
"Move {n} copy to Trash",
"Move {n} copies to Trash",
n as u64,
&[],
));
trash.set_label(&match finder {
Finder::Exact => ngettext_f(
"Move {n} copy to Trash",
"Move {n} copies to Trash",
n as u64,
&[],
),
Finder::Similar => ngettext_f(
"Move {n} photo to Trash",
"Move {n} photos to Trash",
n as u64,
&[],
),
});
trash.set_sensitive(n > 0);
}
};
@@ -171,12 +399,14 @@ fn duplicate_review(ui: &Rc<Ui>, sets: Vec<Vec<PhotoItem>>, trash: &gtk4::Button
}
let ui_trash = ui.clone();
trash.connect_clicked(move |button| {
let handler = trash.connect_clicked(move |button| {
let uids = doomed(&toggles.borrow());
if uids.is_empty() {
return;
}
trash_photos(&ui_trash, uids, true);
// An exact copy is one file of its shot; a similar photo stands for
// its whole shot, raw and all.
trash_photos(&ui_trash, uids, finder == Finder::Exact);
if let Some(dialog) = button
.ancestor(adw::Dialog::static_type())
.and_downcast::<adw::Dialog>()
@@ -190,7 +420,7 @@ fn duplicate_review(ui: &Rc<Ui>, sets: Vec<Vec<PhotoItem>>, trash: &gtk4::Button
.vexpand(true)
.child(&content)
.build();
scroll.upcast()
(scroll.upcast(), handler)
}
/// The uids of every copy not kept.