mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 12:08:10 -06:00
refactor(lightcraft-ui-egui): split dialogs/import.rs into dialogs/import/
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,558 @@
|
||||
use super::tags::{tag_field_width, tag_help, tag_toggle, unknown_tags_warning};
|
||||
use super::*;
|
||||
|
||||
/// The review dialog's first size (points). It can be resized; the photo grid takes the height.
|
||||
pub(crate) const DIALOG_SIZE: [f32; 2] = [960.0, 720.0];
|
||||
|
||||
/// Memory keys: where the photo grid ended and where the dialog's content (its buttons) ended, last
|
||||
/// frame. Their difference is the room the grid leaves below it.
|
||||
const GRID_BOTTOM: &str = "import-grid-bottom";
|
||||
|
||||
const CONTENT_BOTTOM: &str = "import-content-bottom";
|
||||
|
||||
/// Room below the grid before it has been measured (more than the options and buttons take).
|
||||
pub(super) const DEFAULT_BELOW_GRID: f32 = 360.0;
|
||||
|
||||
/// Height of the photo grid: what is `available` once `below` (options and buttons) is left free,
|
||||
/// in whole points so the window never grows by a fraction each frame, and at least one `row`.
|
||||
pub(crate) fn grid_height(available: f32, below: f32, row: f32) -> f32 {
|
||||
let h = (available - below.max(0.0)).floor();
|
||||
if h.is_finite() { h.max(row) } else { (DIALOG_SIZE[1] - DEFAULT_BELOW_GRID).max(row) }
|
||||
}
|
||||
|
||||
/// Remember where the review dialog's content ends this frame (after its buttons), for
|
||||
/// [`grid_height`] on the next.
|
||||
pub(crate) fn note_dialog_bottom(ui: &egui::Ui) {
|
||||
if !ui.is_sizing_pass() {
|
||||
let y = ui.min_rect().bottom();
|
||||
ui.ctx().data_mut(|m| m.insert_temp(egui::Id::unique(CONTENT_BOTTOM), y));
|
||||
}
|
||||
}
|
||||
|
||||
/// Skipped candidates: (already in the library, identical copies within the source, in Recently
|
||||
/// Deleted). Files of photos in Recently Deleted are not "already in the library", and a file with
|
||||
/// the same bytes as one earlier in the list (no library photo) is a copy in the source.
|
||||
pub(super) fn dup_counts(d: &ImportDialog) -> (usize, usize, usize) {
|
||||
let trashed = d.trashed.iter().filter(|t| **t).count();
|
||||
let in_source = d.candidates.iter().filter(|c| c.duplicate.as_deref() == Some("content") && c.existing.is_none()).count();
|
||||
let dups = d.candidates.iter().filter(|c| c.duplicate.is_some()).count().saturating_sub(trashed).saturating_sub(in_source);
|
||||
(dups, in_source, trashed)
|
||||
}
|
||||
|
||||
/// The dialog body: options, then the candidate grid.
|
||||
pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let n = d.candidates.len();
|
||||
let (dups, in_source, trashed) = dup_counts(d);
|
||||
let sel = d.selected_paths().len();
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} found · {sel} selected", n = n, sel = sel)).color(t.text));
|
||||
if dups > 0 {
|
||||
ui.label(egui::RichText::new(crate::i18n::tr_format!("· {dups} already in the library", dups = dups)).color(t.text_dim));
|
||||
}
|
||||
if in_source > 0 {
|
||||
ui.label(egui::RichText::new(crate::i18n::tr_format!("· {in_source} identical copies", in_source = in_source)).color(t.text_dim))
|
||||
.on_hover_text(crate::i18n::tr("Files with the same content as another file in this import: only the first is imported."));
|
||||
}
|
||||
if trashed > 0 {
|
||||
ui.label(egui::RichText::new(crate::i18n::tr_format!("· {trashed} in Recently Deleted", trashed = trashed)).color(t.text_dim));
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if crate::widgets::text_button(ui, "importNone", crate::i18n::tr("Uncheck All"), false).clicked() {
|
||||
d.checked.iter_mut().for_each(|c| *c = false);
|
||||
}
|
||||
if crate::widgets::text_button(ui, "importAll", crate::i18n::tr("Check All"), false).clicked() {
|
||||
for i in 0..n {
|
||||
d.checked[i] = d.checked_by_default(i);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
// with more than one file type: a toggle per type checks or unchecks all of its files
|
||||
let types = d.file_types();
|
||||
if types.len() > 1 {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("File types:")).color(t.text_dim));
|
||||
for (ty, count) in &types {
|
||||
let on = d.type_checked(ty);
|
||||
let r = crate::widgets::text_button(ui, &format!("importType:{ty}"), &format!("{ty} · {count}"), on)
|
||||
.on_hover_text(crate::i18n::tr(if on { "Uncheck the files of this type" } else { "Check the files of this type" }));
|
||||
if r.clicked() {
|
||||
d.check_type(ty, !on);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// candidate grid
|
||||
let cell = 116.0;
|
||||
let avail = ui.available_width();
|
||||
let cols = ((avail + 6.0) / (cell + 6.0)).floor().max(1.0) as usize;
|
||||
let rows = n.div_ceil(cols);
|
||||
// as tall as the window leaves after the options and buttons below it (measured last frame),
|
||||
// so resizing the dialog resizes the grid
|
||||
let below = ui.ctx().data(|m| {
|
||||
let y = |key: &str| m.get_temp::<f32>(egui::Id::unique(key));
|
||||
y(CONTENT_BOTTOM).zip(y(GRID_BOTTOM)).map(|(content, grid)| content - grid)
|
||||
});
|
||||
let grid_h = grid_height(ui.available_height(), below.unwrap_or(DEFAULT_BELOW_GRID), cell + 26.0);
|
||||
let grid =
|
||||
egui::ScrollArea::vertical().id_salt("import-grid").max_height(grid_h).auto_shrink([false, false]).show_viewport(ui, |ui, viewport| {
|
||||
let (area, _) = ui.allocate_exact_size(vec2(avail, rows as f32 * (cell + 26.0)), Sense::hover());
|
||||
for i in 0..n {
|
||||
let (c, r) = (i % cols, i / cols);
|
||||
let local = Rect::from_min_size(pos2(c as f32 * (cell + 6.0), r as f32 * (cell + 26.0)), vec2(cell, cell + 20.0));
|
||||
if !local.intersects(viewport.expand(cell)) {
|
||||
continue;
|
||||
}
|
||||
let rect = local.translate(area.min.to_vec2());
|
||||
candidate_cell(app, ui, d, i, rect);
|
||||
}
|
||||
});
|
||||
if !ui.is_sizing_pass() {
|
||||
ui.ctx().data_mut(|m| m.insert_temp(egui::Id::unique(GRID_BOTTOM), grid.inner_rect.bottom()));
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
// options
|
||||
if !d.sources.is_empty() {
|
||||
field(ui, "Source", |ui| {
|
||||
// (whether each source is a folder is checked off the UI thread, once)
|
||||
let joined = d.sources.join("\n");
|
||||
let summary = crate::panels::left::fs_cached(ui, "import-source", &joined, f64::INFINITY, |s| {
|
||||
inspect_sources(&s.split('\n').map(str::to_string).collect::<Vec<_>>())
|
||||
})
|
||||
.map(|summary| summary.display())
|
||||
.unwrap_or_else(|| crate::i18n::tr_format!("{} item{}", d.sources.len(), if d.sources.len() == 1 { "" } else { "s" }));
|
||||
let r = ui.label(egui::RichText::new(summary).color(t.text_label)).on_hover_text(joined);
|
||||
register(ui, "label:importSource", r.rect);
|
||||
});
|
||||
ui.label(
|
||||
egui::RichText::new(crate::i18n::tr("Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing)."))
|
||||
.color(t.text_dim)
|
||||
.small(),
|
||||
);
|
||||
}
|
||||
field(ui, "Transfer", |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
if crate::widgets::text_button(ui, "importAdd", crate::i18n::tr("Add in place"), !d.copy)
|
||||
.on_hover_text(crate::i18n::tr("Reference the files where they are"))
|
||||
.clicked()
|
||||
{
|
||||
d.copy = false;
|
||||
d.move_files = false;
|
||||
}
|
||||
let can_copy = app.session.library.as_ref().is_some_and(|l| l.on_disk) || app.services.pick_folder.is_some();
|
||||
let r =
|
||||
ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importCopy", crate::i18n::tr("Copy"), d.copy && !d.move_files)).inner;
|
||||
if r.on_hover_text(crate::i18n::tr("Copy the files (into the library's Originals/, or a folder you choose)")).clicked() {
|
||||
d.copy = true;
|
||||
d.move_files = false;
|
||||
}
|
||||
let r =
|
||||
ui.add_enabled_ui(can_copy, |ui| crate::widgets::text_button(ui, "importMove", crate::i18n::tr("Move"), d.copy && d.move_files)).inner;
|
||||
if r.on_hover_text(crate::i18n::tr("Move the files (into the library's Originals/, or a folder you choose), removing them from the source"))
|
||||
.clicked()
|
||||
{
|
||||
d.copy = true;
|
||||
d.move_files = true;
|
||||
}
|
||||
});
|
||||
let r = ui.label(
|
||||
egui::RichText::new(crate::i18n::tr(match (d.copy, d.move_files) {
|
||||
(true, false) => {
|
||||
"Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are."
|
||||
}
|
||||
(true, true) => {
|
||||
"Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are."
|
||||
}
|
||||
_ => "Add in place: the library references the files where they are; nothing is copied or moved.",
|
||||
}))
|
||||
.color(if d.copy && d.move_files { t.caution } else { t.text_dim })
|
||||
.small(),
|
||||
);
|
||||
register(ui, "label:importModeHelp", r.rect);
|
||||
if d.copy {
|
||||
field(ui, if d.move_files { "Move to" } else { "Copy to" }, |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
let shown = if d.destination.trim().is_empty() { crate::i18n::tr("Library Originals").to_string() } else { d.destination.clone() };
|
||||
ui.label(egui::RichText::new(shown).color(Tokens::get(ui.ctx()).text_label));
|
||||
if app.services.pick_folder.is_some()
|
||||
&& crate::widgets::text_button(ui, "importDest", crate::i18n::tr("Choose…"), false).clicked()
|
||||
&& let Some(f) = app.services.pick_folder.as_mut().and_then(|f| f())
|
||||
{
|
||||
d.destination = f;
|
||||
}
|
||||
if !d.destination.is_empty() && crate::widgets::text_button(ui, "importDestReset", crate::i18n::tr("Library"), false).clicked() {
|
||||
d.destination.clear();
|
||||
}
|
||||
});
|
||||
field(ui, "Folders", |ui| {
|
||||
let opts = [
|
||||
("date", "By day (YYYY/YYYY-MM-DD)"),
|
||||
("month", "By month (YYYY/YYYY-MM)"),
|
||||
("flat", "Into one folder"),
|
||||
("custom", "Custom template…"),
|
||||
];
|
||||
let key = if d.organize.is_empty() { "date".to_string() } else { d.organize.clone() };
|
||||
let cur = opts.iter().find(|o| o.0 == key).map_or(opts[0].1, |o| o.1);
|
||||
let combo = crate::widgets::combo_box("import-organize").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| {
|
||||
for (k, label) in opts {
|
||||
let r = ui.selectable_label(key == k, crate::i18n::tr(label));
|
||||
register(ui, format!("button:importOrganize-{k}"), r.rect);
|
||||
if r.clicked() {
|
||||
d.organize = k.to_string();
|
||||
if k == "custom" && d.folder_template.trim().is_empty() {
|
||||
d.folder_template = DEFAULT_FOLDER_TEMPLATE.into();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
crate::widgets::name_combo(&combo.response, crate::i18n::tr("Organize into"));
|
||||
register(ui, "combo:importOrganize", combo.response.rect);
|
||||
});
|
||||
if d.organize == "custom" {
|
||||
let folders_id = egui::Id::unique("import-folder-template");
|
||||
let tags_open = field(ui, "Template", |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
let w = tag_field_width(ui);
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.folder_template).id(folders_id).hint_text(DEFAULT_FOLDER_TEMPLATE).desired_width(w));
|
||||
register(ui, "field:importFolderTemplate", r.rect);
|
||||
tag_toggle(ui, "importFolders")
|
||||
});
|
||||
if tags_open {
|
||||
tag_help(ui, "importFolders", &mut d.folder_template, folders_id);
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
match lightcraft_engine::rename::folder_template_error(&d.folder_template) {
|
||||
Some(e) => {
|
||||
ui.label(egui::RichText::new(e).color(t.caution));
|
||||
}
|
||||
None => unknown_tags_warning(ui, &d.folder_template),
|
||||
}
|
||||
ui.label(
|
||||
egui::RichText::new(crate::i18n::tr(
|
||||
"Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").",
|
||||
))
|
||||
.color(t.text_dim)
|
||||
.small(),
|
||||
);
|
||||
}
|
||||
if !d.move_files {
|
||||
field(ui, "Raw files", |ui| {
|
||||
let r = ui.checkbox(&mut d.dng, crate::i18n::tr("Copy as DNG"));
|
||||
register(ui, "check:importDng", r.rect);
|
||||
});
|
||||
}
|
||||
let rename_id = egui::Id::unique("import-rename");
|
||||
let tags_open = field(ui, "Rename", |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
let w = tag_field_width(ui);
|
||||
let r = ui.add(
|
||||
egui::TextEdit::singleline(&mut d.rename)
|
||||
.id(rename_id)
|
||||
.hint_text(crate::i18n::tr("keep names — or e.g. {date}_{seq:3}"))
|
||||
.desired_width(w),
|
||||
);
|
||||
register(ui, "field:importRename", r.rect);
|
||||
tag_toggle(ui, "importRename")
|
||||
});
|
||||
if tags_open {
|
||||
tag_help(ui, "importRename", &mut d.rename, rename_id);
|
||||
}
|
||||
unknown_tags_warning(ui, &d.rename);
|
||||
if let Some(example) = example_destination(app, d) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let r = ui.label(egui::RichText::new(crate::i18n::tr_format!("Example: {example}", example = example)).color(t.text_dim));
|
||||
register(ui, "label:importExample", r.rect);
|
||||
ui.label(
|
||||
egui::RichText::new(crate::i18n::tr("Folders and {date} use the capture time; a photo without one uses today's date."))
|
||||
.color(t.text_dim)
|
||||
.small(),
|
||||
);
|
||||
}
|
||||
}
|
||||
if d.trashed.iter().any(|t| *t) {
|
||||
field(ui, "In Recently Deleted", |ui| {
|
||||
let cur = match d.on_deleted.as_str() {
|
||||
"restore" => "Restore them",
|
||||
"fresh" => "Import as new",
|
||||
_ => "Leave them",
|
||||
};
|
||||
let mut pick = None;
|
||||
let combo = crate::widgets::combo_box("import-on-deleted").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| {
|
||||
for (v, label) in [("restore", "Restore them"), ("fresh", "Import as new"), ("", "Leave them")] {
|
||||
if ui.selectable_label(d.on_deleted == v, crate::i18n::tr(label)).clicked() {
|
||||
pick = Some(v);
|
||||
}
|
||||
}
|
||||
});
|
||||
crate::widgets::name_combo(&combo.response, crate::i18n::tr("Recently deleted files"));
|
||||
if let Some(v) = pick {
|
||||
d.set_on_deleted(v);
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(
|
||||
egui::RichText::new(crate::i18n::tr("Restore keeps their edits; Import as new deletes the old record first."))
|
||||
.color(t.text_dim)
|
||||
.small(),
|
||||
);
|
||||
});
|
||||
}
|
||||
let mut albums = crate::album_picker::entries_from(&app.session.catalog, |_| None);
|
||||
albums.retain(|a| a.folder || !a.smart);
|
||||
// Catalog changes while the review is open must not leave a hidden, unselectable destination.
|
||||
d.album = d.album.filter(|id| albums.iter().any(|a| a.id == *id && !a.folder));
|
||||
field(ui, "Album", |ui| {
|
||||
ui.vertical(|ui| {
|
||||
let mut chosen = d.album;
|
||||
let mut extra = None;
|
||||
let empty = crate::i18n::tr(if d.new_album.is_empty() { "None" } else { "New album" });
|
||||
let response = crate::album_picker::AlbumPicker::new("import-album", &mut chosen, &albums)
|
||||
.width(ui.available_width())
|
||||
.empty_text(empty)
|
||||
.show_with_choices(ui, |ui| {
|
||||
let none = ui.selectable_label(d.album.is_none() && d.new_album.is_empty(), crate::i18n::tr("None"));
|
||||
register(ui, "importAlbum:none", none.rect);
|
||||
if none.clicked() {
|
||||
extra = Some(false);
|
||||
}
|
||||
let new = ui.selectable_label(d.album.is_none() && !d.new_album.is_empty(), crate::i18n::tr("New album…"));
|
||||
register(ui, "importAlbum:new", new.rect);
|
||||
if new.clicked() {
|
||||
extra = Some(true);
|
||||
}
|
||||
ui.separator();
|
||||
extra.is_some()
|
||||
});
|
||||
if response.changed() {
|
||||
d.album = chosen;
|
||||
d.new_album.clear();
|
||||
}
|
||||
if let Some(new) = extra {
|
||||
d.album = None;
|
||||
if !new {
|
||||
d.new_album.clear();
|
||||
} else if d.new_album.is_empty() {
|
||||
d.new_album = crate::i18n::tr("Imported Photos").into();
|
||||
}
|
||||
}
|
||||
if d.album.is_none() && !d.new_album.is_empty() {
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.new_album).desired_width(f32::INFINITY));
|
||||
register(ui, "field:importAlbumName", r.rect);
|
||||
}
|
||||
});
|
||||
});
|
||||
field(ui, "Preset", |ui| {
|
||||
let cur = app
|
||||
.session
|
||||
.presets
|
||||
.iter()
|
||||
.find(|p| p.id == d.preset)
|
||||
.map(|p| crate::i18n::builtin_label(&p.name, p.builtin).to_string())
|
||||
.unwrap_or_else(|| crate::i18n::tr("None").into());
|
||||
let combo = crate::widgets::combo_box("import-preset").selected_text(cur).height(300.0).show_ui(ui, |ui| {
|
||||
if ui.selectable_label(d.preset.is_empty(), crate::i18n::tr("None")).clicked() {
|
||||
d.preset.clear();
|
||||
}
|
||||
let selected = (!d.preset.is_empty()).then_some(d.preset.as_str());
|
||||
if let Some(pid) = crate::panels::presets::picker_rows(ui, "import-preset", &app.session.presets, selected) {
|
||||
d.preset = pid;
|
||||
}
|
||||
});
|
||||
crate::widgets::name_combo(&combo.response, crate::i18n::tr("Preset"));
|
||||
});
|
||||
if !app.session.metadata_presets.is_empty() {
|
||||
field(ui, "Metadata", |ui| {
|
||||
let cur = if d.metadata_preset.is_empty() { crate::i18n::tr("None").to_string() } else { d.metadata_preset.clone() };
|
||||
let combo = crate::widgets::combo_box("import-metadata").selected_text(cur).show_ui(ui, |ui| {
|
||||
if ui.selectable_label(d.metadata_preset.is_empty(), crate::i18n::tr("None")).clicked() {
|
||||
d.metadata_preset.clear();
|
||||
}
|
||||
for m in &app.session.metadata_presets {
|
||||
if ui.selectable_label(d.metadata_preset == m.name, &m.name).clicked() {
|
||||
d.metadata_preset = m.name.clone();
|
||||
}
|
||||
}
|
||||
});
|
||||
crate::widgets::name_combo(&combo.response, crate::i18n::tr("Metadata preset"));
|
||||
});
|
||||
}
|
||||
field(ui, "Keywords", |ui| {
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut d.keywords).hint_text(crate::i18n::tr("comma, separated")).desired_width(f32::INFINITY));
|
||||
register(ui, "field:importKeywords", r.rect);
|
||||
});
|
||||
}
|
||||
|
||||
/// Draw one import-dialog candidate cell: register `import:{i}`, request the cell's thumbnail,
|
||||
/// paint the selection checkbox, and label duplicates, unreadable or trashed files. A click on an
|
||||
/// importable cell toggles it (Shift extends the range); `rect` is the full cell and the thumbnail
|
||||
/// uses its width as height.
|
||||
fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog, i: usize, rect: Rect) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let c = d.candidates[i].clone();
|
||||
let ok = d.importable(i);
|
||||
let img = Rect::from_min_size(rect.min, vec2(rect.width(), rect.width()));
|
||||
let resp = ui.interact(img, egui::Id::unique(("import-cell", i)), Sense::click());
|
||||
crate::widgets::announce(&resp, c.name.clone());
|
||||
register(ui, format!("import:{i}"), img);
|
||||
let p = ui.painter();
|
||||
p.rect_filled(img, 3.0, t.skeleton);
|
||||
// thumbnail (background job; the grid only asks for the cells in view)
|
||||
let slot = Slot::Import(i as u32);
|
||||
if !app.renderer.textures.contains_key(&slot)
|
||||
&& let Some(job) = app.session.candidate_thumb_job(&c, 192, i as u64)
|
||||
{
|
||||
app.renderer.request_quick(slot, job, 4);
|
||||
}
|
||||
if let Some(tex) = app.renderer.textures.get(&slot) {
|
||||
let [tw, th] = tex.size;
|
||||
let s = ((img.width() - 8.0) / tw as f32).min((img.height() - 8.0) / th as f32);
|
||||
let fit = Rect::from_center_size(img.center(), vec2(tw as f32 * s, th as f32 * s));
|
||||
let tint = if ok { Color32::WHITE } else { Color32::from_gray(110) };
|
||||
p.image(tex.tex.id(), fit, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), tint);
|
||||
}
|
||||
let on = d.checked[i] && ok;
|
||||
if on {
|
||||
p.rect_stroke(img, 3.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
|
||||
}
|
||||
// checkbox
|
||||
let cb = Rect::from_min_size(img.min + vec2(6.0, 6.0), vec2(16.0, 16.0));
|
||||
p.rect(cb, 3.0, if on { t.accent } else { Color32::from_black_alpha(150) }, Stroke::new(1.0, Color32::from_gray(200)), StrokeKind::Inside);
|
||||
if on {
|
||||
p.line_segment([cb.left_center() + vec2(3.5, 0.5), cb.center_bottom() + vec2(-1.0, -4.0)], Stroke::new(2.0, Color32::WHITE));
|
||||
p.line_segment([cb.center_bottom() + vec2(-1.0, -4.0), cb.right_top() + vec2(-3.5, 4.0)], Stroke::new(2.0, Color32::WHITE));
|
||||
}
|
||||
let in_trash = c.existing.is_some_and(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted));
|
||||
let badge = match (&c.duplicate, &c.error) {
|
||||
// checked with "Leave them", the file goes without `onDeleted`: the engine restores it
|
||||
(Some(_), _) if in_trash && on => Some(if d.on_deleted == "fresh" { "Will be replaced" } else { "Will be restored" }),
|
||||
(Some(_), _) if in_trash => Some("In Recently Deleted"),
|
||||
(Some(r), _) if r == "path" => Some("In library"),
|
||||
(Some(_), _) => Some("Duplicate"),
|
||||
(None, Some(_)) => Some("Unreadable"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(b) = badge {
|
||||
let g = p.layout_no_wrap(crate::i18n::tr(b).to_string(), t.semibold(10.0), Color32::WHITE);
|
||||
let br = badge_rect(img, cb, g.size());
|
||||
p.rect_filled(br, 3.0, Color32::from_rgba_unmultiplied(170, 60, 50, 220));
|
||||
p.galley(br.min + vec2(4.0, 2.0), g, Color32::WHITE);
|
||||
}
|
||||
let name = if c.name.chars().count() > 18 { format!("{}…", c.name.chars().take(17).collect::<String>()) } else { c.name.clone() };
|
||||
p.text(pos2(rect.left() + 2.0, img.bottom() + 9.0), Align2::LEFT_CENTER, name, t.font(10.5), if ok { t.text_label } else { t.text_dim });
|
||||
let tip = format!(
|
||||
"{}\n{} × {} · {} · {:.1} MB{}",
|
||||
c.path,
|
||||
c.width,
|
||||
c.height,
|
||||
c.format,
|
||||
c.file_size as f64 / 1e6,
|
||||
c.captured.as_deref().map(|d| format!("\n{}", d.replace('T', " "))).unwrap_or_default()
|
||||
);
|
||||
let resp = resp.on_hover_text(tip);
|
||||
if resp.clicked() && ok {
|
||||
let shift = ui.input(|input| input.modifiers.shift);
|
||||
d.click(i, shift);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a cell's badge goes: top right, or under the checkbox when the text is too wide to sit
|
||||
/// beside it — drawn over the checkbox it hid it (issue #1285).
|
||||
pub(super) fn badge_rect(img: Rect, cb: Rect, text: Vec2) -> Rect {
|
||||
let size = text + vec2(8.0, 4.0);
|
||||
let left = img.right() - size.x - 4.0;
|
||||
let top = if left < cb.right() + 4.0 { cb.bottom() + 4.0 } else { img.top() + 6.0 };
|
||||
Rect::from_min_size(pos2(left, top), size)
|
||||
}
|
||||
|
||||
/// The folder template the Custom choice starts with (`2026/20260114/`).
|
||||
pub const DEFAULT_FOLDER_TEMPLATE: &str = "{date:%Y}/{date:%Y%m%d}";
|
||||
|
||||
/// Where the first selected photo would be copied to (destination, folders, name), for the
|
||||
/// dialog's example line; `None` without a photo or with an unusable folder template.
|
||||
pub fn example_destination(app: &LightcraftApp, d: &ImportDialog) -> Option<String> {
|
||||
let c = d.candidates.iter().zip(&d.checked).enumerate().find(|(i, (_, on))| **on && d.importable(*i)).map(|(_, (c, _))| c)?;
|
||||
let organize = match d.organize_param().ok()? {
|
||||
Some(o) => lightcraft_engine::import::Organize::parse(&o)?,
|
||||
None => Default::default(),
|
||||
};
|
||||
let mut q = lightcraft_catalog::Photo::new(
|
||||
lightcraft_catalog::PhotoId(0),
|
||||
lightcraft_catalog::Source::File { path: c.path.clone() },
|
||||
&c.name,
|
||||
&c.format,
|
||||
0,
|
||||
0,
|
||||
// a photo without a capture time is dated by the import (now)
|
||||
&(app.session.clock)(),
|
||||
);
|
||||
q.captured = c.captured.clone();
|
||||
// the probe's metadata, so {camera}, {title}… preview as they will import
|
||||
if let Some(info) = app.session.import_probes.get(&c.path) {
|
||||
q.meta = info.meta.clone();
|
||||
}
|
||||
let name = if d.rename.trim().is_empty() { c.name.clone() } else { lightcraft_engine::rename::expand(d.rename.trim(), &q, 1) };
|
||||
let root = if d.destination.trim().is_empty() {
|
||||
app.session.library.as_ref().map_or_else(|| "Originals".to_string(), |l| l.dir.join("Originals").to_string_lossy().to_string())
|
||||
} else {
|
||||
d.destination.trim().trim_end_matches(['/', '\\']).to_string()
|
||||
};
|
||||
let sep = std::path::MAIN_SEPARATOR_STR;
|
||||
let mut parts = vec![root];
|
||||
parts.extend(organize.folders(&q));
|
||||
parts.push(name);
|
||||
Some(parts.join(sep))
|
||||
}
|
||||
|
||||
/// The import source in a few words: a folder's name, a file's name, or "N files and folders".
|
||||
pub fn source_summary(sources: &[String]) -> String {
|
||||
inspect_sources(sources).display()
|
||||
}
|
||||
|
||||
/// Facts cached by the filesystem worker, formatted in the UI thread's active language.
|
||||
/// Caching a translated String on the worker would always use its default language (English)
|
||||
/// and also retain that language after the user changes the UI language.
|
||||
#[derive(Clone)]
|
||||
enum SourceSummary {
|
||||
Folder(String),
|
||||
File(String),
|
||||
Many { files: usize, folders: usize },
|
||||
}
|
||||
|
||||
impl SourceSummary {
|
||||
fn display(&self) -> String {
|
||||
match self {
|
||||
Self::Folder(name) => crate::i18n::tr_format!("Folder “{}” (and its subfolders)", name),
|
||||
Self::File(name) => name.clone(),
|
||||
Self::Many { files, folders } => match (files, folders) {
|
||||
(_, 0) => crate::i18n::tr_format!("{} files", files),
|
||||
(0, f) => crate::i18n::tr_format!("{f} folders (and their subfolders)", f = f),
|
||||
(_, f) => crate::i18n::tr_format!("{} files and {f} folder{}", files, if *f == 1 { "" } else { "s" }, f = f),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inspect_sources(sources: &[String]) -> SourceSummary {
|
||||
let name = |p: &str| std::path::Path::new(p).file_name().map_or_else(|| p.to_string(), |n| n.to_string_lossy().to_string());
|
||||
match sources {
|
||||
[one] if std::path::Path::new(one).is_dir() => SourceSummary::Folder(name(one)),
|
||||
[one] => SourceSummary::File(name(one)),
|
||||
many => {
|
||||
let folders = many.iter().filter(|p| std::path::Path::new(p.as_str()).is_dir()).count();
|
||||
SourceSummary::Many { files: many.len().saturating_sub(folders), folders }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A labelled row (fixed label column).
|
||||
fn field<R>(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
ui.allocate_ui_with_layout(vec2(78.0, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_min_width(78.0);
|
||||
ui.label(egui::RichText::new(crate::i18n::tr(label)).color(t.text_label));
|
||||
});
|
||||
add(ui)
|
||||
})
|
||||
.inner
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
use super::task::ImportTask;
|
||||
use super::*;
|
||||
|
||||
/// A folder scan running on a worker thread (a network share can take minutes to read; the
|
||||
/// window must keep answering meanwhile).
|
||||
pub struct ScanTask {
|
||||
progress: std::sync::Arc<ScanProgress>,
|
||||
rx: std::sync::mpsc::Receiver<ScanOutput>,
|
||||
/// Open the review with "copy into the library" checked (a camera / card).
|
||||
pub copy: bool,
|
||||
/// Open the review with this album chosen (an album's context menu ▸ Import…).
|
||||
pub album: Option<u64>,
|
||||
/// Browsing a folder (Local): the photos are read in place instead of opening the review.
|
||||
pub(super) browse: bool,
|
||||
/// What is being scanned (the review's source).
|
||||
sources: Vec<String>,
|
||||
/// The scan's row in the activity stack (its ✕ and `activity.cancel` set `progress.cancel`).
|
||||
guard: lightcraft_engine::activity::TaskGuard,
|
||||
}
|
||||
|
||||
/// The activity row of a scan: the review's ("Scanning folder") or the Local view's ("Reading folder").
|
||||
fn scan_guard(app: &LightcraftApp, progress: &ScanProgress, browse: bool) -> lightcraft_engine::activity::TaskGuard {
|
||||
let label = if browse { "Reading folder" } else { "Scanning folder" };
|
||||
app.session.activity.start("scan", label, lightcraft_engine::activity::Cancel::Flag(progress.cancel.clone()))
|
||||
}
|
||||
|
||||
/// An import and a Synchronize Folder never run at once: each readies its files against the
|
||||
/// library as it was when it started, so both could add the same file.
|
||||
pub(crate) fn busy_synchronizing(app: &LightcraftApp) -> Result<(), String> {
|
||||
if app.sync_run.is_some() { Err(crate::i18n::tr("A folder is being synchronized").to_string()) } else { Ok(()) }
|
||||
}
|
||||
|
||||
/// Settings → Import → "Show the import review when a camera or card is connected", called with
|
||||
/// each device scan while it's on. The first look only notes the cards already in. A card that
|
||||
/// shows up after that opens Import from Device once nothing else is open, and a card that is
|
||||
/// taken out and put back in asks again.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn cards_seen(app: &mut LightcraftApp, now: Vec<lightcraft_engine::devices::Device>) {
|
||||
let Some(known) = app.ui.known_cards.as_mut() else {
|
||||
app.ui.known_cards = Some(now.into_iter().map(|d| d.root).collect());
|
||||
return;
|
||||
};
|
||||
known.retain(|root| now.iter().any(|d| d.root == *root));
|
||||
let Some(card) = now.into_iter().find(|d| !known.contains(&d.root)) else { return };
|
||||
// busy: ask when the window is free (the card isn't noted until then)
|
||||
if app.import.is_some() || app.scan.is_some() || app.sync_run.is_some() || app.lightroom.is_some() || app.ui.dialog.is_some() {
|
||||
return;
|
||||
}
|
||||
known.push(card.root);
|
||||
if let Err(e) = app.run("file.addFromDevice", json!({"path": card.path})) {
|
||||
log::warn!("import from {}: {e}", card.name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan `paths` in the background, then open the review dialog (see [`poll_scan`]).
|
||||
pub fn open(app: &mut LightcraftApp, paths: Vec<String>) -> Result<Value, String> {
|
||||
if app.scan.is_some() {
|
||||
return Err("a scan is already running".into());
|
||||
}
|
||||
if app.review_pending.is_some() {
|
||||
return Err("an import review is waiting for the open dialog to close".into());
|
||||
}
|
||||
let sources = paths.clone();
|
||||
let (input, paths) = ScanInput::new(&mut app.session, &paths);
|
||||
let progress = std::sync::Arc::new(ScanProgress::default());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let p = progress.clone();
|
||||
let job = move || {
|
||||
let _ = tx.send(scan_with(input, &paths, &p));
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::thread::spawn(job);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
job();
|
||||
let guard = scan_guard(app, &progress, false);
|
||||
app.scan = Some(ScanTask { progress, rx, copy: false, album: None, browse: false, sources, guard });
|
||||
Ok(json!({"scanning": true}))
|
||||
}
|
||||
|
||||
/// Show a folder's photos in the Local view (`library.browse` from the UI): the view switches at
|
||||
/// once, the folder is listed and read on a worker thread (a network share can take minutes),
|
||||
/// and the photos then join the view in small batches under a progress window. A browse already
|
||||
/// running is replaced; the import review's scan is not.
|
||||
pub fn browse(app: &mut LightcraftApp, path: &str, subfolders: Option<bool>) -> Result<Value, String> {
|
||||
let dir = std::path::absolute(std::path::Path::new(path)).map_err(|e| e.to_string())?;
|
||||
if !dir.is_dir() {
|
||||
return Err(format!("{path}: not a folder"));
|
||||
}
|
||||
if app.scan.as_ref().is_some_and(|t| !t.browse) || app.import.as_ref().is_some_and(|t| !t.browse) {
|
||||
return Err("an import is running".into());
|
||||
}
|
||||
let dir_s = dir.to_string_lossy().trim_end_matches(['/', '\\']).to_string();
|
||||
let subfolders = subfolders.unwrap_or_else(|| app.session.browse.as_ref().is_some_and(|b| b.subfolders));
|
||||
let running = app.scan.as_ref().is_some_and(|t| t.browse) || app.import.as_ref().is_some_and(|t| t.browse);
|
||||
if running && app.session.browse.as_ref().is_some_and(|b| b.path == dir_s && b.subfolders == subfolders) {
|
||||
// already reading this folder: clicking it again must not restart the progress
|
||||
app.session.source = lightcraft_engine::LibrarySource::Folder;
|
||||
app.session.keep_active_in_view();
|
||||
return Ok(json!({"path": dir_s, "subfolders": subfolders, "scanning": true}));
|
||||
}
|
||||
if let Some(t) = app.scan.take() {
|
||||
t.progress.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
app.import = None;
|
||||
app.session.browse = Some(lightcraft_engine::Browse { path: dir_s.clone(), subfolders });
|
||||
app.session.source = lightcraft_engine::LibrarySource::Folder;
|
||||
// the photos the library already knows from this folder show at once: keep or pick the active one
|
||||
app.session.keep_active_in_view();
|
||||
let (input, _) = ScanInput::new(&mut app.session, std::slice::from_ref(&dir_s));
|
||||
let progress = std::sync::Arc::new(ScanProgress::default());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let p = progress.clone();
|
||||
let root = dir_s.clone();
|
||||
let job = move || {
|
||||
let files: Vec<String> = if subfolders {
|
||||
lightcraft_engine::import::expand(&[root], None)
|
||||
} else {
|
||||
let mut v: Vec<String> = std::fs::read_dir(&root)
|
||||
.map(|rd| {
|
||||
rd.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|f| {
|
||||
f.is_file()
|
||||
&& lightcraft_engine::import::is_supported(f)
|
||||
&& !f.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.'))
|
||||
})
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
let _ = tx.send(scan_with(input, &files, &p));
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
std::thread::spawn(job);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
job();
|
||||
let guard = scan_guard(app, &progress, true);
|
||||
app.scan = Some(ScanTask { progress, rx, copy: false, album: None, browse: true, sources: Vec::new(), guard });
|
||||
app.renderer.forget_imports();
|
||||
Ok(json!({"path": dir_s, "subfolders": subfolders, "scanning": true}))
|
||||
}
|
||||
|
||||
/// Collect a finished scan and open the review (called every frame).
|
||||
pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
show_pending_review(app, ctx);
|
||||
let Some(task) = app.scan.as_ref() else { return };
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
let out = match task.rx.try_recv() {
|
||||
Ok(o) => o,
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => {
|
||||
if task.guard.is_cancelled() {
|
||||
// ✕: the worker stops at its next file; don't wait for it (a NAS read can take a while)
|
||||
app.scan = None;
|
||||
return;
|
||||
}
|
||||
let (total, done) = (task.progress.total.load(Ordering::Relaxed), task.progress.done.load(Ordering::Relaxed));
|
||||
task.guard.progress(done as u64, total as u64);
|
||||
// while the folders are listed the total isn't known: say what is happening instead
|
||||
task.guard.detail(if total == 0 { crate::i18n::tr("Looking for photos…") } else { "" });
|
||||
return;
|
||||
}
|
||||
Err(_) => {
|
||||
app.scan = None;
|
||||
app.toast(ctx, crate::i18n::tr("Scan failed"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(task) = app.scan.take() else { return };
|
||||
if task.progress.cancel.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
app.session.import_probes = out.probes;
|
||||
if task.browse {
|
||||
// the folder and the ones its photos are in count as browsed now, as `library.browse` records them, so
|
||||
// their untouched Local photos aren't forgotten while the folder is in use
|
||||
if let Some(root) = app.session.browse.as_ref().map(|b| b.path.clone()) {
|
||||
let shown: Vec<String> = out.candidates.iter().map(|c| c.path.clone()).collect();
|
||||
app.session.note_browsed(&root, &shown);
|
||||
}
|
||||
// records of files gone from the folder (deleted or moved by another app) that nothing was changed on go,
|
||||
// as `library.browse` forgets them (issue #1392)
|
||||
if let Some(b) = app.session.browse.clone() {
|
||||
let listed: Vec<String> = out.candidates.iter().map(|c| c.path.clone()).collect();
|
||||
app.session.forget_gone_from(&b.path, b.subfolders, &listed);
|
||||
}
|
||||
// what the library doesn't know yet joins the Local view
|
||||
let queue: Vec<String> =
|
||||
out.candidates.iter().filter(|c| c.duplicate != Some("path".into()) && c.error.is_none()).map(|c| c.path.clone()).collect();
|
||||
if !queue.is_empty() {
|
||||
let undo0 = app.session.undo.len();
|
||||
let total = queue.len();
|
||||
let _ = total;
|
||||
app.import = Some(ImportTask::new(queue, json!({"mode": "add", "local": true}), undo0, true));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if out.candidates.is_empty() {
|
||||
app.toast(ctx, crate::i18n::tr("No photos found"));
|
||||
return;
|
||||
}
|
||||
app.renderer.forget_imports();
|
||||
let mut d = ImportDialog::new(out.candidates);
|
||||
let trashed = d
|
||||
.candidates
|
||||
.iter()
|
||||
.map(|c| {
|
||||
c.duplicate.is_some()
|
||||
&& c.existing.is_some_and(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(id)).is_some_and(|p| p.deleted))
|
||||
})
|
||||
.collect();
|
||||
d.set_trashed(trashed);
|
||||
d.copy = task.copy;
|
||||
// only an album the review offers (it may have been deleted while the scan ran)
|
||||
d.album = task.album.filter(|id| app.session.catalog.album(lightcraft_catalog::AlbumId(*id)).is_some_and(|a| !a.folder && !a.is_smart()));
|
||||
d.sources = task.sources;
|
||||
d.library = Some(app.session.library_generation());
|
||||
app.review_pending = Some(crate::state::Dialog::Import { opts: Box::new(d) });
|
||||
show_pending_review(app, ctx);
|
||||
}
|
||||
|
||||
/// Open the review of a finished scan once no other dialog is open (a face model's licence, Settings…
|
||||
/// opened while the scan ran stays), checked again shortly while it waits. A review whose library was
|
||||
/// closed meanwhile (Settings can open another) is dropped.
|
||||
fn show_pending_review(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
if app.review_pending.is_none() {
|
||||
return;
|
||||
}
|
||||
if let Some(crate::state::Dialog::Import { opts }) = &app.review_pending
|
||||
&& !opts.in_library(app)
|
||||
{
|
||||
app.review_pending = None;
|
||||
return;
|
||||
}
|
||||
if app.ui.dialog.is_none() {
|
||||
app.ui.dialog = app.review_pending.take();
|
||||
} else {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
/// Tests: a review scan that never ends while the returned value lives, so a drop meets it running.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn stall_scan(app: &mut LightcraftApp) -> Box<dyn std::any::Any> {
|
||||
stall_scan_of(app, false)
|
||||
}
|
||||
|
||||
/// Tests: the same, for a Local folder browse scan (`browse`) instead of the review's scan.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn stall_scan_of(app: &mut LightcraftApp, browse: bool) -> Box<dyn std::any::Any> {
|
||||
let progress = std::sync::Arc::new(ScanProgress::default());
|
||||
let (tx, rx) = std::sync::mpsc::channel::<ScanOutput>();
|
||||
let guard = scan_guard(app, &progress, false);
|
||||
app.scan = Some(ScanTask { progress, rx, copy: false, album: None, browse, sources: Vec::new(), guard });
|
||||
Box::new(tx)
|
||||
}
|
||||
|
||||
/// Whether an import or any scan (the review's, or a Local folder browse) is running, or a finished scan's
|
||||
/// review waits for a dialog to close, which a drop of photos must not replace: `open` refuses while a scan
|
||||
/// runs, and a browse that finishes replaces `app.import`.
|
||||
pub fn running(app: &LightcraftApp) -> bool {
|
||||
app.import.is_some() || app.scan.is_some() || app.review_pending.is_some()
|
||||
}
|
||||
|
||||
impl ScanTask {
|
||||
/// `{done, total}` for `ui.inspect` (total is 0 while the folders are still being listed).
|
||||
pub fn status(&self) -> Value {
|
||||
json!({"done": self.progress.done.load(Ordering::Relaxed), "total": self.progress.total.load(Ordering::Relaxed)})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use super::*;
|
||||
|
||||
/// Reserve the actual translated tag button width beside a template field.
|
||||
pub(crate) fn tag_field_width(ui: &egui::Ui) -> f32 {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let galley = ui.painter().layout_no_wrap(crate::i18n::tr("Tags").to_string(), t.semibold(11.5), t.text);
|
||||
(ui.available_width() - (galley.size().x + 18.0).max(37.0) - ui.spacing().item_spacing.x).max(80.0)
|
||||
}
|
||||
|
||||
/// The "Tags" toggle beside a template field (`button:<key>Tags`); returns whether the tag help
|
||||
/// ([`tag_help`]) is open.
|
||||
pub(crate) fn tag_toggle(ui: &mut egui::Ui, key: &str) -> bool {
|
||||
let id = egui::Id::unique((key, "tags-open"));
|
||||
let mut open = ui.ctx().data(|d| d.get_temp::<bool>(id)).unwrap_or(false);
|
||||
let r = crate::widgets::text_button(ui, &format!("{key}Tags"), crate::i18n::tr("Tags"), open)
|
||||
.on_hover_text(crate::i18n::tr("Show the template tags; click one to insert it"));
|
||||
if r.clicked() {
|
||||
open = !open;
|
||||
ui.ctx().data_mut(|d| d.insert_temp(id, open));
|
||||
}
|
||||
open
|
||||
}
|
||||
|
||||
/// "Unknown tag {x} stays as typed" under a template field, when it has one.
|
||||
pub(crate) fn unknown_tags_warning(ui: &mut egui::Ui, template: &str) {
|
||||
let unknown = lightcraft_engine::rename::unknown_tokens(template);
|
||||
if !unknown.is_empty() {
|
||||
let s = if unknown.len() == 1 { "" } else { "s" };
|
||||
let text = crate::i18n::tr_format!(
|
||||
"Unknown tag{s} {} stay{} as typed (see Tags)",
|
||||
unknown.join(" "),
|
||||
if unknown.len() == 1 { "s" } else { "" },
|
||||
s = s
|
||||
);
|
||||
ui.label(egui::RichText::new(text).color(Tokens::get(ui.ctx()).caution));
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert `tag` into `text` at the text cursor of the field `edit_id` (replacing its selection);
|
||||
/// appended when the field never had a cursor. The cursor ends up after the tag.
|
||||
pub(crate) fn insert_at_cursor(ctx: &egui::Context, edit_id: egui::Id, text: &mut String, tag: &str) {
|
||||
use egui::text::{CCursor, CCursorRange};
|
||||
let mut state = egui::text_edit::TextEditState::load(ctx, edit_id).unwrap_or_default();
|
||||
let n = text.chars().count();
|
||||
let (a, b) = state.cursor.char_range().map_or((n, n), |r| {
|
||||
let (x, y) = (r.primary.index.0.min(n), r.secondary.index.0.min(n));
|
||||
(x.min(y), x.max(y))
|
||||
});
|
||||
let byte = |c: usize| text.char_indices().nth(c).map_or(text.len(), |(i, _)| i);
|
||||
let (ba, bb) = (byte(a), byte(b));
|
||||
text.replace_range(ba..bb, tag);
|
||||
state.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(a + tag.chars().count()))));
|
||||
state.store(ctx, edit_id);
|
||||
ctx.memory_mut(|m| m.request_focus(edit_id));
|
||||
}
|
||||
|
||||
/// The template tag help: every tag (from the engine's one list, `rename::TOKENS`) with its
|
||||
/// meaning and an example, the `{date:…}` directives and how templates behave. Clicking a tag
|
||||
/// (`button:<key>Tag-<i>`) inserts it at the cursor of the field `edit_id`.
|
||||
pub(crate) fn tag_help(ui: &mut egui::Ui, key: &str, text: &mut String, edit_id: egui::Id) {
|
||||
use lightcraft_engine::rename::{DATE_DIRECTIVES, TEMPLATE_NOTES, TOKENS, token_example};
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::Frame::new().fill(t.inset).corner_radius(4.0).inner_margin(6.0).show(ui, |ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(crate::i18n::tr("Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48."))
|
||||
.color(t.text_dim)
|
||||
.small(),
|
||||
);
|
||||
egui::ScrollArea::vertical().id_salt((key, "tags")).max_height(170.0).show(ui, |ui| {
|
||||
egui::Grid::new((key, "tag-grid")).num_columns(3).spacing([10.0, 2.0]).show(ui, |ui| {
|
||||
for (i, tok) in TOKENS.iter().enumerate() {
|
||||
let r = ui.add(egui::Button::new(egui::RichText::new(tok.tag).monospace().color(t.text)).small()).on_hover_text(
|
||||
if tok.aliases.is_empty() {
|
||||
crate::i18n::tr("Insert at the cursor").to_string()
|
||||
} else {
|
||||
crate::i18n::tr_format!("Insert at the cursor (also written {})", tok.aliases.join(", "))
|
||||
},
|
||||
);
|
||||
register(ui, format!("button:{key}Tag-{i}"), r.rect);
|
||||
if r.clicked() {
|
||||
insert_at_cursor(ui.ctx(), edit_id, text, tok.tag);
|
||||
}
|
||||
ui.label(egui::RichText::new(crate::i18n::tr(tok.meaning)).color(t.text_label));
|
||||
ui.label(egui::RichText::new(token_example(tok.tag)).monospace().color(t.text_dim));
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
let directives: Vec<String> = DATE_DIRECTIVES.iter().map(|(d, m)| format!("{d} {}", crate::i18n::tr(m))).collect();
|
||||
ui.label(egui::RichText::new(crate::i18n::tr_format!("{{date:…}} directives: {}", directives.join(" · "))).color(t.text_dim).small());
|
||||
for n in TEMPLATE_NOTES {
|
||||
ui.label(egui::RichText::new(format!("• {}", crate::i18n::tr(n))).color(t.text_dim).small());
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
use super::scan::{busy_synchronizing, running};
|
||||
use super::*;
|
||||
|
||||
/// 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 {
|
||||
/// 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 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);
|
||||
let plural = |n: usize| if n == 1 { "" } else { "s" };
|
||||
let label = if task.imported == 0 && task.restored > 0 {
|
||||
crate::i18n::tr_format!("Restore {} Photo{}", task.restored, plural(task.restored))
|
||||
} else if task.restored > 0 {
|
||||
crate::i18n::tr_format!("Add {} Photo{}, restore {}", task.imported, plural(task.imported), task.restored)
|
||||
} else {
|
||||
crate::i18n::tr_format!("Add {} Photo{}", task.imported, if task.imported == 1 { "" } else { "s" })
|
||||
};
|
||||
app.session.merge_undo(steps, &label);
|
||||
if task.auto {
|
||||
if !task.warnings.is_empty() {
|
||||
app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0);
|
||||
} else if task.imported > 0 {
|
||||
app.toast(ctx, crate::i18n::tr_format!("Auto Import: added {} photo{}", task.imported, if task.imported == 1 { "" } else { "s" }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if task.browse {
|
||||
if !task.warnings.is_empty() {
|
||||
app.toast_for(ctx, xmp_warnings_text(&task.warnings), 8.0);
|
||||
} else if task.failed > 0 {
|
||||
app.toast(ctx, crate::i18n::tr_format!("{} photo{} not readable", task.failed, if task.failed == 1 { "" } else { "s" }));
|
||||
}
|
||||
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]}));
|
||||
}
|
||||
let plural = |n: usize| if n == 1 { "" } else { "s" };
|
||||
// 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 = if task.cancelled {
|
||||
crate::i18n::tr_format!("Import cancelled · {} photo{} added", task.imported, plural(task.imported))
|
||||
} else if task.imported == 0 && task.restored > 0 {
|
||||
crate::i18n::tr_format!("Restored {} photo{} from Recently Deleted", task.restored, plural(task.restored))
|
||||
} else {
|
||||
crate::i18n::tr_format!("Imported {} photo{}", task.imported, plural(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, plural(task.kept)));
|
||||
}
|
||||
}
|
||||
if task.duplicates > 0 {
|
||||
msg.push_str(&crate::i18n::tr_format!(" · {} duplicate{} skipped", task.duplicates, if task.duplicates == 1 { "" } else { "s" }));
|
||||
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));
|
||||
}
|
||||
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 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
|
||||
}
|
||||
Reference in New Issue
Block a user