//! 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, catalog: &Rc>>, session: &Rc Option>, images: &[ImageId], reload: &Rc, ) { let Some(conn) = session() else { window .global::() .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::() .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::() .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, catalog: &Rc>>, session: &Rc Option>, images: &[ImageId], reload: &Rc, ) { let Some(conn) = session() else { window .global::() .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::() .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::().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::() .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, ctl: Rc, catalog: Rc>>, rx: std::sync::mpsc::Receiver, reload: Rc, // 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::().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::().set_library_status(status.into()); if let Some(first) = failed.first() { w.global::() .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) { 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::().set_trash_count(n as i32); window.global::().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)) }