mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-02 06:27:53 -06:00
557 lines
24 KiB
Rust
557 lines
24 KiB
Rust
use super::scan::{busy_synchronizing, running};
|
|
use super::{ImportDialog, ImportUprightMode, ImportWarning, ImportWarningCode, LightcraftApp, Ordering, Value, json};
|
|
|
|
/// Files per batch in the browser build (each batch joins the catalog as it is ready, so the
|
|
/// progress window updates); the desktop's worker uses [`lightcraft_engine::import::batch_size`].
|
|
#[cfg(target_arch = "wasm32")]
|
|
const BATCH: usize = 8;
|
|
|
|
/// A running import (see the module docs). The file-system work — expanding folders, probing,
|
|
/// reading sidecars, copying or placing files — runs on a worker thread
|
|
/// ([`lightcraft_engine::import::ImportJob`]); each batch it readies is added to the catalog on the
|
|
/// UI thread between frames, so a slow drive never stalls the window.
|
|
#[derive(Debug, Default)]
|
|
pub struct ImportTask {
|
|
/// Files and folders to import (handed to the worker when the task starts).
|
|
queue: Vec<String>,
|
|
pub total: usize,
|
|
pub done: usize,
|
|
params: Value,
|
|
pub imported: usize,
|
|
/// Brought back from Recently Deleted (`onDeleted: restore`).
|
|
pub restored: usize,
|
|
pub duplicates: usize,
|
|
/// The library photos the skipped duplicates match (shown when nothing new was added).
|
|
existing: Vec<u64>,
|
|
pub failed: usize,
|
|
/// Move: originals moved, and sources left in place (reported by the engine with a reason).
|
|
pub moved: usize,
|
|
pub kept: usize,
|
|
/// Structured diagnostics from foreign XMP develop settings.
|
|
pub warnings: Vec<ImportWarning>,
|
|
undo0: usize,
|
|
first: Option<u64>,
|
|
/// Reading a folder for the Local view: the photos stay out of the library, and nothing is
|
|
/// selected or announced as added.
|
|
pub(super) browse: bool,
|
|
/// Cancel was pressed: no further files are started; batches already readied are added.
|
|
pub cancelled: bool,
|
|
/// Auto Import (the watched folder): the selection stays as it is, a short toast when done.
|
|
auto: bool,
|
|
/// Auto Import: the selection to keep.
|
|
keep_selection: Option<lightcraft_engine::Selection>,
|
|
/// The selection and source as the import last left them: a change between frames is the
|
|
/// user choosing another photo or view, which the end of the import doesn't take away.
|
|
left: Option<(lightcraft_engine::Selection, lightcraft_engine::LibrarySource)>,
|
|
/// The user chose another photo or view while the import ran.
|
|
moved_on: bool,
|
|
run: Option<ImportRun>,
|
|
/// The import's row in the activity stack (its ✕ and `activity.cancel` set the run's cancel flag).
|
|
guard: Option<lightcraft_engine::activity::TaskGuard>,
|
|
}
|
|
|
|
impl ImportTask {
|
|
/// Whether it has its row in the activity list yet (it gets one on its first tick).
|
|
pub fn listed(&self) -> bool {
|
|
self.guard.is_some()
|
|
}
|
|
|
|
/// An import of `paths` (files or folders) with `library.import` params (without `paths`).
|
|
pub fn new(paths: Vec<String>, params: Value, undo0: usize, browse: bool) -> Self {
|
|
ImportTask { total: paths.len(), queue: paths, params, undo0, browse, ..Default::default() }
|
|
}
|
|
|
|
/// An Auto Import (see [`ImportTask::auto`]).
|
|
pub fn auto(mut self) -> Self {
|
|
self.auto = true;
|
|
self
|
|
}
|
|
|
|
/// `{done, total, imported, cancelled}` for `ui.inspect`.
|
|
pub fn status(&self) -> Value {
|
|
json!({"done": self.done, "total": self.total, "imported": self.imported, "cancelled": self.cancelled})
|
|
}
|
|
}
|
|
|
|
/// Where an import's batches are readied: a worker thread, or (browser build: no threads, and no
|
|
/// slow drives) inline, one batch per frame.
|
|
enum Runner {
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
Thread(std::sync::mpsc::Receiver<lightcraft_engine::import::Prepared>),
|
|
#[cfg(target_arch = "wasm32")]
|
|
Inline { job: Box<lightcraft_engine::import::ImportJob>, queue: Vec<String>, files: Option<std::collections::VecDeque<String>> },
|
|
}
|
|
|
|
struct ImportRun {
|
|
runner: Runner,
|
|
cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
|
total: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
|
done: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
|
opts: lightcraft_engine::import::ImportOptions,
|
|
now: String,
|
|
album: Option<u64>,
|
|
album_name: Option<String>,
|
|
}
|
|
|
|
impl std::fmt::Debug for ImportRun {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("ImportRun").field("cancel", &self.cancel.load(Ordering::Relaxed)).finish()
|
|
}
|
|
}
|
|
|
|
impl Drop for ImportRun {
|
|
/// A dropped import (replaced, or the app closing) stops its worker at the next file.
|
|
fn drop(&mut self) {
|
|
self.cancel.store(true, Ordering::Relaxed);
|
|
}
|
|
}
|
|
|
|
impl ImportRun {
|
|
fn start(
|
|
job: lightcraft_engine::import::ImportJob,
|
|
queue: Vec<String>,
|
|
album: Option<u64>,
|
|
album_name: Option<String>,
|
|
ctx: &egui::Context,
|
|
) -> Result<Self, String> {
|
|
let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
let (total, done) = (job.total.clone(), job.done.clone());
|
|
let (opts, now) = (job.opts.clone(), job.now().to_string());
|
|
let runner = Self::spawn(job, queue, cancel.clone(), ctx)?;
|
|
Ok(ImportRun { runner, cancel, total, done, opts, now, album, album_name })
|
|
}
|
|
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
fn spawn(
|
|
mut job: lightcraft_engine::import::ImportJob,
|
|
queue: Vec<String>,
|
|
cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
|
ctx: &egui::Context,
|
|
) -> Result<Runner, String> {
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
let ctx = ctx.clone();
|
|
let work = move || {
|
|
let files = job.expand(&queue);
|
|
for chunk in files.chunks(lightcraft_engine::import::batch_size()) {
|
|
if cancel.load(Ordering::Relaxed) {
|
|
break;
|
|
}
|
|
let prepared = match lightcraft_engine::guard::catch("import", || job.prepare_files(chunk.to_vec(), &cancel)) {
|
|
Ok(p) => p,
|
|
Err(_) => break, // logged; the files readied so far are added
|
|
};
|
|
if let Err(unsent) = tx.send(prepared) {
|
|
// the import was dropped: nothing will add these, so take back what a Move placed
|
|
unsent.0.rollback();
|
|
break;
|
|
}
|
|
ctx.request_repaint();
|
|
}
|
|
ctx.request_repaint();
|
|
};
|
|
std::thread::Builder::new().name("lc-import".into()).spawn(work).map(|_| Runner::Thread(rx)).map_err(|e| format!("could not start: {e}"))
|
|
}
|
|
|
|
#[cfg(target_arch = "wasm32")]
|
|
fn spawn(
|
|
job: lightcraft_engine::import::ImportJob,
|
|
queue: Vec<String>,
|
|
_cancel: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
|
_ctx: &egui::Context,
|
|
) -> Result<Runner, String> {
|
|
Ok(Runner::Inline { job: Box::new(job), queue, files: None })
|
|
}
|
|
|
|
/// Readied batches (at most a few per frame). `None` once the worker is done.
|
|
fn next_batches(&mut self) -> Option<Vec<lightcraft_engine::import::Prepared>> {
|
|
match &mut self.runner {
|
|
#[cfg(not(target_arch = "wasm32"))]
|
|
Runner::Thread(rx) => {
|
|
let mut out = Vec::new();
|
|
while out.len() < 4 {
|
|
match rx.try_recv() {
|
|
Ok(p) => out.push(p),
|
|
Err(std::sync::mpsc::TryRecvError::Empty) => return Some(out),
|
|
Err(std::sync::mpsc::TryRecvError::Disconnected) => return if out.is_empty() { None } else { Some(out) },
|
|
}
|
|
}
|
|
Some(out)
|
|
}
|
|
#[cfg(target_arch = "wasm32")]
|
|
Runner::Inline { job, queue, files } => {
|
|
if self.cancel.load(Ordering::Relaxed) {
|
|
return None;
|
|
}
|
|
if files.is_none() {
|
|
*files = Some(job.expand(queue).into());
|
|
return Some(Vec::new());
|
|
}
|
|
let Some(files) = files.as_mut() else { return None };
|
|
if files.is_empty() {
|
|
return None;
|
|
}
|
|
let n = files.len().min(BATCH);
|
|
let batch: Vec<String> = files.drain(..n).collect();
|
|
Some(vec![job.prepare_files(batch, &self.cancel)])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Start importing the dialog's checked files (the dialog's OK / `ui.dialog.confirm`). Refused while another
|
|
/// import runs: replacing `app.import` would stop its worker.
|
|
pub fn start(app: &mut LightcraftApp, d: &ImportDialog) -> Result<Value, String> {
|
|
busy_synchronizing(app)?;
|
|
if !d.in_library(app) {
|
|
return Err("another library was opened since the photos were scanned: nothing was imported".into());
|
|
}
|
|
// a browse scan that finishes starts its own import, replacing this one
|
|
if app.import.is_some() || app.scan.as_ref().is_some_and(|t| t.browse) {
|
|
return Err(crate::i18n::tr("An import is running").to_string());
|
|
}
|
|
let queue = d.selected_paths();
|
|
if queue.is_empty() {
|
|
return Err("no photos selected".into());
|
|
}
|
|
let undo0 = app.session.undo.len();
|
|
let mut album = d.album;
|
|
if album.is_none() && !d.new_album.trim().is_empty() {
|
|
let r = app.session.execute("album.create", &json!({"name": d.new_album.trim()})).map_err(|e| e.to_string())?;
|
|
album = r["id"].as_u64();
|
|
}
|
|
let mode = match (d.copy, d.move_files) {
|
|
(false, _) => "add",
|
|
(true, false) => "copy",
|
|
(true, true) => "move",
|
|
};
|
|
let mut params = json!({"mode": mode, "keywords": d.keywords()});
|
|
if !d.on_deleted.is_empty() {
|
|
params["onDeleted"] = json!(d.on_deleted);
|
|
}
|
|
if let Some(a) = album {
|
|
params["album"] = json!(a);
|
|
}
|
|
if !d.preset.is_empty() {
|
|
params["preset"] = json!(d.preset);
|
|
}
|
|
if !d.metadata_preset.is_empty() {
|
|
params["metadataPreset"] = json!(d.metadata_preset);
|
|
}
|
|
if d.copy {
|
|
if !d.destination.trim().is_empty() {
|
|
params["destination"] = json!(d.destination.trim());
|
|
}
|
|
if let Some(o) = d.organize_param()? {
|
|
params["organize"] = json!(o);
|
|
}
|
|
if !d.rename.trim().is_empty() {
|
|
params["rename"] = json!(d.rename.trim());
|
|
params["renameStart"] = json!(1);
|
|
}
|
|
if d.dng && !d.move_files {
|
|
params["dng"] = json!(true);
|
|
}
|
|
}
|
|
let total = queue.len();
|
|
app.import = Some(ImportTask::new(queue, params, undo0, false));
|
|
app.renderer.forget_imports();
|
|
Ok(json!({"importing": total}))
|
|
}
|
|
|
|
/// Start importing `paths` (files or folders) in the background, e.g. dropped on the window.
|
|
pub fn start_paths(app: &mut LightcraftApp, paths: Vec<String>) -> Result<Value, String> {
|
|
busy_synchronizing(app)?;
|
|
if running(app) {
|
|
return Err(crate::i18n::tr("An import is running").to_string());
|
|
}
|
|
let undo0 = app.session.undo.len();
|
|
let total = paths.len();
|
|
app.import = Some(ImportTask::new(paths, json!({"mode": "add"}), undo0, false));
|
|
Ok(json!({"importing": total}))
|
|
}
|
|
|
|
/// Advance the import in progress (called every frame): start its worker, then add the batches it
|
|
/// has readied to the catalog.
|
|
pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) {
|
|
let Some(mut task) = app.import.take() else { return };
|
|
if task.run.is_none() {
|
|
let mut p = task.params.clone();
|
|
p["paths"] = json!(task.queue);
|
|
let started = lightcraft_engine::cmd::library::import_params(&app.session, &p)
|
|
.and_then(|req| Ok((lightcraft_engine::import::ImportJob::new(&mut app.session, req.opts)?, req.album, req.album_name)))
|
|
.map_err(|e| e.to_string())
|
|
.and_then(|(job, album, name)| ImportRun::start(job, std::mem::take(&mut task.queue), album, name, ctx));
|
|
if task.auto {
|
|
task.keep_selection = Some(app.session.selection.clone());
|
|
}
|
|
match started {
|
|
Ok(run) => {
|
|
let label = if task.browse { "Reading folder" } else { "Importing" };
|
|
task.guard = Some(app.session.activity.start("import", label, lightcraft_engine::activity::Cancel::Flag(run.cancel.clone())));
|
|
task.run = Some(run);
|
|
}
|
|
Err(e) => {
|
|
log::warn!("import: {e}");
|
|
app.toast(ctx, crate::i18n::tr_format!("Import failed: {e}", e = e));
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
|
let Some(run) = task.run.as_mut() else { return };
|
|
let batches = run.next_batches();
|
|
let finished = batches.is_none();
|
|
if task.left.as_ref().is_some_and(|(sel, source)| *sel != app.session.selection || *source != app.session.source) {
|
|
task.moved_on = true;
|
|
}
|
|
for prepared in batches.into_iter().flatten() {
|
|
commit_batch(app, &mut task, prepared);
|
|
// arriving photos don't take over the selection
|
|
if let Some(sel) = &task.keep_selection {
|
|
app.session.selection = sel.clone();
|
|
}
|
|
// a Local folder still on screen: its first photo becomes active once there is one (#1447)
|
|
if task.browse && app.session.source == lightcraft_engine::LibrarySource::Folder {
|
|
app.session.keep_active_in_view();
|
|
}
|
|
}
|
|
task.left = Some((app.session.selection.clone(), app.session.source));
|
|
if let Some(run) = &task.run {
|
|
task.total = run.total.load(Ordering::Relaxed).max(task.total);
|
|
task.done = run.done.load(Ordering::Relaxed);
|
|
}
|
|
if let Some(guard) = &task.guard {
|
|
guard.progress(task.done as u64, task.total as u64);
|
|
// ✕ in the stack (or `activity.cancel`): no further files are started
|
|
if guard.is_cancelled() {
|
|
task.cancelled = true;
|
|
}
|
|
}
|
|
if !finished {
|
|
app.import = Some(task);
|
|
return;
|
|
}
|
|
finish(app, ctx, task);
|
|
}
|
|
|
|
/// Add one readied batch to the catalog (an undo step merged into the import's at the end).
|
|
fn commit_batch(app: &mut LightcraftApp, task: &mut ImportTask, prepared: lightcraft_engine::import::Prepared) {
|
|
let Some(run) = task.run.as_ref() else { return };
|
|
let n = prepared.len();
|
|
let (opts, now, album, album_name) = (&run.opts, run.now.as_str(), run.album, run.album_name.clone());
|
|
let r = app.session.execute_fn("library.import", |s| {
|
|
let report = lightcraft_engine::import::commit_prepared(s, opts, now, prepared)?;
|
|
lightcraft_engine::cmd::library::import_batch_done(s, report, album, album_name.as_deref())
|
|
});
|
|
match r {
|
|
Ok(v) => {
|
|
// a new album (named in the params) is created with the first photos; later batches join it
|
|
if let (Some(a), Some(run)) = (v["album"].as_u64(), task.run.as_mut()) {
|
|
run.album = Some(a);
|
|
}
|
|
let len = |k: &str| v[k].as_array().map_or(0, Vec::len);
|
|
task.imported += len("imported");
|
|
task.restored += len("restored");
|
|
task.duplicates += len("duplicates");
|
|
task.existing.extend(v["duplicates"].as_array().into_iter().flatten().filter_map(|d| d["existing"].as_u64()));
|
|
task.failed += len("failed");
|
|
task.moved += len("moved");
|
|
task.kept += len("kept");
|
|
task.warnings.extend(v["warnings"].as_array().into_iter().flatten().filter_map(|warning| serde_json::from_value(warning.clone()).ok()));
|
|
for k in v["kept"].as_array().into_iter().flatten() {
|
|
log::warn!("import: kept {}: {}", k["path"].as_str().unwrap_or(""), k["reason"].as_str().unwrap_or(""));
|
|
}
|
|
if task.first.is_none() {
|
|
task.first = ["imported", "restored"].iter().find_map(|k| v[*k].as_array().and_then(|a| a.first()).and_then(Value::as_u64));
|
|
}
|
|
}
|
|
Err(e) => {
|
|
log::warn!("import: {e}");
|
|
task.failed += n;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn xmp_warning_text(warning: &ImportWarning) -> String {
|
|
let path = &warning.path;
|
|
match warning.code {
|
|
ImportWarningCode::CameraProfile => crate::i18n::tr_format!(
|
|
"XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.",
|
|
profile = warning.profile.as_deref().unwrap_or_default(),
|
|
path = path,
|
|
),
|
|
ImportWarningCode::UprightGeometry => match warning.mode.unwrap_or(ImportUprightMode::Unknown) {
|
|
ImportUprightMode::Off => crate::i18n::tr_format!(
|
|
"Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.",
|
|
path = path,
|
|
),
|
|
ImportUprightMode::Guided => crate::i18n::tr_format!(
|
|
"Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.",
|
|
path = path,
|
|
),
|
|
ImportUprightMode::Auto => crate::i18n::tr_format!(
|
|
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.",
|
|
path = path,
|
|
mode = crate::i18n::tr("Auto")
|
|
),
|
|
ImportUprightMode::Level => crate::i18n::tr_format!(
|
|
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.",
|
|
path = path,
|
|
mode = crate::i18n::tr("Level")
|
|
),
|
|
ImportUprightMode::Vertical => crate::i18n::tr_format!(
|
|
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.",
|
|
path = path,
|
|
mode = crate::i18n::tr("Vertical")
|
|
),
|
|
ImportUprightMode::Full => crate::i18n::tr_format!(
|
|
"Lightroom geometry was not preserved for {path}; {mode} will be recalculated.",
|
|
path = path,
|
|
mode = crate::i18n::tr("Full")
|
|
),
|
|
ImportUprightMode::Unknown => {
|
|
crate::i18n::tr_format!("Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.", path = path,)
|
|
}
|
|
},
|
|
ImportWarningCode::UnmappedXmp => {
|
|
crate::i18n::tr_format!("XMP adjustments were not imported for {path}: {fields}.", path = path, fields = warning.fields.join(", "),)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn xmp_warnings_text(warnings: &[ImportWarning]) -> String {
|
|
crate::i18n::tr_format!(
|
|
"Import warnings ({n}):\n{warnings}",
|
|
n = warnings.len(),
|
|
warnings = warnings.iter().map(xmp_warning_text).collect::<Vec<_>>().join("\n"),
|
|
)
|
|
}
|
|
|
|
/// The name of the import's one undo step.
|
|
fn undo_label(task: &ImportTask) -> String {
|
|
if task.imported == 0 && task.restored > 0 {
|
|
crate::i18n::tr_format!("Restore {} Photo{}", task.restored, crate::i18n::plural_s(task.restored))
|
|
} else if task.restored > 0 {
|
|
crate::i18n::tr_format!("Add {} Photo{}, restore {}", task.imported, crate::i18n::plural_s(task.imported), task.restored)
|
|
} else {
|
|
crate::i18n::tr_format!("Add {} Photo{}", task.imported, crate::i18n::plural_s(task.imported))
|
|
}
|
|
}
|
|
|
|
/// The import is done (or cancelled): one undo step, show and select the first photo (unless the
|
|
/// user chose another meanwhile), say what happened.
|
|
fn finish(app: &mut LightcraftApp, ctx: &egui::Context, task: ImportTask) {
|
|
let steps = app.session.undo.len().saturating_sub(task.undo0);
|
|
app.session.merge_undo(steps, &undo_label(&task));
|
|
if task.auto || task.browse {
|
|
quiet_finish(app, ctx, &task);
|
|
return;
|
|
}
|
|
if let Some(f) = task.first.filter(|_| !task.moved_on) {
|
|
// a view the new photos aren't in (an album, Picks, a Local folder) would leave the active
|
|
// photo off screen: show All Photos, as Lightroom shows the import's own collection (#1446)
|
|
if !app.session.visible().contains(&lightcraft_catalog::PhotoId(f)) {
|
|
let _ = app.run("library.source", json!({"kind": "all"}));
|
|
}
|
|
let _ = app.run("library.select", json!({"ids": [f]}));
|
|
}
|
|
// nothing new, only files the library already has: show where they are (Recently Deleted is
|
|
// easy to miss, and the side panel that lists it starts collapsed)
|
|
if task.imported == 0
|
|
&& task.restored == 0
|
|
&& task.failed == 0
|
|
&& task.warnings.is_empty()
|
|
&& !task.cancelled
|
|
&& show_existing(app, ctx, &task.existing)
|
|
{
|
|
return;
|
|
}
|
|
let mut msg = import_summary(app, &task);
|
|
if task.warnings.is_empty() {
|
|
app.toast(ctx, msg);
|
|
} else {
|
|
msg.push_str("\n\n");
|
|
msg.push_str(&xmp_warnings_text(&task.warnings));
|
|
app.toast_for(ctx, msg, 8.0);
|
|
}
|
|
}
|
|
|
|
/// The end of an automatic import or of one that only browses: a toast only when there is something to warn about.
|
|
fn quiet_finish(app: &mut LightcraftApp, ctx: &egui::Context, task: &ImportTask) {
|
|
if !task.warnings.is_empty() {
|
|
app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0);
|
|
} else if task.auto && task.imported > 0 {
|
|
app.toast(ctx, crate::i18n::tr_format!("Auto Import: added {} photo{}", task.imported, crate::i18n::plural_s(task.imported)));
|
|
} else if !task.auto && task.failed > 0 {
|
|
app.toast(ctx, crate::i18n::tr_format!("{} photo{} not readable", task.failed, crate::i18n::plural_s(task.failed)));
|
|
}
|
|
}
|
|
|
|
/// What an import did, in a sentence.
|
|
fn import_summary(app: &LightcraftApp, task: &ImportTask) -> String {
|
|
let mut msg = if task.cancelled {
|
|
crate::i18n::tr_format!("Import cancelled · {} photo{} added", task.imported, crate::i18n::plural_s(task.imported))
|
|
} else if task.imported == 0 && task.restored > 0 {
|
|
crate::i18n::tr_format!("Restored {} photo{} from Recently Deleted", task.restored, crate::i18n::plural_s(task.restored))
|
|
} else {
|
|
crate::i18n::tr_format!("Imported {} photo{}", task.imported, crate::i18n::plural_s(task.imported))
|
|
};
|
|
if task.restored > 0 && (task.imported > 0 || task.cancelled) {
|
|
msg.push_str(&crate::i18n::tr_format!(" · {} restored from Recently Deleted", task.restored));
|
|
}
|
|
if task.params["mode"] == "move" {
|
|
msg.push_str(&crate::i18n::tr_format!(" · {} moved", task.moved));
|
|
if task.kept > 0 {
|
|
msg.push_str(&crate::i18n::tr_format!(" · {} original{} left at the source", task.kept, crate::i18n::plural_s(task.kept)));
|
|
}
|
|
}
|
|
if task.duplicates > 0 {
|
|
msg.push_str(&crate::i18n::tr_format!(" · {} duplicate{} skipped", task.duplicates, crate::i18n::plural_s(task.duplicates)));
|
|
let deleted = deleted_of(app, &task.existing).len();
|
|
if deleted > 0 {
|
|
msg.push_str(&crate::i18n::tr_format!(" ({} in Recently Deleted)", deleted));
|
|
}
|
|
}
|
|
if task.failed > 0 {
|
|
msg.push_str(&crate::i18n::tr_format!(" · {} not readable", task.failed));
|
|
}
|
|
msg
|
|
}
|
|
|
|
/// The photos among `ids` that are in Recently Deleted (each once).
|
|
fn deleted_of(app: &LightcraftApp, ids: &[u64]) -> Vec<u64> {
|
|
let mut v: Vec<u64> =
|
|
ids.iter().copied().filter(|&id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted)).collect();
|
|
v.sort_unstable();
|
|
v.dedup();
|
|
v
|
|
}
|
|
|
|
/// Select the library photos an import skipped as duplicates of, in Recently Deleted (side panel
|
|
/// opened, with how to get them back) or in All Photos, and say so. `false` when there are none.
|
|
fn show_existing(app: &mut LightcraftApp, ctx: &egui::Context, existing: &[u64]) -> bool {
|
|
let deleted = deleted_of(app, existing);
|
|
let (kind, mut ids) = if deleted.is_empty() { ("all", existing.to_vec()) } else { ("recentlyDeleted", deleted) };
|
|
ids.sort_unstable();
|
|
ids.dedup();
|
|
ids.retain(|&id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some());
|
|
if ids.is_empty() || app.run("library.source", json!({"kind": kind})).is_err() {
|
|
return false;
|
|
}
|
|
let _ = app.run("library.select", json!({"ids": ids}));
|
|
app.ui.left_panel = true;
|
|
let n = ids.len();
|
|
let plural = if n == 1 { "" } else { "s" };
|
|
let msg = if kind == "all" {
|
|
crate::i18n::tr_format!("Already in the library: {} photo{} selected in All Photos", n, plural)
|
|
} else {
|
|
crate::i18n::tr_format!(
|
|
"Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh",
|
|
n,
|
|
plural
|
|
)
|
|
};
|
|
app.toast_for(ctx, msg, 6.0);
|
|
true
|
|
}
|