LR-LIB-ALBUM: albums hold library photos; Local photos are skipped, a Local-only selection is refused (#684)

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:<id>.

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.
This commit is contained in:
Jason Baldwin
2026-10-10 10:54:07 +02:00
parent 5436951299
commit bec920f67d
28 changed files with 430 additions and 77 deletions
+74 -37
View File
@@ -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<Vec<PhotoId>> {
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<PhotoId>) -> (Vec<PhotoId>, usize) {
let before = targets.len();
let kept: Vec<PhotoId> = 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<PhotoId>) -> Result<(Vec<PhotoId>, 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<Op>) -> Result<Value> {
let targets = s.targets(p);
@@ -866,22 +884,31 @@ pub fn specs() -> Vec<CommandSpec> {
}
),
// ---- 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<CommandSpec> {
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<CommandSpec> {
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| {
+8
View File
@@ -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(()) }
}
+2
View File
@@ -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;
+99
View File
@@ -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<PhotoId> = 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, &params).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());
}
+2 -1
View File
@@ -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)"
}
+3 -1
View File
@@ -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"
}
+2 -1
View File
@@ -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)"
}
+3 -1
View File
@@ -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"
}
+2 -1
View File
@@ -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)"
}
+3 -1
View File
@@ -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"
}
+2 -1
View File
@@ -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}:アルバムに入れられるのはライブラリの写真です(先にマイフォトに追加してください)"
}
+3 -1
View File
@@ -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": "先にマイフォトに追加"
}
+2 -1
View File
@@ -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)"
}
+3 -1
View File
@@ -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"
}
+2 -1
View File
@@ -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} — в альбомах только фото из библиотеки (сначала добавьте их в «Мои фото»)"
}
+3 -1
View File
@@ -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": "Сначала добавить в «Мои фото»"
}
+2 -1
View File
@@ -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} — в альбомах лише фото з бібліотеки (спершу додайте їх до моїх фото)"
}
+3 -1
View File
@@ -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": "Спершу додати до моїх фото"
}
+2 -1
View File
@@ -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}:相册只能收录图库中的照片(请先添加到我的照片)"
}
+3 -1
View File
@@ -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": "先添加到我的照片"
}
+2 -1
View File
@@ -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}:相簿只能收錄圖庫中的照片(請先加入我的照片)"
}
+3 -1
View File
@@ -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": "先加入我的照片"
}
+15 -7
View File
@@ -573,15 +573,23 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> {
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<MenuNode> =
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<MenuNode> = 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,
+5
View File
@@ -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;
+45 -7
View File
@@ -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())
+38 -5
View File
@@ -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<lightcraft_catalog::PhotoId> = 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);
+96
View File
@@ -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<crate::menubar::MenuNode> {
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<bool> {
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);
}
+3 -3
View File
File diff suppressed because one or more lines are too long