From bec920f67d02fea3b9328da00c5ae4c95c336d73 Mon Sep 17 00:00:00 2001 From: Jason Baldwin <33934381+bitshiftza@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:54:07 +0200 Subject: [PATCH] LR-LIB-ALBUM: albums hold library photos; Local photos are skipped, a Local-only selection is refused (#684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Album views list library photos only, but album.create {addSelected}, album.addPhotos and album.toggleTarget (B) took the selection as is, so a browsed Local photo became an album member that never shows: count 1, an empty album, a cover that can't render. Engine: the three commands drop Local photos from their targets and report `skipped`; a selection of nothing but Local photos is an error that says to Add to My Photos first; album.addPhotos and album.toggleTarget use a new has_library_selection enabler. album.create still makes the album, minus the Local photos. UI: for a Local-only selection the grid's right-click Add to Album is off with a hover hint, Photo ▸ Add to Album starts with a disabled "Add to My Photos First" item and its entries and New Album… are off, a folder row's Add Selected Photos is off, and a drag over an album row shows not-allowed and toasts the hint on release. A mixed selection runs and toasts how many Local photos were skipped (drop, context menu, folder row, New Album dialog). Context-menu album entries are button:contextAlbum:. Messages added to every locale catalog. Tests: tests_albums_local.rs (engine) and tests_panels::local_photos_cannot_be_added_to_albums_until_ they_join_my_photos (UI, fails on main). Docs: parity rows LR-LIB-ALBUM, LR-IMP-LOCAL, MENU-PHOTO-ADDTOALBUM. --- crates/engine/src/cmd/library.rs | 111 +++++++++++++------- crates/engine/src/cmd/mod.rs | 8 ++ crates/engine/src/lib.rs | 2 + crates/engine/src/tests_albums_local.rs | 99 +++++++++++++++++ crates/ui-egui/locales/de-formats.json | 3 +- crates/ui-egui/locales/de.json | 4 +- crates/ui-egui/locales/es-formats.json | 3 +- crates/ui-egui/locales/es.json | 4 +- crates/ui-egui/locales/fr-formats.json | 3 +- crates/ui-egui/locales/fr.json | 4 +- crates/ui-egui/locales/ja-formats.json | 3 +- crates/ui-egui/locales/ja.json | 4 +- crates/ui-egui/locales/pt-br-formats.json | 3 +- crates/ui-egui/locales/pt-br.json | 4 +- crates/ui-egui/locales/ru-formats.json | 3 +- crates/ui-egui/locales/ru.json | 4 +- crates/ui-egui/locales/uk-formats.json | 3 +- crates/ui-egui/locales/uk.json | 4 +- crates/ui-egui/locales/zh-hans-formats.json | 3 +- crates/ui-egui/locales/zh-hans.json | 4 +- crates/ui-egui/locales/zh-hant-formats.json | 3 +- crates/ui-egui/locales/zh-hant.json | 4 +- crates/ui-egui/src/menubar.rs | 22 ++-- crates/ui-egui/src/panels/dialogs.rs | 5 + crates/ui-egui/src/panels/grid.rs | 52 +++++++-- crates/ui-egui/src/panels/left.rs | 43 +++++++- crates/ui-egui/src/tests_panels.rs | 96 +++++++++++++++++ docs/parity.md | 6 +- 28 files changed, 430 insertions(+), 77 deletions(-) create mode 100644 crates/engine/src/tests_albums_local.rs diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 27e96f4f..c8d55ad7 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -4,7 +4,7 @@ use lightcraft_catalog::{Album, AlbumId, ColorLabel, CopyrightStatus, Filter, Flag, GroupBy, Op, PhotoId, Sort, SortKey}; use serde_json::{Value, json}; -use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_selection, ok, str_param}; +use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_library_selection, has_selection, ok, str_param}; use crate::{LibrarySource, Result, Selection, Session}; fn select_after_deletion(s: &mut Session, before: &[PhotoId]) { @@ -149,6 +149,24 @@ fn ids_param(p: &Value) -> Option> { p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(PhotoId).collect()) } +/// The targets an album can hold and how many were browsed (Local) photos, which album views +/// never show (issue #684): those are skipped, and reported, instead of becoming invisible members. +fn album_targets(s: &Session, targets: Vec) -> (Vec, usize) { + let before = targets.len(); + let kept: Vec = targets.into_iter().filter(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)).collect(); + let skipped = before - kept.len(); + (kept, skipped) +} + +/// Album targets, or the error for a selection that was nothing but Local photos. +fn album_targets_or_err(s: &Session, command: &str, targets: Vec) -> Result<(Vec, usize)> { + let (kept, skipped) = album_targets(s, targets); + if kept.is_empty() && skipped > 0 { + return Err(bad(command, super::LOCAL_ONLY)); + } + Ok((kept, skipped)) +} + /// Apply one op per target as a single undo step. fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> Option) -> Result { let targets = s.targets(p); @@ -866,22 +884,31 @@ pub fn specs() -> Vec { } ), // ---- albums - cmd!("album.create", "New Album", ["File"], None, "{name, parent?: folderId, folder?: bool, addSelected?: bool}", always, |s, p| { - let name = str_param(p, "name").unwrap_or("Untitled Album").trim().to_string(); - if name.is_empty() { - return Err(bad("album.create", "empty name")); + cmd!( + "album.create", + "New Album", + ["File"], + None, + "{name, parent?: folderId, folder?: bool, addSelected?: bool} → {id, skipped} (skipped: Local photos of the selection, which albums can't hold)", + always, + |s, p| { + let name = str_param(p, "name").unwrap_or("Untitled Album").trim().to_string(); + if name.is_empty() { + return Err(bad("album.create", "empty name")); + } + let folder = bool_or(p, "folder", false); + let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId); + let (photos, skipped) = + if !folder && bool_or(p, "addSelected", false) { album_targets(s, s.targets(&Value::Null)) } else { (vec![], 0) }; + let id = s.catalog.alloc_album_id(); + let cover = photos.first().copied(); + s.commit( + if folder { "New Folder" } else { "New Album" }, + Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None, quick: false, order: None } }, + )?; + Ok(json!({"id": id.0, "skipped": skipped})) } - let folder = bool_or(p, "folder", false); - let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId); - let photos = if !folder && bool_or(p, "addSelected", false) { s.targets(&Value::Null) } else { vec![] }; - let id = s.catalog.alloc_album_id(); - let cover = photos.first().copied(); - s.commit( - if folder { "New Folder" } else { "New Album" }, - Op::AddAlbum { album: Album { id, name, parent, folder, photos, cover, smart: None, quick: false, order: None } }, - )?; - Ok(json!({"id": id.0})) - }), + ), cmd!( "album.createSmart", "New Smart Album…", @@ -1103,34 +1130,44 @@ pub fn specs() -> Vec { Ok(json!({"changed": changed})) } ), - cmd!("album.addPhotos", "Add to Album", ["Photo"], None, "{id: albumId, ids?: [photoIds]} (default: selection)", has_selection, |s, p| { - let id = album_param(p, "id", "album.addPhotos")?; - let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)); - let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?; - if al.is_smart() || al.folder { - return Err(bad("album.addPhotos", "smart albums and folders can't hold photos")); - } - let mut photos = al.photos.clone(); - let before = photos.len(); - for t in targets { - if !photos.contains(&t) { - photos.push(t); + cmd!( + "album.addPhotos", + "Add to Album", + ["Photo"], + None, + "{id: albumId, ids?: [photoIds]} (default: selection) → {added, skipped}; Local photos are skipped (albums hold library photos), only Local ones is an error", + has_library_selection, + |s, p| { + let id = album_param(p, "id", "album.addPhotos")?; + let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)); + let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?; + if al.is_smart() || al.folder { + return Err(bad("album.addPhotos", "smart albums and folders can't hold photos")); } + let (targets, skipped) = album_targets_or_err(s, "album.addPhotos", targets)?; + let mut photos = al.photos.clone(); + let before = photos.len(); + for t in targets { + if !photos.contains(&t) { + photos.push(t); + } + } + let added = photos.len() - before; + let cover = al.cover.or(photos.first().copied()); + s.commit("Add to Album", Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] })?; + Ok(json!({"added": added, "skipped": skipped})) } - let added = photos.len() - before; - let cover = al.cover.or(photos.first().copied()); - s.commit("Add to Album", Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] })?; - Ok(json!({"added": added})) - }), + ), cmd!( "album.toggleTarget", "Add to Target Album", [], None, - "{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added}", - has_selection, + "{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added, skipped}; Local photos are skipped", + has_library_selection, |s, p| { - let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)); + let targets = album_targets_or_err(s, "album.toggleTarget", ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)))?; + let (targets, skipped) = targets; let target = s.target_album.filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder)); let id = match target.or_else(|| s.catalog.quick_collection()) { Some(id) => id, @@ -1154,7 +1191,7 @@ pub fn specs() -> Vec { if all_in { "Remove from Target Album" } else { "Add to Target Album" }, Op::Batch { ops: vec![Op::SetAlbumPhotos { id, photos }, Op::SetAlbumCover { id, cover }] }, )?; - Ok(json!({"album": id.0, "name": name, "added": !all_in, "count": s.catalog.album_count(id)})) + Ok(json!({"album": id.0, "name": name, "added": !all_in, "count": s.catalog.album_count(id), "skipped": skipped})) } ), cmd!("album.setTarget", "Set as Target Album", [], None, "{id: albumId | null} (null = the Quick Collection)", always, |s, p| { diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ebc82862..8b55ceef 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -99,6 +99,14 @@ pub fn has_active(s: &Session) -> std::result::Result<(), String> { pub fn has_selection(s: &Session) -> std::result::Result<(), String> { if s.selection.ids.is_empty() && s.selection.active.is_none() { Err("no photos selected".into()) } else { Ok(()) } } +/// Albums hold library photos: a selection of only browsed (Local) photos has nothing an album +/// can take (issue #684; Add to My Photos first). +pub fn has_library_selection(s: &Session) -> std::result::Result<(), String> { + has_selection(s)?; + if s.targets(&serde_json::Value::Null).iter().any(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)) { Ok(()) } else { Err(LOCAL_ONLY.into()) } +} +/// Why an album command refused a Local-only selection. +pub const LOCAL_ONLY: &str = "only Local photos: albums hold library photos (Add to My Photos first)"; pub fn can_undo(s: &Session) -> std::result::Result<(), String> { if s.undo.is_empty() { Err("nothing to undo".into()) } else { Ok(()) } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 22faff70..8357b86d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -862,6 +862,8 @@ mod tests; #[cfg(test)] mod tests_album_order; #[cfg(test)] +mod tests_albums_local; +#[cfg(test)] mod tests_color; #[cfg(test)] mod tests_denoise; diff --git a/crates/engine/src/tests_albums_local.rs b/crates/engine/src/tests_albums_local.rs new file mode 100644 index 00000000..641fdbd6 --- /dev/null +++ b/crates/engine/src/tests_albums_local.rs @@ -0,0 +1,99 @@ +//! Albums hold library photos (issue #684): browsed (Local) photos are skipped by +//! `album.create {addSelected}`, `album.addPhotos` and `album.toggleTarget` (B), which report how +//! many; a Local-only selection is refused and leaves Photo ▸ Add to Album off. + +use std::path::{Path, PathBuf}; + +use lightcraft_catalog::{AlbumId, PhotoId}; +use serde_json::json; + +use crate::{Selection, Session}; + +fn temp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("lc-albums-local-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn png(p: &Path, seed: u8) { + let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, seed, 255]); + let b = + crate::export::encode_image(&img, &crate::export::ExportOptions { format: crate::export::ExportFormat::Png, ..Default::default() }).unwrap(); + std::fs::write(p, b).unwrap(); +} + +/// A session browsing a folder of two files, one of them added to My Photos: (session, local, library). +fn browsed(tag: &str) -> (Session, PhotoId, PhotoId) { + let dir = temp_dir(tag); + png(&dir.join("a.png"), 1); + png(&dir.join("b.png"), 2); + let mut s = Session::new().with_fs(); + s.execute("library.browse", &json!({"path": dir.to_string_lossy()})).unwrap(); + let mut ids: Vec = s.catalog.photos().filter(|p| p.local).map(|p| p.id).collect(); + ids.sort(); + assert_eq!(ids.len(), 2, "both files are browsed"); + let (local, library) = (ids[0], ids[1]); + s.execute("photo.addToLibrary", &json!({"ids": [library.0]})).unwrap(); + assert!(!s.catalog.photo(library).unwrap().local && s.catalog.photo(local).unwrap().local); + (s, local, library) +} + +fn enabled(s: &Session, id: &str) -> bool { + s.commands().into_iter().find(|c| c.id == id).unwrap_or_else(|| panic!("no command {id}")).enabled +} + +#[test] +fn a_local_only_selection_is_refused_and_leaves_add_to_album_off() { + let (mut s, local, library) = browsed("refused"); + let album = AlbumId(s.execute("album.create", &json!({"name": "Trip", "addSelected": false})).unwrap()["id"].as_u64().unwrap()); + s.selection = Selection::single(local); + assert!(!enabled(&s, "album.addPhotos"), "Photo ▸ Add to Album is off for a Local photo"); + for (command, params) in [ + ("album.addPhotos", json!({"id": album.0})), + ("album.addPhotos", json!({"id": album.0, "ids": [local.0]})), + ("album.toggleTarget", json!({})), + ] { + let e = s.execute(command, ¶ms).unwrap_err().to_string(); + assert!(e.contains("Local") && e.contains("Add to My Photos"), "{command}: {e}"); + } + assert!(s.catalog.album(album).unwrap().photos.is_empty(), "nothing invisible was added"); + assert!(s.catalog.quick_collection().is_none(), "no Quick Collection was made for nothing"); + // the album is still created from a Local-only selection, just without it + let r = s.execute("album.create", &json!({"name": "Empty", "addSelected": true})).unwrap(); + assert_eq!(r["skipped"], 1, "{r}"); + assert!(s.catalog.album(AlbumId(r["id"].as_u64().unwrap())).unwrap().photos.is_empty()); + // once in My Photos the same photo goes in + s.execute("photo.addToLibrary", &json!({"ids": [local.0]})).unwrap(); + assert!(enabled(&s, "album.addPhotos")); + let r = s.execute("album.addPhotos", &json!({"id": album.0})).unwrap(); + assert_eq!((r["added"].as_u64(), r["skipped"].as_u64()), (Some(1), Some(0)), "{r}"); + assert_eq!(s.catalog.album(album).unwrap().photos, vec![local]); + let _ = library; +} + +#[test] +fn a_mixed_selection_skips_the_local_photos_and_says_how_many() { + let (mut s, local, library) = browsed("mixed"); + s.selection = Selection { ids: vec![local, library], active: Some(library) }; + assert!(enabled(&s, "album.addPhotos"), "one library photo is enough"); + let r = s.execute("album.create", &json!({"name": "Trip", "addSelected": true})).unwrap(); + let album = AlbumId(r["id"].as_u64().unwrap()); + assert_eq!(r["skipped"], 1, "{r}"); + assert_eq!(s.catalog.album(album).unwrap().photos, vec![library]); + assert_eq!(s.catalog.album(album).unwrap().cover, Some(library), "the cover is a photo the album shows"); + + let other = AlbumId(s.execute("album.create", &json!({"name": "Other", "addSelected": false})).unwrap()["id"].as_u64().unwrap()); + let r = s.execute("album.addPhotos", &json!({"id": other.0})).unwrap(); + assert_eq!((r["added"].as_u64(), r["skipped"].as_u64()), (Some(1), Some(1)), "{r}"); + assert_eq!(s.catalog.album(other).unwrap().photos, vec![library]); + + let r = s.execute("album.toggleTarget", &json!({})).unwrap(); + assert_eq!((r["added"].as_bool(), r["skipped"].as_u64()), (Some(true), Some(1)), "{r}"); + let quick = s.catalog.quick_collection().expect("B made the Quick Collection"); + assert_eq!(s.catalog.album(quick).unwrap().photos, vec![library]); + // B again: the library photo is the whole addable selection and it is in, so it comes out + let r = s.execute("album.toggleTarget", &json!({})).unwrap(); + assert_eq!(r["added"], false, "{r}"); + assert!(s.catalog.album(quick).unwrap().photos.is_empty()); +} diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index ea00ae3c..4f257b33 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -221,5 +221,6 @@ "; {n} gained synonyms": "; mit neuen Synonymen: {n}", "Exported {n} keyword{}": "Stichwörter exportiert: {n}{:.0}", " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One importiert keine Liste mit ; , < oder > in einem Stichwort: {names}", - " — left out, as a keyword list can't hold them: {names}": " — ausgelassen, weil eine Stichwortliste sie nicht aufnehmen kann: {names}" + " — left out, as a keyword list can't hold them: {names}": " — ausgelassen, weil eine Stichwortliste sie nicht aufnehmen kann: {names}", + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Übersprungen: {n} lokale Fotos{0:.0} – Alben enthalten Bibliotheksfotos (zuerst zu „Meine Fotos“ hinzufügen)" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index c4bded93..3ebb99c7 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2016,5 +2016,7 @@ "Stopped by an error (see the log)": "Durch einen Fehler angehalten (siehe Protokoll)", "Not saved": "Nicht gespeichert", "A folder is being synchronized": "Ein Ordner wird gerade synchronisiert", - "Wait for the import to finish": "Warten Sie, bis der Import abgeschlossen ist" + "Wait for the import to finish": "Warten Sie, bis der Import abgeschlossen ist", + "Albums hold library photos: Add to My Photos first": "Alben enthalten Bibliotheksfotos: zuerst zu „Meine Fotos“ hinzufügen", + "Add to My Photos First": "Zuerst zu „Meine Fotos“ hinzufügen" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index ce6ace8c..3657a3d2 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -221,5 +221,6 @@ "; {n} gained synonyms": "; con sinónimos nuevos: {n}", "Exported {n} keyword{}": "Palabras clave exportadas: {n}{:.0}", " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One no importa una lista con ; , < o > en una palabra clave: {names}", - " — left out, as a keyword list can't hold them: {names}": " — omitidas, porque una lista de palabras clave no puede contenerlas: {names}" + " — left out, as a keyword list can't hold them: {names}": " — omitidas, porque una lista de palabras clave no puede contenerlas: {names}", + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Fotos locales omitidas: {n}{0:.0} — los álbumes contienen fotos de la biblioteca (añádelas primero a Mis fotos)" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index eb13321a..fae3b9eb 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1861,5 +1861,7 @@ "Stopped by an error (see the log)": "Detenido por un error (ver el registro)", "Not saved": "Sin guardar", "A folder is being synchronized": "Se está sincronizando una carpeta", - "Wait for the import to finish": "Espera a que termine la importación" + "Wait for the import to finish": "Espera a que termine la importación", + "Albums hold library photos: Add to My Photos first": "Los álbumes contienen fotos de la biblioteca: añádelas primero a Mis fotos", + "Add to My Photos First": "Añadir primero a Mis fotos" } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index 6e609329..bb8bb75e 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -221,5 +221,6 @@ "; {n} gained synonyms": " ; avec de nouveaux synonymes : {n}", "Exported {n} keyword{}": "Mots-clés exportés : {n}{:.0}", " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One n’importe pas une liste dont un mot-clé contient ; , < ou > : {names}", - " — left out, as a keyword list can't hold them: {names}": " — omis, car une liste de mots-clés ne peut pas les contenir : {names}" + " — left out, as a keyword list can't hold them: {names}": " — omis, car une liste de mots-clés ne peut pas les contenir : {names}", + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Photos locales ignorées : {n}{0:.0} — les albums contiennent des photos de la bibliothèque (ajoutez-les d’abord à Mes photos)" } \ No newline at end of file diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index a978e983..871d9e4d 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1861,5 +1861,7 @@ "Stopped by an error (see the log)": "Arrêté par une erreur (voir le journal)", "Not saved": "Non enregistré", "A folder is being synchronized": "Un dossier est en cours de synchronisation", - "Wait for the import to finish": "Attendez la fin de l'importation" + "Wait for the import to finish": "Attendez la fin de l'importation", + "Albums hold library photos: Add to My Photos first": "Les albums contiennent des photos de la bibliothèque : ajoutez-les d’abord à Mes photos", + "Add to My Photos First": "Ajouter d’abord à Mes photos" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index e4976420..e4de12d3 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -221,5 +221,6 @@ "; {n} gained synonyms": "。{n}件に同義語を追加", "Exported {n} keyword{}": "{n}件のキーワードを書き出しました{:.0}", " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One は ; , < > を含むキーワードがあるリストを読み込みません: {names}", - " — left out, as a keyword list can't hold them: {names}": " — キーワードリストに書けないため省略: {names}" + " — left out, as a keyword list can't hold them: {names}": " — キーワードリストに書けないため省略: {names}", + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "ローカル写真{n}枚をスキップしました{0:.0}:アルバムに入れられるのはライブラリの写真です(先にマイフォトに追加してください)" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 4d9c8ae7..1f5ee62b 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1863,5 +1863,7 @@ "Stopped by an error (see the log)": "エラーで停止しました(ログを参照)", "Not saved": "保存されていません", "A folder is being synchronized": "フォルダを同期中です", - "Wait for the import to finish": "読み込みが終わるまでお待ちください" + "Wait for the import to finish": "読み込みが終わるまでお待ちください", + "Albums hold library photos: Add to My Photos first": "アルバムに入れられるのはライブラリの写真です:先にマイフォトに追加してください", + "Add to My Photos First": "先にマイフォトに追加" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index b6c1c23b..b7f3c353 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -221,5 +221,6 @@ "; {n} gained synonyms": "; {n} receberam sinônimos", "Exported {n} keyword{}": "Palavras-chave exportadas: {n}{:.0}", " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — O Capture One não importa uma lista com ; , < ou > em uma palavra-chave: {names}", - " — left out, as a keyword list can't hold them: {names}": " — omitidas, pois uma lista de palavras-chave não pode contê-las: {names}" + " — left out, as a keyword list can't hold them: {names}": " — omitidas, pois uma lista de palavras-chave não pode contê-las: {names}", + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Fotos locais ignoradas: {n}{0:.0} — álbuns contêm fotos da biblioteca (adicione-as às Minhas fotos primeiro)" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 318120d8..8c947140 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1861,5 +1861,7 @@ "Stopped by an error (see the log)": "Interrompido por um erro (veja o log)", "Not saved": "Não salvo", "A folder is being synchronized": "Uma pasta está sendo sincronizada", - "Wait for the import to finish": "Aguarde a importação terminar" + "Wait for the import to finish": "Aguarde a importação terminar", + "Albums hold library photos: Add to My Photos first": "Álbuns contêm fotos da biblioteca: adicione-as às Minhas fotos primeiro", + "Add to My Photos First": "Adicionar às Minhas fotos primeiro" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index cd55e708..a6104933 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -221,5 +221,6 @@ "; {n} gained synonyms": "; синонимы добавлены к {n}", "Exported {n} keyword{}": "Экспортировано ключевых слов: {n}{:.0}", " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One не импортирует список, где в ключевом слове есть ; , < или >: {names}", - " — left out, as a keyword list can't hold them: {names}": " — пропущены: список ключевых слов не может их содержать: {names}" + " — left out, as a keyword list can't hold them: {names}": " — пропущены: список ключевых слов не может их содержать: {names}", + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Пропущено локальных фото: {n}{0:.0} — в альбомах только фото из библиотеки (сначала добавьте их в «Мои фото»)" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index b51355c7..ce5d78c6 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1574,5 +1574,7 @@ "Stopped by an error (see the log)": "Остановлено из-за ошибки (см. журнал)", "Not saved": "Не сохранено", "A folder is being synchronized": "Идёт синхронизация папки", - "Wait for the import to finish": "Дождитесь окончания импорта" + "Wait for the import to finish": "Дождитесь окончания импорта", + "Albums hold library photos: Add to My Photos first": "В альбомах только фото из библиотеки: сначала добавьте их в «Мои фото»", + "Add to My Photos First": "Сначала добавить в «Мои фото»" } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 2196c6ba..88c16d3a 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -221,5 +221,6 @@ "; {n} gained synonyms": "; отримали синоніми: {n}", "Exported {n} keyword{}": "Експортовано ключових слів: {n}{:.0}", " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One не імпортує список, у якому ключове слово містить ; , < або >: {names}", - " — left out, as a keyword list can't hold them: {names}": " — пропущено, бо список ключових слів не може їх містити: {names}" + " — left out, as a keyword list can't hold them: {names}": " — пропущено, бо список ключових слів не може їх містити: {names}", + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Пропущено локальних фото: {n}{0:.0} — в альбомах лише фото з бібліотеки (спершу додайте їх до моїх фото)" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 52a30fb7..651d2985 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2055,5 +2055,7 @@ "Stopped by an error (see the log)": "Зупинено через помилку (див. журнал)", "Not saved": "Не збережено", "A folder is being synchronized": "Тека синхронізується", - "Wait for the import to finish": "Дочекайтеся завершення імпорту" + "Wait for the import to finish": "Дочекайтеся завершення імпорту", + "Albums hold library photos: Add to My Photos first": "В альбомах лише фото з бібліотеки: спершу додайте їх до моїх фото", + "Add to My Photos First": "Спершу додати до моїх фото" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index e006cdfd..2651ec0c 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -221,5 +221,6 @@ "; {n} gained synonyms": ";{n} 个获得了同义词", "Exported {n} keyword{}": "已导出 {n} 个关键字{:.0}", " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One 不会导入关键字中含有 ; , < 或 > 的列表:{names}", - " — left out, as a keyword list can't hold them: {names}": " — 已省略,关键字列表无法容纳: {names}" + " — left out, as a keyword list can't hold them: {names}": " — 已省略,关键字列表无法容纳: {names}", + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已跳过 {n} 张本地照片{0:.0}:相册只能收录图库中的照片(请先添加到我的照片)" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index d654240b..d6685182 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1861,5 +1861,7 @@ "Stopped by an error (see the log)": "因错误而停止(见日志)", "Not saved": "未保存", "A folder is being synchronized": "正在同步文件夹", - "Wait for the import to finish": "请等待导入完成" + "Wait for the import to finish": "请等待导入完成", + "Albums hold library photos: Add to My Photos first": "相册只能收录图库中的照片:请先添加到我的照片", + "Add to My Photos First": "先添加到我的照片" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index c47f4569..90da5d6c 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -221,5 +221,6 @@ "; {n} gained synonyms": ";{n} 個獲得了同義詞", "Exported {n} keyword{}": "已匯出 {n} 個關鍵字{:.0}", " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One 不會匯入關鍵字中含有 ; , < 或 > 的清單:{names}", - " — left out, as a keyword list can't hold them: {names}": " — 已省略,關鍵字清單無法容納: {names}" + " — left out, as a keyword list can't hold them: {names}": " — 已省略,關鍵字清單無法容納: {names}", + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已略過 {n} 張本機照片{0:.0}:相簿只能收錄圖庫中的照片(請先加入我的照片)" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index ef8160a5..cd5c7f40 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1861,5 +1861,7 @@ "Stopped by an error (see the log)": "因錯誤而停止(請見記錄)", "Not saved": "未儲存", "A folder is being synchronized": "正在同步資料夾", - "Wait for the import to finish": "請等待匯入完成" + "Wait for the import to finish": "請等待匯入完成", + "Albums hold library photos: Add to My Photos first": "相簿只能收錄圖庫中的照片:請先加入我的照片", + "Add to My Photos First": "先加入我的照片" } diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index b919aa2e..10184507 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -573,15 +573,23 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option> { v } "Add to Album" => { - let sel = !app.session.selection.ids.is_empty() || has; - let mut albums: Vec<_> = app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.name.clone(), a.id.0)).collect(); - albums.sort_by_key(|(n, _)| n.to_lowercase()); - let mut v: Vec = - albums.into_iter().map(|(name, id)| item("album.addPhotos", json!({"id": id}), name, None, sel, None)).collect(); - if !v.is_empty() { + let targets = app.session.targets(&Value::Null); + let sel = !targets.is_empty(); + // albums hold library photos (issue #684): a Local-only selection is told what to do first + let local_only = crate::panels::grid::local_only(app, &targets); + let mut v: Vec = vec![]; + if local_only { + v.push(item("photo.addToLibrary", Value::Null, crate::i18n::tr("Add to My Photos First"), None, false, None)); v.push(MenuNode::Separator); } - v.push(item("dialog.newAlbum", Value::Null, "New Album…", None, true, None)); + let mut albums: Vec<_> = app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.name.clone(), a.id.0)).collect(); + albums.sort_by_key(|(n, _)| n.to_lowercase()); + let any = !albums.is_empty(); + v.extend(albums.into_iter().map(|(name, id)| item("album.addPhotos", json!({"id": id}), name, None, sel && !local_only, None))); + if any { + v.push(MenuNode::Separator); + } + v.push(item("dialog.newAlbum", Value::Null, "New Album…", None, !local_only, None)); v } _ => return None, diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index aa287c95..3ec27743 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -1056,6 +1056,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { *error = Some(e); } } + Ok(r) if matches!(dlg, Dialog::NewAlbum { .. }) && r.get("skipped").and_then(serde_json::Value::as_u64).is_some_and(|n| n > 0) => { + // albums hold library photos (issue #684): say which part of the selection stayed out + crate::panels::grid::toast_skipped(app, ctx, &r); + close = true; + } Ok(_) if keeps_open(app, &dlg) => { if let Dialog::SamModel { error, .. } = &mut dlg { *error = None; diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 4264e429..37b913d6 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -801,6 +801,32 @@ pub fn drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { pub use super::filterbar::label_color; +/// Albums hold library photos (issue #684): true when `ids` are photos and all of them are +/// browsed (Local) ones, which album views never show. +pub fn local_only(app: &LightcraftApp, ids: &[PhotoId]) -> bool { + !ids.is_empty() && ids.iter().all(|id| app.session.catalog.photo(*id).is_some_and(|p| p.local)) +} + +/// Why an album action is off for Local photos. +pub fn albums_hint() -> String { + crate::i18n::tr("Albums hold library photos: Add to My Photos first").to_string() +} + +/// Tell the user when an album command skipped Local photos of the selection (`skipped` in its +/// result). +pub fn toast_skipped(app: &mut LightcraftApp, ctx: &egui::Context, r: &serde_json::Value) { + if let Some(n) = r.get("skipped").and_then(serde_json::Value::as_u64).filter(|n| *n > 0) { + app.toast( + ctx, + crate::i18n::tr_format!( + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)", + if n == 1 { "" } else { "s" }, + n = n + ), + ); + } +} + /// "Set Color Label" items for the active photo, shared by context menus. pub fn label_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) { let current = app.session.active().and_then(|id| app.session.catalog.photo(id)).and_then(|p| p.label); @@ -913,14 +939,26 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } }); ui.menu_button(crate::i18n::tr("Set Color Label"), |ui| label_menu(app, ui)); - ui.menu_button(crate::i18n::tr("Add to Album"), |ui| { - let albums: Vec<_> = app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.id.0, a.name.clone())).collect(); - for (aid, name) in albums { - if ui.button(name).clicked() { - let _ = app.run("album.addPhotos", json!({"id": aid})); + if local_only(app, &app.session.targets(&serde_json::Value::Null)) { + // albums hold library photos (issue #684): nothing here to add until they join My Photos + let add = ui.add_enabled(false, egui::Button::new(crate::i18n::tr("Add to Album"))).on_disabled_hover_text(albums_hint()); + register(ui.ctx(), "button:contextAddToAlbum", add.rect); + } else { + let add = ui.menu_button(crate::i18n::tr("Add to Album"), |ui| { + let albums: Vec<_> = app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.id.0, a.name.clone())).collect(); + for (aid, name) in albums { + let b = ui.button(name); + register(ui.ctx(), format!("button:contextAlbum:{aid}"), b.rect); + if b.clicked() { + match app.run("album.addPhotos", json!({"id": aid})) { + Ok(r) => toast_skipped(app, ui.ctx(), &r), + Err(e) => app.toast(ui.ctx(), e), + } + } } - } - }); + }); + register(ui.ctx(), "button:contextAddToAlbum", add.response.rect); + } // in a (non-smart) album: take the selection out of it, or make this photo its cover if let lightcraft_engine::LibrarySource::Album(aid) = app.session.source && app.session.catalog.album(aid).is_some_and(|a| !a.folder && !a.is_smart()) diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index b435f6ed..e18007d2 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -1014,12 +1014,36 @@ fn drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response if !over { return; } + let released = ui.input(|i| i.pointer.any_released()); + // albums hold library photos (issue #684): Local ones can't be dropped in; the row doesn't + // light up, and a release says why + let photos: Vec = ids.iter().map(|id| lightcraft_catalog::PhotoId(*id)).collect(); + if super::grid::local_only(app, &photos) { + ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed); + if released { + app.toast(ui.ctx(), super::grid::albums_hint()); + app.ui.dragging_photos = None; + } + return; + } let t = Tokens::get(ui.ctx()); ui.painter().rect_stroke(resp.rect.shrink2(vec2(8.0, 1.0)), 4.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); - if ui.input(|i| i.pointer.any_released()) { - let n = ids.len(); + if released { match app.run("album.addPhotos", json!({"id": a.id.0, "ids": ids})) { - Ok(_) => app.toast(ui.ctx(), crate::i18n::tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, a.name, n = n)), + Ok(r) => { + let n = r.get("added").and_then(serde_json::Value::as_u64).unwrap_or(0); + let skipped = r.get("skipped").and_then(serde_json::Value::as_u64).unwrap_or(0); + let mut msg = crate::i18n::tr_format!("Added {n} photo{} to “{}”", if n == 1 { "" } else { "s" }, a.name, n = n); + if skipped > 0 { + msg.push_str(" · "); + msg.push_str(&crate::i18n::tr_format!( + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)", + if skipped == 1 { "" } else { "s" }, + n = skipped + )); + } + app.toast(ui.ctx(), msg); + } Err(e) => app.toast(ui.ctx(), e), } app.ui.dragging_photos = None; @@ -1051,8 +1075,17 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { } ui.separator(); } - if !a.folder && !a.is_smart() && ui.button(crate::i18n::tr("Add Selected Photos")).clicked() { - let _ = app.run("album.addPhotos", json!({"id": a.id.0})); + if !a.folder && !a.is_smart() { + let local_only = super::grid::local_only(app, &app.session.targets(&serde_json::Value::Null)); + let b = ui + .add_enabled(!local_only, egui::Button::new(crate::i18n::tr("Add Selected Photos"))) + .on_disabled_hover_text(super::grid::albums_hint()); + if b.clicked() { + match app.run("album.addPhotos", json!({"id": a.id.0})) { + Ok(r) => super::grid::toast_skipped(app, ui.ctx(), &r), + Err(e) => app.toast(ui.ctx(), e), + } + } } if !a.folder && !a.is_smart() { let is_target = app.session.target_album == Some(a.id) || (app.session.target_album.is_none() && a.quick); diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index 1f5cd4d4..d9fa8345 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -1182,3 +1182,99 @@ fn the_tracklog_toast_shows_after_a_while() { assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.0.starts_with("Tagged")), "{:?}", h.app.ui.toast); let _ = std::fs::remove_dir_all(&dir); } + +/// Issue #684: albums hold library photos. For a browsed (Local) photo the grid's right-click +/// Add to Album is off, Photo ▸ Add to Album says to Add to My Photos first with its entries off, +/// and a drop onto an album row is refused with that hint; once the photo is in My Photos all +/// three work. +#[test] +fn local_photos_cannot_be_added_to_albums_until_they_join_my_photos() { + let mut h = demo([1400.0, 900.0], json!({"view": "photoGrid", "leftPanel": true})); + let album = h.app.session.execute("album.create", &json!({"name": "Trip", "addSelected": false})).unwrap()["id"].as_u64().unwrap(); + let album_id = lightcraft_catalog::AlbumId(album); + let dir = std::env::temp_dir().join(format!("lc-ui-albums-local-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(24, 16, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + std::fs::write(dir.join("a.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": dir.to_string_lossy()}}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let vis = h.app.session.visible_cloned(); + assert_eq!(vis.len(), 1, "{vis:?}"); + let local = vis[0]; + assert!(h.app.session.catalog.photo(local).unwrap().local); + let thumb = widget(&h, &format!("thumb:{}", local.0)); + + // right-click: Add to Album is there but off, so hovering opens no album entries + let r = h.request("ui.click", json!({"x": thumb.center().x, "y": thumb.center().y, "button": "right"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(h.app.session.selection.contains(local)); + let r = h.request("ui.hoverWidget", json!({"id": "button:contextAddToAlbum"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(!h.app.widgets.iter().any(|(w, _)| w.starts_with("button:contextAlbum:")), "the submenu must not open for a Local photo"); + let r = h.request("ui.key", json!({"key": "Escape"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + + // Photo ▸ Add to Album: the hint item first (off), the album and New Album… off + let add_to_album = |app: &LightcraftApp| -> Vec { + let bar = crate::menubar::menu_bar(app); + let photo = &bar.iter().find(|(t, _)| t == "Photo").expect("Photo menu").1; + photo + .iter() + .find_map(|n| match n { + crate::menubar::MenuNode::Submenu { label, children } if label == "Add to Album" => Some(children.clone()), + _ => None, + }) + .expect("Add to Album submenu") + }; + let enabled_of = |nodes: &[crate::menubar::MenuNode], id: &str| -> Option { + nodes.iter().find_map(|n| match n { + crate::menubar::MenuNode::Item { id: i, enabled, .. } if i == id => Some(*enabled), + _ => None, + }) + }; + let nodes = add_to_album(&h.app); + assert!(matches!(nodes.first(), Some(crate::menubar::MenuNode::Item { id, enabled: false, .. }) if id == "photo.addToLibrary"), "{nodes:?}"); + assert_eq!(enabled_of(&nodes, "album.addPhotos"), Some(false), "{nodes:?}"); + assert_eq!(enabled_of(&nodes, "dialog.newAlbum"), Some(false), "{nodes:?}"); + + // a drop onto the album row is refused and says why + let row = widget(&h, &format!("source:album:{album}")); + let r = h.request("ui.drag", json!({"x": thumb.center().x, "y": thumb.center().y, "toX": row.center().x, "toY": row.center().y, "steps": 12}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.session.catalog.album(album_id).unwrap().photos.is_empty(), "nothing invisible was added"); + assert!(h.app.ui.dragging_photos.is_none(), "the drag ended"); + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.contains("Add to My Photos first"), "{toast:?}"); + + // in My Photos: the menu entries come on, the submenu opens, and the album takes the photo + h.app.run("photo.addToLibrary", json!({"ids": [local.0]})).unwrap(); + h.step(); + let nodes = add_to_album(&h.app); + assert!(!matches!(nodes.first(), Some(crate::menubar::MenuNode::Item { id, .. }) if id == "photo.addToLibrary"), "{nodes:?}"); + assert_eq!(enabled_of(&nodes, "album.addPhotos"), Some(true), "{nodes:?}"); + assert_eq!(enabled_of(&nodes, "dialog.newAlbum"), Some(true), "{nodes:?}"); + let thumb = widget(&h, &format!("thumb:{}", local.0)); + let r = h.request("ui.click", json!({"x": thumb.center().x, "y": thumb.center().y, "button": "right"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + let r = h.request("ui.hoverWidget", json!({"id": "button:contextAddToAlbum"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + let r = h.request("ui.clickWidget", json!({"id": format!("button:contextAlbum:{album}")}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!(h.app.session.catalog.album(album_id).unwrap().photos, vec![local]); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/docs/parity.md b/docs/parity.md index ad82e3d2..f9eec6cd 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -142,7 +142,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it | | LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | | | LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop; masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs; Adobe profile formats deliberately unsupported | -| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts; folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | +| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | | LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP; DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Hasselblad 3FR with uncompressed raw data (X1D, X1D II, X2D, CFV 100C: a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`; the lossless-JPEG 3FR and FFF files use scan selection value 8 and stay preview-only), Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8 (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS TIFFs: IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan), and any decode failure of a recognised raw, not only an unsupported variant; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate) | | LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535; pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; private Z 8 crop verified. | @@ -157,7 +157,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-LIB-ALLPHOTOS | All photos | P0 | ✅ | `cmd:library.source`, `crates/ui-egui/src/panels/left.rs` | | | LR-LIB-RECENT-ADDED | Recently added | P1 | ✅ | `cmd:library.source` (`recentlyAdded`), `crates/engine/src/view.rs` (`RECENT_DAYS`) | photos imported in the 30 days up to the latest import, newest import first, grouped by import day | | LR-LIB-BYDATE | Browse by date | P1 | ✅ | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` (`date_row`), `crates/catalog/src/query.rs` (`date_groups`) | year → month → day tree with library-wide counts; a row filters the current source by that date. Active filters show as removable chips under the grid header (`crates/ui-egui/src/panels/chips.rs`, with “+N more” and Clear all), the header counts “N of M photos” (matching of the source's total), a badge on the top-bar filter icon counts them, and an empty result says no photos match the active filters | -| LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum` | photos dragged from the grid onto an album row are added to it (LR-BEHAV-DRAGDROP) | +| LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum`, `crates/engine/src/tests_albums_local.rs`, `crates/ui-egui/src/tests_panels.rs` | photos dragged from the grid onto an album row are added to it (LR-BEHAV-DRAGDROP); albums hold library photos (issue #684): `album.create {addSelected}`, `album.addPhotos` and `album.toggleTarget` (B) skip browsed Local photos and return `skipped` (a toast says so), a Local-only selection is an error and leaves Photo ▸ Add to Album, the grid's right-click Add to Album, a folder row's Add Selected Photos and a drop onto an album row off with the hint to Add to My Photos first (`button:contextAddToAlbum`, `button:contextAlbum:`) | | LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`, `parent`), `cmd:album.move`, `cmd:album.reorder`, `cmd:album.sort`, `cmd:dialog.newAlbum` / `cmd:dialog.newFolder` / `cmd:dialog.smartAlbum` / `cmd:dialog.newSmartAlbum` (`parent`), `crates/ui-egui/src/panels/left.rs` (`albums_tree`, `folder_menu`, `disclosure_triangle`), `crates/ui-egui/src/tests_panels.rs` | Delete removes a folder and its nested albums as one undoable edit, keeps photos in the library and returns a deleted album view to All Photos (issue #501); folders nest and fold with the same disclosure triangle as Local / By Date / Folders / Keywords (`albumToggle:`; the row folds them too; open by default); right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder… creates inside it (the `dialog.new*` commands take `parent`), opening the folders down to the new row; creating is only allowed inside folders; moving: drag an album or folder row onto a folder (outlined when allowed; a closed folder opens after 0.6 s under the pointer; the "Albums" header takes an item that sits in a folder back to the top level while it is dragged), the Move to menu or `album.move` — one undo step each; not onto a plain album, itself or something inside it; drop on the top / bottom half of an album or the edge of a folder to place it there by hand (`cmd:album.reorder`, a line shows the place; ordered per folder, folders stay first, new albums go after the ordered ones; right-click ▸ Sort Contents A–Z or + ▸ Sort Albums A–Z = `cmd:album.sort`) | | LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs`, `crates/ui-egui/src/lib.rs` (`album_counts`) | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album) or a rule set from the rule editor (match all / any / none, nested groups; see LRC-LIB-SMARTCOLL), live; sidebar counts are cached per catalog revision (and per minute while an “in the last…” rule is used), counted without building id lists | | LR-LIB-SHARED-ALBUM | Shared albums | P2 | ⬜ | | needs a sharing service | @@ -600,7 +600,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-VIEW-STACKS | Expand/collapse stacks | P1 | ✅ | `cmd:stack.expandAll`, `cmd:stack.collapseAll` | | | MENU-VIEW-PHOTOCOUNT | Show photo counts | P2 | ✅ | `cmd:view.photoCounts` | View ▸ Show Photo Counts toggles the left panel's counts | | MENU-VIEW-HDR | HDR display options | P2 | ⬜ | | | -| MENU-PHOTO-ADDTOALBUM | Add to album | P0 | ✅ | `cmd:album.addPhotos` | | +| MENU-PHOTO-ADDTOALBUM | Add to album | P0 | ✅ | `cmd:album.addPhotos` | off for a Local-only selection, with an Add to My Photos First hint item (issue #684) | | MENU-PHOTO-REMOVEFROMALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | | | MENU-PHOTO-RATE | Rate submenu | P0 | ✅ | `cmd:photo.rate` | | | MENU-PHOTO-FLAG | Flag submenu | P0 | ✅ | `cmd:photo.flag` | |