Files
DarkRoom/ui/dr-ui/src/collections_ui/trash.rs
T
dtourolle 220e9af222 Add the duplicate originals review, from the sidebar and from Settings
"Duplicate originals" appears under the trash in the collections
sidebar while the catalog holds any, and Settings says how many there
are beside the other whole-library passes. Both open one page: every
group with its picture and paths, the copy that stays (tap another path
to change it), a per-group Include box, what the survivor will gain and
any flag, label or face conflict, and why a group was skipped.

The summary is the dry run -- "N groups, M files to trash, K skipped" --
and nothing moves until "Check" has read the copies and "Move M copies
to trash" is pressed. Both run on workers with progress on the page, in
the activity register and, for the move, on the library status line;
Stop ends a job between groups. When it ends the grid, the sidebar and
the trash are refreshed and the survivors' judgements are written to
their sidecars and XMP the way a rating keystroke writes them.

The page is paginated at 30 groups, so a redraw decodes 30 thumbnails
and previews 30 merges whatever the size of the library. Back and
Escape leave it like its own Back button.
2026-09-26 07:19:13 -04:00

313 lines
11 KiB
Rust

//! Soft delete: moving images to the trash on the server, restoring them,
//! and the worker drain both share.
//!
//! A drop is a *soft delete*: the file moves to a trash folder on the
//! server and the catalog records where it came from. Nothing is
//! destroyed until the user empties it, a separate, deliberate action.
use std::cell::RefCell;
use std::rc::Rc;
use dr_catalog::Catalog;
use dr_types::ImageId;
use slint::ComponentHandle;
use crate::{AppWindow, Collections, Library};
use super::controller::CollectionsController;
use super::tree_sync::refresh_tree;
/// Begin a soft delete: plan the moves, then hand them to a worker.
///
/// The plan is built here because it reads the catalog, which is not `Send`; the
/// worker gets paths and ids and needs no catalog to do its half.
#[allow(clippy::too_many_arguments)]
pub(super) fn start_trash(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
session: &Rc<dyn Fn() -> Option<dr_sync::Connection>>,
images: &[ImageId],
reload: &Rc<dyn Fn()>,
) {
let Some(conn) = session() else {
window
.global::<Collections>()
.set_collection_error("Open a library first.".into());
return;
};
let moves = {
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
match crate::trash::plan_trash(cat, &conn.account.root, images) {
Ok(m) => m,
Err(e) => {
window
.global::<Collections>()
.set_collection_error(format!("planning delete: {e}").into());
return;
}
}
};
if moves.is_empty() {
return;
}
log::info!("moving {} image(s) to the trash", moves.len());
window
.global::<Library>()
.set_library_status(format!("Moving {} to the trash…", moves.len()).into());
// The images are leaving the grid; a selection pointing at them would
// survive as a set of ids the user can no longer see.
ctl.clear_selection();
let count = moves.len();
let rx = crate::trash::spawn_move(
conn.clone(),
moves,
crate::trash::Direction::ToTrash,
crate::library::catalog_path(&conn.account),
);
drain_trash(
window.as_weak(),
ctl.clone(),
catalog.clone(),
rx,
reload.clone(),
format!("Moving {count} photograph(s) to the trash"),
);
}
/// TRACES: FR-CAT-15
/// Put trashed images back where they came from.
///
/// The mirror of [`start_trash`], and separate from it rather than a `direction`
/// parameter on one function: the two differ in what they plan, what they report
/// and what they say when the plan comes back empty, and the shared part is the
/// three lines that spawn the worker.
///
/// An image whose origin was never recorded is skipped by
/// [`crate::trash::plan_restore`] rather than guessed at. That can make the plan
/// shorter than the selection, which is why an empty plan is reported here
/// instead of returning silently — the user pressed a button and is owed an
/// answer either way.
pub(super) fn start_restore(
window: &AppWindow,
ctl: &Rc<CollectionsController>,
catalog: &Rc<RefCell<Option<Catalog>>>,
session: &Rc<dyn Fn() -> Option<dr_sync::Connection>>,
images: &[ImageId],
reload: &Rc<dyn Fn()>,
) {
let Some(conn) = session() else {
window
.global::<Collections>()
.set_collection_error("Open a library first.".into());
return;
};
let moves = {
let borrow = catalog.borrow();
let Some(cat) = borrow.as_ref() else { return };
match crate::trash::plan_restore(cat, images) {
Ok(m) => m,
Err(e) => {
window
.global::<Collections>()
.set_collection_error(format!("planning restore: {e}").into());
return;
}
}
};
if moves.is_empty() {
// Said out loud rather than passed over in silence: a button that does
// nothing visible reads as broken, and the reason here is specific.
window.global::<Collections>().set_collection_error(
"Nothing to restore — no record of where these came from.".into(),
);
return;
}
log::info!("restoring {} image(s) from the trash", moves.len());
window
.global::<Library>()
.set_library_status(format!("Restoring {}…", moves.len()).into());
// The images are leaving the trash view, so a selection pointing at them
// would survive as ids the user can no longer see.
ctl.clear_selection();
let count = moves.len();
let rx = crate::trash::spawn_move(
conn.clone(),
moves,
crate::trash::Direction::Restore,
crate::library::catalog_path(&conn.account),
);
drain_trash(
window.as_weak(),
ctl.clone(),
catalog.clone(),
rx,
reload.clone(),
format!("Restoring {count} photograph(s)"),
);
}
/// Drain a trash worker on the UI thread.
///
/// Same shape as the scan and thumbnail drains: an mpsc channel polled by a
/// Slint timer, so nothing blocks the event loop (NFR-P9).
pub(super) fn drain_trash(
weak: slint::Weak<AppWindow>,
ctl: Rc<CollectionsController>,
catalog: Rc<RefCell<Option<Catalog>>>,
rx: std::sync::mpsc::Receiver<crate::trash::TrashMessage>,
reload: Rc<dyn Fn()>,
// What the register calls this operation. Passed in rather than derived
// here: the three callers move files to the trash, back out of it, and
// delete them outright, and "Deleting 40 photographs" is the one word of
// the three that must not appear over a restore.
title: String,
) {
use crate::trash::TrashMessage;
let timer = slint::Timer::default();
let ctl_cb = ctl.clone();
// A server-side MOVE per file, so it is a transfer in the sense that
// matters: it takes as long as the connection is slow, and it can fail
// halfway with the library in two states at once.
let job = ctl.activity.begin(crate::activity::Kind::Trash, title);
timer.start(
slint::TimerMode::Repeated,
std::time::Duration::from_millis(120),
move || {
let Some(w) = weak.upgrade() else { return };
loop {
let msg = match rx.try_recv() {
Ok(m) => m,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
// A worker that died without reporting must not leave the
// status line mid-sentence.
job.fail("stopped without finishing");
stop_trash(&ctl_cb);
return;
}
};
match msg {
TrashMessage::Progress {
done,
total,
failed,
} => {
let status = if failed > 0 {
format!("{done} / {total} · {failed} failed")
} else {
format!("{done} / {total}")
};
job.progress(done, total);
if failed > 0 {
job.detail(format!("{failed} failed"));
}
w.global::<Library>().set_library_status(status.into());
}
TrashMessage::Done { moved, failed } => {
// Reported honestly, including the partial case: "38 of
// 40" is the truth when two files could not be moved,
// and claiming 40 would hide a real problem.
let status = if failed.is_empty() {
format!("{moved} image(s) done")
} else {
format!("{moved} done · {} failed", failed.len())
};
// A partial failure is a failure in the register: the
// library is now in two states at once, which is
// exactly the thing worth keeping on the list.
if failed.is_empty() {
job.finish(status.clone());
} else {
job.fail(status.clone());
}
w.global::<Library>().set_library_status(status.into());
if let Some(first) = failed.first() {
w.global::<Collections>()
.set_collection_error(first.as_str().into());
}
let borrow = catalog.borrow();
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);
// The grid changed: images left the library, or came
// back into it.
reload();
stop_trash(&ctl_cb);
return;
}
}
}
},
);
*ctl.trash_timer.borrow_mut() = Some(timer);
}
fn stop_trash(ctl: &Rc<CollectionsController>) {
if let Some(t) = ctl.trash_timer.borrow().as_ref() {
t.stop();
}
}
/// Refresh the sidebar's trash count and size.
///
/// The size is formatted here rather than in Slint, which has no byte-size
/// formatting — and the number is what tells the user whether emptying is worth
/// it.
pub fn refresh_trash(window: &AppWindow, catalog: &Catalog) {
let (n, bytes) = dr_catalog::trash::summary(catalog.connection()).unwrap_or((0, 0));
window.global::<Collections>().set_trash_count(n as i32);
window.global::<Collections>().set_trash_label(if n == 0 {
slint::SharedString::new()
} else {
format!("{n} · {}", format_bytes(bytes)).into()
});
}
/// Bytes as a human-readable size.
///
/// Binary units, one decimal place past a kilobyte: a RAW library is measured in
/// gigabytes and "3.4 GB" is the figure a photographer reasons about, where
/// 3_650_722_201 is not.
fn format_bytes(bytes: u64) -> String {
const KB: f64 = 1024.0;
let b = bytes as f64;
if bytes < 1024 {
return format!("{bytes} B");
}
for (limit, unit) in [
(KB * KB, "kB"),
(KB * KB * KB, "MB"),
(KB * KB * KB * KB, "GB"),
] {
if b < limit {
return format!("{:.1} {unit}", b / (limit / KB));
}
}
format!("{:.1} TB", b / (KB * KB * KB * KB))
}