mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
feat(core,fuse,gui): find and trash duplicate photos
A "Find duplicates" button in the Photos header opens a review of every set of files that share a content hash. Each set shows its copies side by side with a Keep toggle; the copy in the most albums, then in the larger RAW+JPEG group, then the oldest, is kept by default, and a set can't lose its last kept copy. The copies not kept move to Trash one file at a time. - core: Db::photo_duplicates, Request::PhotoDuplicates, and a files_only flag on TrashNodes (defaults to false, so old clients still trash whole groups). - core: deleting a group's representative now clears the group key of its remaining files instead of leaving them pointing at a missing uid. - fuse: handlers for both requests.
This commit is contained in:
1 parent
7dbf818d83
commit
2f14c1688f
20 files changed
+2475
-1579
No files matched your search
@@ -88,6 +88,9 @@ pub enum Request {
|
||||
/// `limit` of them — the "On this day" strip above the timeline. Replies
|
||||
/// with [`Response::Photos`]; a front-end groups them by year itself.
|
||||
PhotosOnThisDay { limit: usize },
|
||||
/// Sets of photos that are byte-identical copies of each other, going by
|
||||
/// the server's content hash. Replies with [`Response::PhotoDuplicates`].
|
||||
PhotoDuplicates,
|
||||
/// List the account's photo albums, newest activity first, including the
|
||||
/// albums other people share with us. Metadata only — an album's cover
|
||||
/// thumbnail is fetched with [`Request::PhotoThumbs`] like any other photo.
|
||||
@@ -134,8 +137,14 @@ pub enum Request {
|
||||
///
|
||||
/// A photo that belongs to a group takes the whole group with it: trashing
|
||||
/// the JPEG of a shot and leaving its RAW behind is not what anyone means by
|
||||
/// deleting that photo.
|
||||
TrashNodes { uids: Vec<String> },
|
||||
/// deleting that photo. With `files_only` set, exactly the listed files
|
||||
/// go and their groups stay: the duplicate finder removes one copy of a
|
||||
/// shot, not the shot. Older front-ends omit it and trash whole groups.
|
||||
TrashNodes {
|
||||
uids: Vec<String>,
|
||||
#[serde(default)]
|
||||
files_only: bool,
|
||||
},
|
||||
/// Fetch thumbnails for ordinary Drive image files shown outside the Photos
|
||||
/// timeline. The modification time is the cache validity tag, so replacing
|
||||
/// an image can never reuse its previous revision's thumbnail.
|
||||
@@ -1784,6 +1793,9 @@ pub enum Response {
|
||||
/// The months the timeline spans (reply to [`Request::PhotoMonths`]),
|
||||
/// newest first.
|
||||
PhotoMonths { months: Vec<PhotoMonth> },
|
||||
/// The duplicate sets (reply to [`Request::PhotoDuplicates`]), newest
|
||||
/// first. Each set lists the copy worth keeping first.
|
||||
PhotoDuplicates { sets: Vec<Vec<PhotoItem>> },
|
||||
/// Thumbnails for a [`Request::PhotoThumbs`] batch.
|
||||
Thumbs { items: Vec<PhotoThumb> },
|
||||
/// Per-node outcome of a [`Request::TrashNodes`] batch. Reported per uid
|
||||
@@ -2180,7 +2192,17 @@ mod tests {
|
||||
fn trash_requests_and_outcomes_roundtrip() {
|
||||
let request = Request::TrashNodes {
|
||||
uids: vec!["volume~first".into(), "volume~second".into()],
|
||||
files_only: false,
|
||||
};
|
||||
// An older front-end's request trashes whole groups.
|
||||
let old: Request = serde_json::from_str(r#"{"TrashNodes":{"uids":["v~a"]}}"#).unwrap();
|
||||
assert!(matches!(
|
||||
old,
|
||||
Request::TrashNodes {
|
||||
files_only: false,
|
||||
..
|
||||
}
|
||||
));
|
||||
let line = serde_json::to_string(&request).unwrap();
|
||||
assert!(!line.contains('\n'), "wire form must be a single line");
|
||||
let decoded: Request = serde_json::from_str(&line).unwrap();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use rusqlite::params;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use super::Db;
|
||||
use crate::Result;
|
||||
@@ -447,6 +447,14 @@ impl Db {
|
||||
albums.execute([uid])?;
|
||||
}
|
||||
}
|
||||
// A group whose representative went is left pointing at nothing, which
|
||||
// drops its other files off every page. Make them stand alone until the
|
||||
// next refresh groups them again.
|
||||
tx.execute(
|
||||
"UPDATE photos SET group_key = NULL WHERE group_key IS NOT NULL \
|
||||
AND group_key NOT IN (SELECT uid FROM photos)",
|
||||
[],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(removed)
|
||||
}
|
||||
@@ -609,6 +617,57 @@ impl Db {
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Sets of photos with the same content hash, each set ordered with the
|
||||
/// copy worth keeping first, and the sets newest first.
|
||||
///
|
||||
/// The copy to keep is the one in the most albums, then the one that is part
|
||||
/// of a larger group (the JPEG's RAW is filed with it, a stray re-upload is
|
||||
/// not), then the earliest in server order. At most one file per group is
|
||||
/// listed: the files of one RAW+JPEG shot are not copies of each other, and a
|
||||
/// set left with one member is no set.
|
||||
pub fn photo_duplicates(&self) -> Result<Vec<Vec<StoredPhoto>>> {
|
||||
let rows: Vec<(String, String, String)> = {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT p.uid, p.content_hash, COALESCE(p.group_key, p.uid) AS k FROM photos p \
|
||||
WHERE p.content_hash IN (SELECT content_hash FROM photos \
|
||||
WHERE content_hash IS NOT NULL GROUP BY content_hash HAVING COUNT(*) > 1) \
|
||||
ORDER BY p.content_hash, \
|
||||
(SELECT COUNT(*) FROM album_photos a WHERE a.uid = p.uid) DESC, \
|
||||
(SELECT COUNT(*) FROM photos m WHERE COALESCE(m.group_key, m.uid) = COALESCE(p.group_key, p.uid)) DESC, \
|
||||
p.seq",
|
||||
)?;
|
||||
stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
|
||||
.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
let uids: Vec<String> = rows.iter().map(|(uid, _, _)| uid.clone()).collect();
|
||||
let mut stored: HashMap<String, StoredPhoto> = self
|
||||
.photos_by_uid(&uids)?
|
||||
.into_iter()
|
||||
.map(|photo| (photo.uid.clone(), photo))
|
||||
.collect();
|
||||
|
||||
let mut sets: Vec<Vec<StoredPhoto>> = Vec::new();
|
||||
let mut hash_of_set: Option<&str> = None;
|
||||
let mut groups_in_set: HashSet<&str> = HashSet::new();
|
||||
for (uid, hash, group) in &rows {
|
||||
if hash_of_set != Some(hash.as_str()) {
|
||||
hash_of_set = Some(hash);
|
||||
groups_in_set.clear();
|
||||
sets.push(Vec::new());
|
||||
}
|
||||
if !groups_in_set.insert(group) {
|
||||
continue;
|
||||
}
|
||||
if let (Some(set), Some(photo)) = (sets.last_mut(), stored.remove(uid)) {
|
||||
set.push(photo);
|
||||
}
|
||||
}
|
||||
sets.retain(|set| set.len() > 1);
|
||||
sets.sort_by_key(|set| std::cmp::Reverse(set.iter().map(|p| p.capture_time).max()));
|
||||
Ok(sets)
|
||||
}
|
||||
|
||||
/// Per-tab counts for the Photos page subtitle: `(photos, videos, raw)`.
|
||||
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
|
||||
use crate::control::PhotoKind;
|
||||
|
||||
@@ -285,6 +285,64 @@ fn on_this_day_finds_the_same_date_in_earlier_years() {
|
||||
);
|
||||
}
|
||||
|
||||
fn hashed(uid: &str, capture_time: i64, name: &str, hash: &str) -> TimelineRow {
|
||||
TimelineRow {
|
||||
name: Some(name.into()),
|
||||
content_hash: Some(hash.into()),
|
||||
..TimelineRow::new(uid, capture_time)
|
||||
}
|
||||
}
|
||||
|
||||
/// A copy an album holds is the one kept; the files of one RAW+JPEG shot are
|
||||
/// never copies of each other; a hash only one file carries is no set.
|
||||
#[test]
|
||||
fn duplicates_list_the_copy_to_keep_first() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.photos_replace(&[
|
||||
hashed("copy", 500, "IMG_2.JPG", "h2"),
|
||||
hashed("filed", 400, "IMG_2(1).JPG", "h2"),
|
||||
hashed("jpeg", 300, "IMG_1.JPG", "h1"),
|
||||
hashed("raw", 300, "IMG_1.CR2", "h1"),
|
||||
hashed("stray", 200, "IMG_1(1).CR2", "h1"),
|
||||
hashed("alone", 100, "IMG_3.JPG", "h3"),
|
||||
])
|
||||
.unwrap();
|
||||
db.album_photos_replace("a1", &[("filed".into(), 400, None, None)])
|
||||
.unwrap();
|
||||
|
||||
let sets = db.photo_duplicates().unwrap();
|
||||
let uids: Vec<Vec<&str>> = sets
|
||||
.iter()
|
||||
.map(|set| set.iter().map(|p| p.uid.as_str()).collect())
|
||||
.collect();
|
||||
assert_eq!(uids, [vec!["filed", "copy"], vec!["jpeg", "stray"]]);
|
||||
}
|
||||
|
||||
/// Trashing the file a group is shown as leaves the rest of the group on the
|
||||
/// timeline instead of pointing at a photo that is gone.
|
||||
#[test]
|
||||
fn deleting_a_group_representative_keeps_its_other_files() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.photos_replace(&[
|
||||
hashed("jpeg", 300, "IMG_1.JPG", "h1"),
|
||||
hashed("raw", 300, "IMG_1.CR2", "h2"),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
db.photos_page(0, 10, None, None, false, false)
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
|
||||
db.photos_delete(&["jpeg".into()]).unwrap();
|
||||
let page = db.photos_page(0, 10, None, None, false, false).unwrap();
|
||||
assert_eq!(
|
||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["raw"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn not_in_album_hides_photos_any_album_holds() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
|
||||
@@ -461,6 +461,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::PhotoDuplicates) => match core.photo_duplicates() {
|
||||
Ok(sets) => CtlResponse::PhotoDuplicates { sets },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::PhotosOnThisDay { limit }) => {
|
||||
match core.photos_on_this_day(clamp_limit(limit)) {
|
||||
Ok(items) => CtlResponse::Photos {
|
||||
@@ -564,8 +568,8 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
items: core.photo_thumbs(&parsed),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::TrashNodes { uids }) => match Core::parse_uids(&uids) {
|
||||
Ok(parsed) => match core.trash_photos(&parsed) {
|
||||
Ok(CtlRequest::TrashNodes { uids, files_only }) => match Core::parse_uids(&uids) {
|
||||
Ok(parsed) => match core.trash_photos(&parsed, files_only) {
|
||||
Ok((trashed, failed)) => CtlResponse::Trashed {
|
||||
trashed,
|
||||
failed: failed
|
||||
|
||||
@@ -729,6 +729,16 @@ impl Core {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The timeline's duplicate sets, as wire items (see
|
||||
/// [`db::Db::photo_duplicates`]).
|
||||
pub(crate) fn photo_duplicates(&self) -> CoreResult<Vec<Vec<PhotoItem>>> {
|
||||
let sets = self.db.photo_duplicates().map_err(CoreError::from)?;
|
||||
Ok(sets
|
||||
.into_iter()
|
||||
.map(|set| set.into_iter().map(|p| self.photo_item(p)).collect())
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Photos taken on today's date in earlier years, as wire items. Served
|
||||
/// from the stored timeline only: the strip is a nicety, not worth a wait
|
||||
/// on the network, and the timeline's own load keeps that store fresh.
|
||||
@@ -822,12 +832,23 @@ impl Core {
|
||||
/// A photo that is one file of a group takes its whole group with it. The
|
||||
/// gallery shows a RAW+JPEG shot as one tile, so deleting that tile has to
|
||||
/// delete the shot — leaving the RAW behind would put the photo back on the
|
||||
/// Raw tab and nowhere else.
|
||||
pub(crate) fn trash_photos(&self, uids: &[NodeUid]) -> CoreResult<TrashOutcome> {
|
||||
/// Raw tab and nowhere else. `files_only` turns that off, for removing
|
||||
/// one copy of a shot that is stored twice.
|
||||
pub(crate) fn trash_photos(
|
||||
&self,
|
||||
uids: &[NodeUid],
|
||||
files_only: bool,
|
||||
) -> CoreResult<TrashOutcome> {
|
||||
if uids.is_empty() {
|
||||
return Ok((Vec::new(), Vec::new()));
|
||||
}
|
||||
let uids = &self.expand_photo_groups(uids);
|
||||
let expanded;
|
||||
let uids = if files_only {
|
||||
uids
|
||||
} else {
|
||||
expanded = self.expand_photo_groups(uids);
|
||||
&expanded
|
||||
};
|
||||
for uid in uids {
|
||||
self.require_photo_writable(uid)
|
||||
.map_err(|errno| self.errno_error(errno, "trash access"))?;
|
||||
|
||||
@@ -9,6 +9,7 @@ use pages::activity::*;
|
||||
use pages::albums::*;
|
||||
use pages::browser::*;
|
||||
use pages::devices::*;
|
||||
use pages::duplicates::*;
|
||||
use pages::locations::*;
|
||||
use pages::login::*;
|
||||
use pages::photo_viewer::*;
|
||||
@@ -726,6 +727,10 @@ fn build_window(app: &adw::Application) {
|
||||
&gallery_widgets.empty_upload,
|
||||
&gallery_widgets.empty_import,
|
||||
);
|
||||
let ui_duplicates = ui.clone();
|
||||
gallery_widgets
|
||||
.duplicates
|
||||
.connect_clicked(move |_| show_duplicates(&ui_duplicates));
|
||||
wire_albums(&ui);
|
||||
wire_trash(&ui, &trash_widgets);
|
||||
wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark);
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
use crate::*;
|
||||
|
||||
/// Side of a copy's thumbnail in the duplicates dialog, in px.
|
||||
const COPY_SIZE: i32 = 150;
|
||||
|
||||
/// Each set's copies with their Keep toggles.
|
||||
type SetToggles = Vec<Vec<(PhotoItem, gtk4::ToggleButton)>>;
|
||||
|
||||
/// 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.
|
||||
pub(crate) fn show_duplicates(ui: &Rc<Ui>) {
|
||||
let status = adw::StatusPage::builder()
|
||||
.icon_name("edit-copy-symbolic")
|
||||
.title(gettext("Looking for duplicates…"))
|
||||
.vexpand(true)
|
||||
.build();
|
||||
status.add_css_class("compact");
|
||||
|
||||
let stack = gtk4::Stack::new();
|
||||
stack.add_named(&status, Some("status"));
|
||||
|
||||
let trash = gtk4::Button::builder()
|
||||
.label(gettext("Move to Trash"))
|
||||
.halign(gtk4::Align::End)
|
||||
.sensitive(false)
|
||||
.build();
|
||||
trash.add_css_class("destructive-action");
|
||||
trash.add_css_class("pill");
|
||||
let bottom = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||
bottom.add_css_class("toolbar");
|
||||
bottom.set_halign(gtk4::Align::End);
|
||||
bottom.append(&trash);
|
||||
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&adw::HeaderBar::new());
|
||||
toolbar.set_content(Some(&stack));
|
||||
toolbar.add_bottom_bar(&bottom);
|
||||
toolbar.set_reveal_bottom_bars(false);
|
||||
|
||||
let dialog = adw::Dialog::builder()
|
||||
.title(gettext("Duplicates"))
|
||||
.content_width(760)
|
||||
.content_height(640)
|
||||
.child(&toolbar)
|
||||
.build();
|
||||
dialog.present(ui_window(ui).as_ref());
|
||||
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoDuplicates);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let sets = match rx.recv().await {
|
||||
Ok(Ok(Response::PhotoDuplicates { sets })) => sets,
|
||||
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));
|
||||
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.")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
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.",
|
||||
)));
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
/// 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: >k4::Button) -> gtk4::Widget {
|
||||
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.",
|
||||
))
|
||||
.wrap(true)
|
||||
.xalign(0.0)
|
||||
.build();
|
||||
intro.add_css_class("dim-label");
|
||||
|
||||
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||
content.set_margin_top(12);
|
||||
content.set_margin_bottom(12);
|
||||
content.set_margin_start(18);
|
||||
content.set_margin_end(18);
|
||||
content.append(&intro);
|
||||
|
||||
// Every copy's toggle, by set, so the button can count what goes.
|
||||
let toggles: Rc<RefCell<SetToggles>> = Rc::new(RefCell::new(Vec::new()));
|
||||
for set in sets {
|
||||
let Some(first) = set.first() else {
|
||||
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 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);
|
||||
row.append(&card);
|
||||
set_toggles.push((photo, keep));
|
||||
}
|
||||
let row_scroll = gtk4::ScrolledWindow::builder()
|
||||
.vscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.propagate_natural_height(true)
|
||||
.child(&row)
|
||||
.build();
|
||||
let section = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||
section.append(&heading);
|
||||
section.append(&row_scroll);
|
||||
content.append(§ion);
|
||||
toggles.borrow_mut().push(set_toggles);
|
||||
}
|
||||
|
||||
let sync = {
|
||||
let toggles = toggles.clone();
|
||||
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_sensitive(n > 0);
|
||||
}
|
||||
};
|
||||
sync();
|
||||
for set in toggles.borrow().iter() {
|
||||
for (_, keep) in set {
|
||||
let toggles = toggles.clone();
|
||||
let sync = sync.clone();
|
||||
keep.connect_toggled(move |keep| {
|
||||
// A set always keeps a copy: unticking the last one would trash
|
||||
// the photo itself, which is not what removing duplicates means.
|
||||
if !keep.is_active()
|
||||
&& toggles.borrow().iter().any(|set| {
|
||||
set.iter().any(|(_, t)| t == keep)
|
||||
&& set.iter().all(|(_, t)| !t.is_active())
|
||||
})
|
||||
{
|
||||
keep.set_active(true);
|
||||
return;
|
||||
}
|
||||
sync();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let ui_trash = ui.clone();
|
||||
trash.connect_clicked(move |button| {
|
||||
let uids = doomed(&toggles.borrow());
|
||||
if uids.is_empty() {
|
||||
return;
|
||||
}
|
||||
trash_photos(&ui_trash, uids, true);
|
||||
if let Some(dialog) = button
|
||||
.ancestor(adw::Dialog::static_type())
|
||||
.and_downcast::<adw::Dialog>()
|
||||
{
|
||||
dialog.close();
|
||||
}
|
||||
});
|
||||
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.vexpand(true)
|
||||
.child(&content)
|
||||
.build();
|
||||
scroll.upcast()
|
||||
}
|
||||
|
||||
/// The uids of every copy not kept.
|
||||
fn doomed(toggles: &SetToggles) -> Vec<String> {
|
||||
toggles
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|(_, keep)| !keep.is_active())
|
||||
.map(|(photo, _)| photo.uid.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One copy: its thumbnail, its file name, and whether to keep it.
|
||||
fn copy_card(ui: &Rc<Ui>, photo: &PhotoItem, keep: bool) -> (gtk4::Box, gtk4::ToggleButton) {
|
||||
let picture = gtk4::Picture::builder()
|
||||
.content_fit(gtk4::ContentFit::Cover)
|
||||
.can_shrink(true)
|
||||
.build();
|
||||
let placeholder = gtk4::Image::builder()
|
||||
.icon_name("image-x-generic-symbolic")
|
||||
.pixel_size(24)
|
||||
.build();
|
||||
placeholder.add_css_class("photo-placeholder");
|
||||
let overlay = gtk4::Overlay::new();
|
||||
overlay.set_size_request(COPY_SIZE, COPY_SIZE);
|
||||
overlay.set_child(Some(&placeholder));
|
||||
overlay.add_overlay(&picture);
|
||||
overlay.set_overflow(gtk4::Overflow::Hidden);
|
||||
overlay.add_css_class("memory-cover");
|
||||
want_thumb(ui, photo, &picture);
|
||||
|
||||
let name = photo.name.clone().unwrap_or_default();
|
||||
let label = gtk4::Label::builder()
|
||||
.label(&name)
|
||||
.tooltip_text(&name)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
|
||||
.max_width_chars(18)
|
||||
.build();
|
||||
label.add_css_class("caption");
|
||||
|
||||
let toggle = gtk4::ToggleButton::builder()
|
||||
.label(gettext("Keep"))
|
||||
.active(keep)
|
||||
.build();
|
||||
toggle.add_css_class("pill");
|
||||
|
||||
let card = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||
card.set_size_request(COPY_SIZE, -1);
|
||||
card.append(&overlay);
|
||||
card.append(&label);
|
||||
card.append(&toggle);
|
||||
(card, toggle)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ pub(crate) mod activity;
|
||||
pub(crate) mod albums;
|
||||
pub(crate) mod browser;
|
||||
pub(crate) mod devices;
|
||||
pub(crate) mod duplicates;
|
||||
pub(crate) mod locations;
|
||||
pub(crate) mod login;
|
||||
pub(crate) mod photo_viewer;
|
||||
|
||||
@@ -1447,7 +1447,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
delete_btn.connect_clicked(move |_| {
|
||||
let uid = viewer_delete.uid.borrow().clone();
|
||||
w_delete.close();
|
||||
trash_photos(&ui_delete, vec![uid]);
|
||||
trash_photos(&ui_delete, vec![uid], false);
|
||||
});
|
||||
|
||||
let ui_group = ui.clone();
|
||||
@@ -1573,7 +1573,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
||||
Some("Delete" | "KP_Delete") => {
|
||||
let uid = viewer_key.uid.borrow().clone();
|
||||
w_key.close();
|
||||
trash_photos(&ui_key, vec![uid]);
|
||||
trash_photos(&ui_key, vec![uid], false);
|
||||
}
|
||||
Some("Escape" | "q") => w_key.close(),
|
||||
Some("w") if ctrl => w_key.close(),
|
||||
|
||||
@@ -361,6 +361,7 @@ pub(crate) struct GalleryWidgets {
|
||||
pub(crate) empty_upload: gtk4::Button,
|
||||
pub(crate) empty_import: gtk4::Button,
|
||||
pub(crate) refresh: gtk4::Button,
|
||||
pub(crate) duplicates: gtk4::Button,
|
||||
/// The Select toggle and the bar it reveals.
|
||||
pub(crate) select_btn: gtk4::ToggleButton,
|
||||
pub(crate) select_bar: gtk4::Revealer,
|
||||
@@ -520,6 +521,15 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
import.add_css_class("circular");
|
||||
let refresh = refresh_button();
|
||||
|
||||
// The duplicate finder opens over the page: a review, not a view of it.
|
||||
let duplicates = gtk4::Button::builder()
|
||||
.icon_name("edit-copy-symbolic")
|
||||
.tooltip_text(gettext("Find duplicates"))
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
duplicates.add_css_class("flat");
|
||||
duplicates.add_css_class("circular");
|
||||
|
||||
// Picking photos is a mode, so its control is a toggle rather than a button.
|
||||
let select_btn = gtk4::ToggleButton::builder()
|
||||
.icon_name("selection-mode-symbolic")
|
||||
@@ -749,6 +759,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
header.pack_end(&refresh);
|
||||
header.pack_end(&select_btn);
|
||||
header.pack_end(&import);
|
||||
header.pack_end(&duplicates);
|
||||
|
||||
(
|
||||
frame.upcast(),
|
||||
@@ -770,6 +781,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
||||
empty_upload,
|
||||
empty_import,
|
||||
refresh,
|
||||
duplicates,
|
||||
select_btn,
|
||||
select_bar,
|
||||
select_label,
|
||||
@@ -1849,7 +1861,7 @@ pub(crate) fn confirm_trash_photos(ui: &Rc<Ui>, uids: Vec<String>) {
|
||||
let ui = ui.clone();
|
||||
dialog.connect_response(None, move |_, response| {
|
||||
if response == "trash" {
|
||||
trash_photos(&ui, uids.clone());
|
||||
trash_photos(&ui, uids.clone(), false);
|
||||
}
|
||||
});
|
||||
dialog.present(win.as_ref());
|
||||
@@ -1861,13 +1873,19 @@ pub(crate) fn confirm_trash_photos(ui: &Rc<Ui>, uids: Vec<String>) {
|
||||
/// photos the user just deleted until a refresh lands — reads as a failure. What
|
||||
/// the server refuses comes back, so the grid still ends up telling the truth,
|
||||
/// and what succeeded is one Undo away for as long as the toast is up.
|
||||
pub(crate) fn trash_photos(ui: &Rc<Ui>, uids: Vec<String>) {
|
||||
///
|
||||
/// `files_only` trashes exactly `uids` and leaves the rest of their groups, for
|
||||
/// the duplicate finder; otherwise each photo takes its whole group along.
|
||||
pub(crate) fn trash_photos(ui: &Rc<Ui>, uids: Vec<String>, files_only: bool) {
|
||||
let removed = remove_photos(ui, &uids);
|
||||
set_selection_mode(ui, false);
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::TrashNodes { uids: uids.clone() },
|
||||
Request::TrashNodes {
|
||||
uids: uids.clone(),
|
||||
files_only,
|
||||
},
|
||||
);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
|
||||
@@ -4,6 +4,7 @@ crates/pdfs-gui/src/app/pages/activity.rs
|
||||
crates/pdfs-gui/src/app/pages/albums.rs
|
||||
crates/pdfs-gui/src/app/pages/browser.rs
|
||||
crates/pdfs-gui/src/app/pages/devices.rs
|
||||
crates/pdfs-gui/src/app/pages/duplicates.rs
|
||||
crates/pdfs-gui/src/app/pages/locations.rs
|
||||
crates/pdfs-gui/src/app/pages/login.rs
|
||||
crates/pdfs-gui/src/app/pages/photo_viewer.rs
|
||||
|
||||
+225
-174
File diff suppressed because it is too large.
Load diff
+225
-174
File diff suppressed because it is too large.
Load diff
+225
-174
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user