# Conflicts:
#	crates/ui-egui/locales/de.json
#	crates/ui-egui/locales/es.json
#	crates/ui-egui/locales/fr.json
#	crates/ui-egui/locales/ja.json
#	crates/ui-egui/locales/pt-br.json
#	crates/ui-egui/locales/ru.json
#	crates/ui-egui/locales/uk.json
#	crates/ui-egui/locales/zh-hans.json
#	crates/ui-egui/locales/zh-hant.json
#	crates/ui-egui/src/tests_panels.rs
#	docs/parity-checklist.md
This commit is contained in:
Claude 2026-10-11 11:48:06 +00:00
commit 40492d238e
No known key found for this signature in database
28 changed files with 428 additions and 77 deletions

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| {

View File

@ -100,6 +100,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(()) }
}

View File

@ -873,6 +873,8 @@ mod tests;
#[cfg(test)]
mod tests_album_order;
#[cfg(test)]
mod tests_albums_local;
#[cfg(test)]
mod tests_color;
#[cfg(test)]
mod tests_crop_angle;

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());
}

View File

@ -230,5 +230,6 @@
"+{n} more": "+{n} weitere",
"1 task is still running: {names}. Quitting stops it.": "1 Aufgabe läuft noch: {names}. Beenden bricht sie ab.",
"{n} tasks are still running: {names}. Quitting stops them.": "{n} Aufgaben laufen noch: {names}. Beenden bricht sie ab.",
"{what} merge cancelled": "{what}-Zusammenfügung abgebrochen"
"{what} merge cancelled": "{what}-Zusammenfügung abgebrochen",
"{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)"
}

View File

@ -2046,5 +2046,7 @@
"Synchronizing folder": "Ordner wird synchronisiert",
"Looking for changes": "Änderungen werden gesucht",
"This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Dies ist ein Lightroom-Katalog, keine LightCraft-Bibliothek. Stattdessen in eine LightCraft-Bibliothek importieren?",
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "Der Import liest den Katalog, ohne ihn zu ändern, und fügt seine Fotos, Bearbeitungen, Stichwörter und Sammlungen der jetzt geöffneten Bibliothek hinzu."
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "Der Import liest den Katalog, ohne ihn zu ändern, und fügt seine Fotos, Bearbeitungen, Stichwörter und Sammlungen der jetzt geöffneten Bibliothek hinzu.",
"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"
}

View File

@ -230,5 +230,6 @@
"+{n} more": "+{n} más",
"1 task is still running: {names}. Quitting stops it.": "1 tarea sigue en curso: {names}. Salir la detiene.",
"{n} tasks are still running: {names}. Quitting stops them.": "{n} tareas siguen en curso: {names}. Salir las detiene.",
"{what} merge cancelled": "Combinación {what} cancelada"
"{what} merge cancelled": "Combinación {what} cancelada",
"{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)"
}

View File

@ -1891,5 +1891,7 @@
"Synchronizing folder": "Sincronizando carpeta",
"Looking for changes": "Buscando cambios",
"This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Esto es un catálogo de Lightroom, no una biblioteca de LightCraft. ¿Importarlo a una biblioteca de LightCraft?",
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "La importación lee el catálogo sin modificarlo y añade sus fotos, ediciones, palabras clave y colecciones a la biblioteca abierta ahora."
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "La importación lee el catálogo sin modificarlo y añade sus fotos, ediciones, palabras clave y colecciones a la biblioteca abierta ahora.",
"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"
}

View File

@ -230,5 +230,6 @@
"+{n} more": "+{n} autres",
"1 task is still running: {names}. Quitting stops it.": "1 tâche est encore en cours : {names}. Quitter l'arrête.",
"{n} tasks are still running: {names}. Quitting stops them.": "{n} tâches sont encore en cours : {names}. Quitter les arrête.",
"{what} merge cancelled": "Fusion {what} annulée"
"{what} merge cancelled": "Fusion {what} annulée",
"{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)"
}

View File

@ -1891,5 +1891,7 @@
"Resize panel window": "Resize panel window",
"Resize panels": "Resize panels",
"This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Ceci est un catalogue Lightroom, pas une bibliothèque LightCraft. L’importer plutôt dans une bibliothèque LightCraft ?",
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "L’importation lit le catalogue sans le modifier et ajoute ses photos, retouches, mots-clés et collections à la bibliothèque ouverte."
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "L’importation lit le catalogue sans le modifier et ajoute ses photos, retouches, mots-clés et collections à la bibliothèque ouverte.",
"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"
}

View File

@ -230,5 +230,6 @@
"+{n} more": "ほか{n}件",
"1 task is still running: {names}. Quitting stops it.": "1件のタスクが実行中です:{names}。終了すると停止します。",
"{n} tasks are still running: {names}. Quitting stops them.": "{n}件のタスクが実行中です:{names}。終了すると停止します。",
"{what} merge cancelled": "{what}合成をキャンセルしました"
"{what} merge cancelled": "{what}合成をキャンセルしました",
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "ローカル写真{n}枚をスキップしました{0:.0}:アルバムに入れられるのはライブラリの写真です(先にマイフォトに追加してください)"
}

View File

@ -1893,5 +1893,7 @@
"Synchronizing folder": "フォルダを同期中",
"Looking for changes": "変更を確認中",
"This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "これは Lightroom カタログで、LightCraft ライブラリではありません。代わりに LightCraft ライブラリに読み込みますか?",
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "読み込みはカタログを変更せずに読み取り、写真・編集・キーワード・コレクションを現在開いているライブラリに追加します。"
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "読み込みはカタログを変更せずに読み取り、写真・編集・キーワード・コレクションを現在開いているライブラリに追加します。",
"Albums hold library photos: Add to My Photos first": "アルバムに入れられるのはライブラリの写真です:先にマイフォトに追加してください",
"Add to My Photos First": "先にマイフォトに追加"
}

View File

@ -230,5 +230,6 @@
"+{n} more": "+{n} mais",
"1 task is still running: {names}. Quitting stops it.": "1 tarefa ainda está em andamento: {names}. Sair a interrompe.",
"{n} tasks are still running: {names}. Quitting stops them.": "{n} tarefas ainda estão em andamento: {names}. Sair as interrompe.",
"{what} merge cancelled": "{what} mesclagem cancelada"
"{what} merge cancelled": "{what} mesclagem cancelada",
"{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)"
}

View File

@ -1891,5 +1891,7 @@
"Synchronizing folder": "Sincronizando pasta",
"Looking for changes": "Procurando alterações",
"This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Este é um catálogo do Lightroom, não uma biblioteca do LightCraft. Importá-lo para uma biblioteca do LightCraft?",
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "A importação lê o catálogo sem alterá-lo e adiciona as fotos, edições, palavras-chave e coleções dele à biblioteca aberta agora."
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "A importação lê o catálogo sem alterá-lo e adiciona as fotos, edições, palavras-chave e coleções dele à biblioteca aberta agora.",
"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"
}

View File

@ -230,5 +230,6 @@
"+{n} more": "ещё {n}",
"1 task is still running: {names}. Quitting stops it.": "Ещё выполняется задача: {names}. Выход прервёт её.",
"{n} tasks are still running: {names}. Quitting stops them.": "Ещё выполняется задач: {n} — {names}. Выход прервёт их.",
"{what} merge cancelled": "{what}: слияние отменено"
"{what} merge cancelled": "{what}: слияние отменено",
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Пропущено локальных фото: {n}{0:.0} — в альбомах только фото из библиотеки (сначала добавьте их в «Мои фото»)"
}

View File

@ -1605,5 +1605,7 @@
"Synchronizing folder": "Синхронизация папки",
"Looking for changes": "Поиск изменений",
"This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Это каталог Lightroom, а не библиотека LightCraft. Импортировать его в библиотеку LightCraft?",
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "Импорт читает каталог, не изменяя его, и добавляет его фото, правки, ключевые слова и коллекции в открытую сейчас библиотеку."
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "Импорт читает каталог, не изменяя его, и добавляет его фото, правки, ключевые слова и коллекции в открытую сейчас библиотеку.",
"Albums hold library photos: Add to My Photos first": "В альбомах только фото из библиотеки: сначала добавьте их в «Мои фото»",
"Add to My Photos First": "Сначала добавить в «Мои фото»"
}

View File

@ -230,5 +230,6 @@
"+{n} more": "+{n} ще",
"1 task is still running: {names}. Quitting stops it.": "1 завдання ще виконується: {names}. Вихід зупинить його.",
"{n} tasks are still running: {names}. Quitting stops them.": "Ще виконуються завдання ({n}): {names}. Вихід зупинить їх.",
"{what} merge cancelled": "Об’єднання {what} скасовано"
"{what} merge cancelled": "Об’єднання {what} скасовано",
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Пропущено локальних фото: {n}{0:.0} — в альбомах лише фото з бібліотеки (спершу додайте їх до моїх фото)"
}

View File

@ -2085,5 +2085,7 @@
"Resize panel window": "Resize panel window",
"Resize panels": "Resize panels",
"This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Це каталог Lightroom, а не бібліотека LightCraft. Імпортувати його до бібліотеки LightCraft?",
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "Імпорт читає каталог, не змінюючи його, і додає його фото, редагування, ключові слова та колекції до відкритої зараз бібліотеки."
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "Імпорт читає каталог, не змінюючи його, і додає його фото, редагування, ключові слова та колекції до відкритої зараз бібліотеки.",
"Albums hold library photos: Add to My Photos first": "В альбомах лише фото з бібліотеки: спершу додайте їх до моїх фото",
"Add to My Photos First": "Спершу додати до моїх фото"
}

View File

@ -230,5 +230,6 @@
"+{n} more": "还有 {n} 项",
"1 task is still running: {names}. Quitting stops it.": "仍有 1 个任务在运行:{names}。退出将停止它。",
"{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 个任务在运行:{names}。退出将停止它们。",
"{what} merge cancelled": "已取消 {what} 合并"
"{what} merge cancelled": "已取消 {what} 合并",
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已跳过 {n} 张本地照片{0:.0}:相册只能收录图库中的照片(请先添加到我的照片)"
}

View File

@ -1891,5 +1891,7 @@
"Synchronizing folder": "正在同步文件夹",
"Looking for changes": "正在查找更改",
"This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "这是 Lightroom 目录,不是 LightCraft 图库。要改为将其导入 LightCraft 图库吗?",
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "导入只读取目录、不做任何更改,并将其中的照片、编辑、关键字和收藏夹添加到当前打开的图库。"
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "导入只读取目录、不做任何更改,并将其中的照片、编辑、关键字和收藏夹添加到当前打开的图库。",
"Albums hold library photos: Add to My Photos first": "相册只能收录图库中的照片:请先添加到我的照片",
"Add to My Photos First": "先添加到我的照片"
}

View File

@ -230,5 +230,6 @@
"+{n} more": "還有 {n} 項",
"1 task is still running: {names}. Quitting stops it.": "仍有 1 個工作在執行:{names}。結束將停止它。",
"{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 個工作在執行:{names}。結束將停止它們。",
"{what} merge cancelled": "已取消{what}合併"
"{what} merge cancelled": "已取消{what}合併",
"{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已略過 {n} 張本機照片{0:.0}:相簿只能收錄圖庫中的照片(請先加入我的照片)"
}

View File

@ -1891,5 +1891,7 @@
"Synchronizing folder": "正在同步資料夾",
"Looking for changes": "正在尋找變更",
"This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "這是 Lightroom 目錄,不是 LightCraft 照片庫。要改為將其匯入 LightCraft 照片庫嗎?",
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "匯入只讀取目錄、不做任何變更,並將其中的照片、編輯、關鍵字和收藏夾加入目前開啟的照片庫。"
"The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "匯入只讀取目錄、不做任何變更,並將其中的照片、編輯、關鍵字和收藏夾加入目前開啟的照片庫。",
"Albums hold library photos: Add to My Photos first": "相簿只能收錄圖庫中的照片:請先加入我的照片",
"Add to My Photos First": "先加入我的照片"
}

View File

@ -575,15 +575,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,

View File

@ -1245,6 +1245,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;

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())

View File

@ -1010,12 +1010,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;
@ -1047,8 +1071,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);

View File

@ -1227,5 +1227,99 @@ fn recognise_faces_stays_off_until_a_model_is_chosen() {
assert!(!enabled(&mut h), "the box switches recognition off");
click(&mut h);
assert!(enabled(&mut h), "and on again, with a model chosen");
/// 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);
}

File diff suppressed because one or more lines are too long