From b90679da15385f6cbea7416ceb97d7f6fe1acf82 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Sat, 24 Oct 2026 09:21:45 -0400 Subject: [PATCH 1/3] LR-LIB-ALBUM: album commands skip photos the library lacks and record nothing when they add nothing album_targets kept ids the catalog doesn't have, so album.create {addSelected}, album.addPhotos and album.toggleTarget could name a missing photo in an album. It now drops them before anything changes. album.addPhotos and album.toggleTarget also committed when nothing was added (every photo already in, or none left), which recorded an undo step and cleared redo. They now return without a commit; toggleTarget also makes no Quick Collection for nothing. --- crates/engine/src/cmd/library/selection.rs | 18 ++- crates/engine/src/cmd/library/specs.rs | 26 +++- crates/engine/src/tests.rs | 1 + crates/engine/src/tests/album_missing.rs | 135 +++++++++++++++++++++ crates/ui-egui/src/shortcuts.rs | 5 +- docs/parity-checklist.md | 5 +- docs/whats-new.md | 7 ++ 7 files changed, 186 insertions(+), 11 deletions(-) create mode 100644 crates/engine/src/tests/album_missing.rs diff --git a/crates/engine/src/cmd/library/selection.rs b/crates/engine/src/cmd/library/selection.rs index 723b7c331..964b2a8c8 100644 --- a/crates/engine/src/cmd/library/selection.rs +++ b/crates/engine/src/cmd/library/selection.rs @@ -35,10 +35,22 @@ pub(super) fn strs(p: &Value, key: &str) -> Vec { /// 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. +/// An id the catalog doesn't have (named outright, or kept selected after an undo took its photo +/// away) is dropped before anything changes, and isn't counted: `skipped` stays the Local count +/// the toasts name. pub(super) 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(); + let mut skipped = 0; + let kept: Vec = targets + .into_iter() + .filter(|id| match s.catalog.photo(*id) { + None => false, + Some(p) if p.local => { + skipped += 1; + false + } + Some(_) => true, + }) + .collect(); (kept, skipped) } diff --git a/crates/engine/src/cmd/library/specs.rs b/crates/engine/src/cmd/library/specs.rs index 2e7c1311d..df54d9162 100644 --- a/crates/engine/src/cmd/library/specs.rs +++ b/crates/engine/src/cmd/library/specs.rs @@ -1027,7 +1027,7 @@ pub fn specs() -> Vec { "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", + "{id: albumId, ids?: [photoIds]} (default: selection) → {added, skipped}; Local photos are skipped (albums hold library photos), only Local ones is an error; ids the library doesn't have are dropped; nothing new to add changes nothing (no undo step)", has_library_selection, |s, p| { let id = album_param(p, "id", "album.addPhotos")?; @@ -1047,6 +1047,11 @@ pub fn specs() -> Vec { } } let added = photos.len() - before; + // all of them were there already (or none was left to add): the album is unchanged, + // so no undo step, and redo stays + if added == 0 { + return Ok(json!({"added": 0, "skipped": skipped})); + } 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})) @@ -1057,12 +1062,23 @@ pub fn specs() -> Vec { "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, skipped}; Local photos are skipped", + "{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, toggled, skipped}; Local photos are skipped, ids the library doesn't have are dropped (none left: nothing changes and `added` is null)", has_library_selection, |s, p| { let targets = album_targets_or_err(s, "album.toggleTarget", crate::cmd::photo_ids(p, "album.toggleTarget")?.unwrap_or_else(|| s.selected()))?; let (targets, skipped) = targets; + // nothing left to toggle (every id named is gone): no album is made or changed + if targets.is_empty() { + let existing = s + .target_album + .filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder)) + .or_else(|| s.catalog.quick_collection()); + let al = existing.and_then(|a| s.catalog.album(a)); + return Ok( + json!({"album": al.map(|a| a.id.0), "name": al.map(|a| a.name.clone()), "added": null, "count": al.map_or(0, |a| s.catalog.album_count(a.id)), "skipped": skipped}), + ); + } 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, @@ -1076,7 +1092,7 @@ pub fn specs() -> Vec { let al = s.catalog.album(id).ok_or_else(|| bad("album.toggleTarget", "no target album"))?; // looked up, not searched for: thousands toggled in a big album stay linear let mut held: std::collections::HashSet = al.photos.iter().copied().collect(); - let all_in = !targets.is_empty() && targets.iter().all(|t| held.contains(t)); + let all_in = targets.iter().all(|t| held.contains(t)); let photos: Vec = if all_in { let targets: std::collections::HashSet = targets.iter().copied().collect(); al.photos.iter().copied().filter(|x| !targets.contains(x)).collect() @@ -1084,12 +1100,14 @@ pub fn specs() -> Vec { al.photos.iter().copied().chain(targets.iter().copied().filter(|t| held.insert(*t))).collect() }; let name = al.name.clone(); + // photos that went in or came out: what a toast can say (not the ones that were in already) + let toggled = al.photos.len().abs_diff(photos.len()); let cover = al.cover.filter(|c| photos.contains(c)).or(photos.first().copied()); s.commit( 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), "skipped": skipped})) + Ok(json!({"album": id.0, "name": name, "added": !all_in, "toggled": toggled, "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/tests.rs b/crates/engine/src/tests.rs index ae03859d2..7b486374e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -2,6 +2,7 @@ use serde_json::{Value, json}; use crate::{Selection, Session, command_specs}; +mod album_missing; mod album_order; mod album_remove; mod albums_local; diff --git a/crates/engine/src/tests/album_missing.rs b/crates/engine/src/tests/album_missing.rs new file mode 100644 index 000000000..016f7e3df --- /dev/null +++ b/crates/engine/src/tests/album_missing.rs @@ -0,0 +1,135 @@ +//! Album commands only take photos the catalog has, and a call that changes nothing records +//! nothing. +//! +//! Scenarios: +//! - Given ids the catalog doesn't have (named outright, or still selected after an undo took the +//! photos away), when they are added to an album, toggled into the target album, or used for a +//! new album, then they reach no album. +//! - Given nothing is left to add, or every photo is in the album already, then there is no undo +//! step and redo is left alone. +//! - Given a normal add or toggle, then one step is recorded and one undo restores the album. + +use lightcraft_catalog::{AlbumId, PhotoId}; +use serde_json::json; + +use crate::{Selection, Session}; + +const GONE: PhotoId = PhotoId(987_654); + +fn album(s: &mut Session, name: &str) -> AlbumId { + AlbumId(s.execute("album.create", &json!({"name": name})).unwrap()["id"].as_u64().unwrap()) +} + +/// A demo session, an album, and a redo entry waiting (a rating set, then undone). +fn with_redo() -> (Session, AlbumId, PhotoId) { + let mut s = Session::with_demo(); + let photo = s.catalog.photos().next().unwrap().id; + let a = album(&mut s, "Trip"); + s.execute("photo.rate", &json!({"ids": [photo.0], "rating": 4})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.redo.len(), 1); + (s, a, photo) +} + +#[test] +fn photos_the_catalog_lacks_reach_no_album() { + let (mut s, a, photo) = with_redo(); + let (undo, redo) = (s.undo.len(), s.redo.len()); + let r = s.execute("album.addPhotos", &json!({"id": a.0, "ids": [GONE.0]})).unwrap(); + assert_eq!((r["added"].as_u64(), r["skipped"].as_u64()), (Some(0), Some(0)), "{r}"); + assert!(s.catalog.album(a).unwrap().photos.is_empty()); + assert_eq!((s.undo.len(), s.redo.len()), (undo, redo), "no step, redo kept"); + + // a mix: the one that is there goes in, the other doesn't + let r = s.execute("album.addPhotos", &json!({"id": a.0, "ids": [GONE.0, photo.0]})).unwrap(); + assert_eq!(r["added"], 1, "{r}"); + assert_eq!(s.catalog.album(a).unwrap().photos, vec![photo]); + assert_eq!(s.undo.len(), undo + 1); + assert_eq!(s.redo.len(), 0, "a real change clears redo"); + + // toggling: nothing is made or changed for ids that are gone + let r = s.execute("album.toggleTarget", &json!({"ids": [GONE.0]})).unwrap(); + assert!(r["added"].is_null(), "{r}"); + assert!(s.catalog.quick_collection().is_none(), "no Quick Collection for nothing"); + assert_eq!(s.undo.len(), undo + 1); + + // a new album from a selection that is gone starts empty + s.selection = Selection::single(GONE); + let r = s.execute("album.create", &json!({"name": "New", "addSelected": true})).unwrap(); + let new = s.catalog.album(AlbumId(r["id"].as_u64().unwrap())).unwrap(); + assert!(new.photos.is_empty() && new.cover.is_none(), "{r}"); +} + +/// Leaves one redo entry waiting: a rating set, then undone. +fn leave_redo(s: &mut Session, photo: PhotoId) { + s.execute("photo.rate", &json!({"ids": [photo.0], "rating": 4})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.redo.len(), 1); +} + +#[test] +fn toggling_gone_photos_leaves_the_target_album_and_redo_alone() { + let mut s = Session::with_demo(); + let photo = s.catalog.photos().next().unwrap().id; + let a = album(&mut s, "Trip"); + s.execute("album.setTarget", &json!({"id": a.0})).unwrap(); + s.execute("album.toggleTarget", &json!({"ids": [photo.0]})).unwrap(); + leave_redo(&mut s, photo); + let undo = s.undo.len(); + let r = s.execute("album.toggleTarget", &json!({"ids": [GONE.0]})).unwrap(); + assert_eq!(r["album"].as_u64(), Some(a.0), "{r}"); + assert!(r["added"].is_null(), "{r}"); + assert_eq!(s.catalog.album(a).unwrap().photos, vec![photo]); + assert_eq!((s.undo.len(), s.redo.len()), (undo, 1)); +} + +#[test] +fn adding_photos_that_are_all_there_already_records_nothing() { + let mut s = Session::with_demo(); + let photo = s.catalog.photos().next().unwrap().id; + let a = album(&mut s, "Trip"); + s.execute("album.addPhotos", &json!({"id": a.0, "ids": [photo.0]})).unwrap(); + leave_redo(&mut s, photo); + let undo = s.undo.len(); + let r = s.execute("album.addPhotos", &json!({"id": a.0, "ids": [photo.0]})).unwrap(); + assert_eq!(r["added"], 0, "{r}"); + assert_eq!((s.undo.len(), s.redo.len()), (undo, 1), "no step, redo kept"); +} + +#[test] +fn a_normal_add_or_toggle_is_one_step_and_one_undo_restores_it() { + let (mut s, a, photo) = with_redo(); + let undo = s.undo.len(); + s.execute("album.addPhotos", &json!({"id": a.0, "ids": [photo.0]})).unwrap(); + assert_eq!(s.undo.len(), undo + 1); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.catalog.album(a).unwrap().photos.is_empty()); + + s.execute("album.setTarget", &json!({"id": a.0})).unwrap(); + let undo = s.undo.len(); + let r = s.execute("album.toggleTarget", &json!({"ids": [GONE.0, photo.0]})).unwrap(); + assert_eq!((r["added"].as_bool(), r["toggled"].as_u64(), s.undo.len()), (Some(true), Some(1), undo + 1), "{r}"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.catalog.album(a).unwrap().photos.is_empty()); + s.execute("edit.redo", &json!({})).unwrap(); + // toggled off: also one step, one undo puts the photo back + let undo = s.undo.len(); + let r = s.execute("album.toggleTarget", &json!({"ids": [photo.0]})).unwrap(); + assert_eq!((r["added"].as_bool(), r["toggled"].as_u64(), s.undo.len()), (Some(false), Some(1), undo + 1), "{r}"); + assert!(s.catalog.album(a).unwrap().photos.is_empty()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.album(a).unwrap().photos, vec![photo]); +} + +#[test] +fn a_toggle_counts_only_the_photos_it_added() { + let mut s = Session::with_demo(); + let ids: Vec = s.catalog.photos().take(3).map(|p| p.id).collect(); + let a = album(&mut s, "Trip"); + s.execute("album.setTarget", &json!({"id": a.0})).unwrap(); + s.execute("album.addPhotos", &json!({"id": a.0, "ids": [ids[0].0, ids[1].0]})).unwrap(); + let all: Vec = ids.iter().map(|p| p.0).collect(); + let r = s.execute("album.toggleTarget", &json!({"ids": all})).unwrap(); + assert_eq!((r["added"].as_bool(), r["toggled"].as_u64()), (Some(true), Some(1)), "two were in already: {r}"); + assert_eq!(s.catalog.album(a).unwrap().photos.len(), 3); +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 2f0b07d45..32ca796cd 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -710,8 +710,9 @@ fn brush_key(app: &mut LightcraftApp, ctx: &egui::Context) -> bool { if !library_grid(app) { return false; } - if let Ok(r) = app.run("album.toggleTarget", json!({})) { - let n = app.session.selected().len(); + // `added` is null when nothing was left to toggle (the selected photos are gone): nothing to say + if let Some(r) = app.run("album.toggleTarget", json!({})).ok().filter(|r| r["added"].is_boolean()) { + let n = r["toggled"].as_u64().unwrap_or(0); let what = crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n); let name = r["name"].as_str().unwrap_or("Quick Collection").to_string(); app.toast( diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 90cfc248f..477b0f8cf 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (LR-LIB-ALBUM: album commands leave out photos the library no longer has and record nothing when they add nothing; LR-BEHAV-TITLEBAR: a double-click follows the system setting, and a panel over the bar keeps the window still; KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 go to the next / previous keyword set; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings; KEYC-METADATA: ⌘K / Ctrl+K Add Keywords; LRC-DEV-HISTOGRAM-DRAG: the histogram's five tonal zones — hover for the value, drag to adjust the Basic control the press locked, double-click to reset; LR-VIEW-HISTOGRAM note updated; MENU-BAR: on macOS a menu item that yields its key to a focused text field or to shortcut recording really lets it go; LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -94,7 +94,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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`, `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:`); right-click a photo ▸ Add to Album lists the albums and ends with New Album…, which creates one holding the selection — also when the library has no album yet (issue #676; `button:contextNewAlbum`); New Album's "Include selected photos" (`check:albumIncludeSelected`, on by default; `dialog.newAlbum {includeSelected}`) leaves the selection out when unticked, and, as in Lightroom, is only shown when something is selected (never for a folder) | +| 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:`); right-click a photo ▸ Add to Album lists the albums and ends with New Album…, which creates one holding the selection — also when the library has no album yet (issue #676; `button:contextNewAlbum`); New Album's "Include selected photos" (`check:albumIncludeSelected`, on by default; `dialog.newAlbum {includeSelected}`) leaves the selection out when unticked, and, as in Lightroom, is only shown when something is selected (never for a folder); album commands only take photos the library has (`album.create {addSelected}`, `album.addPhotos` and `album.toggleTarget` drop ids it lacks) and `album.addPhotos` with nothing to add (all photos already in, or none left) records no undo step and leaves redo alone; `album.toggleTarget` with none left changes nothing (`added` is null, no toast) | | 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`, `cmd:library.holdView`, `cmd:library.refresh`, `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; views and source counts with direct or nested relative-date rules expire on the session-clock minute; sidebar counts remain cached without building id lists; View ▸ Hold View Until Refreshed (`library.holdView`, off by default) keeps a photo that an edit or clock expiry made stop matching (a smart album, Picks, the filter bar) in the view until Refresh View, Reshuffle or another source, filter or sort, while newly matching photos enter at once | | LR-LIB-SHARED-ALBUM | Shared albums | P2 | ⬜ | | needs a sharing service | @@ -959,3 +959,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-23 | patch | LR-BEHAV-ACCESS: the name field of New Album, New Smart Album, Rename and the other one-field dialogs takes the keyboard focus once when the dialog opens instead of every frame, so Tab and Shift+Tab reach the check box, Cancel and OK; status unchanged (🟡) | | 2026-10-22 | minor | LR-CROP-AUTO: Auto straighten turns by at most 10° and only on at least 3 agreeing lines, and says when it finds nothing to level by; measured on 68 photos straightened by hand and 300 left as shot, unwanted turns of more than 1° fell from 79 to 19 of the 300. Downgraded ✅ → 🟡: it still leaves most tilted photos alone | | 2026-10-23 | patch | LRC-LIB-FILTERBAR: showing an album (`library.source`) while the filter holds an album drops the filter's album, as showing a library folder already dropped its folder filter; before, the old album stayed in the filter unseen (the filter bar has no album chip) and narrowed the next source such as All Photos; the row's note says so; status unchanged (✅) | +| 2026-10-24 | patch | LR-LIB-ALBUM: `album.create {addSelected}`, `album.addPhotos` and `album.toggleTarget` drop photo ids the catalog lacks before any album changes, and an add that adds nothing records no undo step and leaves redo alone; status unchanged (✅) | diff --git a/docs/whats-new.md b/docs/whats-new.md index c6c259200..614e0791d 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -19,6 +19,13 @@ photo, and the line can be dragged: pressing it picks the region under the pointer (Highlights, Lights, Darks or Shadows) and moving up or down changes that slider, as one undo step. Hovering the line names the region first. +### Adding photos that are already in an album no longer costs an undo step +- Add to Album and dragging photos to an album now leave the album, the undo history and Redo alone when every + photo was in the album already. Before, they still recorded an undo step, so Redo was lost and Undo had a step + that changed nothing. Photos the library no longer has (a selection kept after an Undo took them away) are also + left out of a new album made from the selection, out of Add to Album, and out of the target album (B), which does + nothing and says nothing when none of them is left, and counts only the photos it changed. + ## 2026-10-23 ### Tab leaves the name field in New Album, New Smart Album and the name prompts From f5e1dd2cbef090d617528a4ad9416ede66772d9a Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Sat, 24 Oct 2026 13:30:56 -0400 Subject: [PATCH 2/3] LR-LIB-ALBUM: album.toggleTarget reports toggled: 0 when no photo is left to toggle The command description lists toggled in every result; the early return left it out, so a client read null instead of 0. --- crates/engine/src/cmd/library/specs.rs | 2 +- crates/engine/src/tests/album_missing.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/library/specs.rs b/crates/engine/src/cmd/library/specs.rs index df54d9162..0dcbefe19 100644 --- a/crates/engine/src/cmd/library/specs.rs +++ b/crates/engine/src/cmd/library/specs.rs @@ -1076,7 +1076,7 @@ pub fn specs() -> Vec { .or_else(|| s.catalog.quick_collection()); let al = existing.and_then(|a| s.catalog.album(a)); return Ok( - json!({"album": al.map(|a| a.id.0), "name": al.map(|a| a.name.clone()), "added": null, "count": al.map_or(0, |a| s.catalog.album_count(a.id)), "skipped": skipped}), + json!({"album": al.map(|a| a.id.0), "name": al.map(|a| a.name.clone()), "added": null, "toggled": 0, "count": al.map_or(0, |a| s.catalog.album_count(a.id)), "skipped": skipped}), ); } let target = s.target_album.filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder)); diff --git a/crates/engine/src/tests/album_missing.rs b/crates/engine/src/tests/album_missing.rs index 016f7e3df..560e2074c 100644 --- a/crates/engine/src/tests/album_missing.rs +++ b/crates/engine/src/tests/album_missing.rs @@ -49,7 +49,7 @@ fn photos_the_catalog_lacks_reach_no_album() { // toggling: nothing is made or changed for ids that are gone let r = s.execute("album.toggleTarget", &json!({"ids": [GONE.0]})).unwrap(); - assert!(r["added"].is_null(), "{r}"); + assert!(r["added"].is_null() && r["toggled"] == 0, "{r}"); assert!(s.catalog.quick_collection().is_none(), "no Quick Collection for nothing"); assert_eq!(s.undo.len(), undo + 1); @@ -78,7 +78,7 @@ fn toggling_gone_photos_leaves_the_target_album_and_redo_alone() { let undo = s.undo.len(); let r = s.execute("album.toggleTarget", &json!({"ids": [GONE.0]})).unwrap(); assert_eq!(r["album"].as_u64(), Some(a.0), "{r}"); - assert!(r["added"].is_null(), "{r}"); + assert!(r["added"].is_null() && r["toggled"] == 0, "{r}"); assert_eq!(s.catalog.album(a).unwrap().photos, vec![photo]); assert_eq!((s.undo.len(), s.redo.len()), (undo, 1)); } From 865cca9290589601b84a44ce4f39cea4d24714e1 Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Thu, 29 Oct 2026 11:09:20 -0400 Subject: [PATCH 3/3] LR-LIB-ALBUM: album.addPhotos and album.toggleTarget take photos named in ids when nothing usable is selected --- crates/engine/src/cmd.rs | 14 ++ crates/engine/src/cmd/library/specs.rs | 185 ++++++++++++----------- crates/engine/src/tests/album_missing.rs | 32 ++++ 3 files changed, 145 insertions(+), 86 deletions(-) diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 9b8dc3dbd..daddc7f6e 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -193,6 +193,20 @@ pub(crate) fn names_photos(p: &Value) -> bool { ["ids", "id"].iter().any(|k| p.get(k).is_some_and(|v| !v.is_null())) } +/// The call names its target photos in `ids`, whatever else it carries in `id` (`album.addPhotos`'s `id` is the album). +pub(crate) fn names_photo_ids(p: &Value) -> bool { + p.get("ids").is_some_and(|v| !v.is_null()) +} + +/// For a command that opts into `explicit_targets` but whose `id` isn't a photo: [`Session::execute`] skipped the +/// selection check because the call carries an `id`, so the command makes it itself unless `ids` names the photos. +pub(crate) fn enabled_unless_ids(s: &Session, p: &Value, id: &str, enabled: Enabled) -> Result<()> { + if names_photo_ids(p) { + return Ok(()); + } + enabled(s).map_err(|why| EngineError::Disabled(id.to_string(), why)) +} + /// The photos `ids` names: `None` when it is missing or null (the command then falls back to the /// selection, or its own default), else every entry as a photo id. An `ids` that isn't a list of /// whole numbers is an error, never `None`: falling back would act on the selection instead of diff --git a/crates/engine/src/cmd/library/specs.rs b/crates/engine/src/cmd/library/specs.rs index 0dcbefe19..aef3f6cdf 100644 --- a/crates/engine/src/cmd/library/specs.rs +++ b/crates/engine/src/cmd/library/specs.rs @@ -1022,94 +1022,107 @@ pub fn specs() -> Vec { Ok(json!({"changed": changed})) } ), - 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; ids the library doesn't have are dropped; nothing new to add changes nothing (no undo step)", - has_library_selection, - |s, p| { - let id = album_param(p, "id", "album.addPhotos")?; - let targets = crate::cmd::photo_ids(p, "album.addPhotos")?.unwrap_or_else(|| s.selected()); - 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(); - // looked up, not searched for: adding thousands to a big album stays linear - let mut held: std::collections::HashSet = photos.iter().copied().collect(); - for t in targets { - if held.insert(t) { - photos.push(t); + CommandSpec { + explicit_targets: true, + ..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; ids the library doesn't have are dropped; nothing new to add changes nothing (no undo step)", + has_library_selection, + |s, p| { + crate::cmd::enabled_unless_ids(s, p, "album.addPhotos", has_library_selection)?; + let id = album_param(p, "id", "album.addPhotos")?; + let targets = crate::cmd::photo_ids(p, "album.addPhotos")?.unwrap_or_else(|| s.selected()); + 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 added = photos.len() - before; - // all of them were there already (or none was left to add): the album is unchanged, - // so no undo step, and redo stays - if added == 0 { - return Ok(json!({"added": 0, "skipped": skipped})); - } - 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})) - } - ), - 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, toggled, skipped}; Local photos are skipped, ids the library doesn't have are dropped (none left: nothing changes and `added` is null)", - has_library_selection, - |s, p| { - let targets = - album_targets_or_err(s, "album.toggleTarget", crate::cmd::photo_ids(p, "album.toggleTarget")?.unwrap_or_else(|| s.selected()))?; - let (targets, skipped) = targets; - // nothing left to toggle (every id named is gone): no album is made or changed - if targets.is_empty() { - let existing = s - .target_album - .filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder)) - .or_else(|| s.catalog.quick_collection()); - let al = existing.and_then(|a| s.catalog.album(a)); - return Ok( - json!({"album": al.map(|a| a.id.0), "name": al.map(|a| a.name.clone()), "added": null, "toggled": 0, "count": al.map_or(0, |a| s.catalog.album_count(a.id)), "skipped": skipped}), - ); - } - 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, - None => { - let id = s.catalog.alloc_album_id(); - let album = Album { quick: true, ..Album::new(id, "Quick Collection") }; - s.commit("New Quick Collection", Op::AddAlbum { album })?; - id + let (targets, skipped) = album_targets_or_err(s, "album.addPhotos", targets)?; + let mut photos = al.photos.clone(); + let before = photos.len(); + // looked up, not searched for: adding thousands to a big album stays linear + let mut held: std::collections::HashSet = photos.iter().copied().collect(); + for t in targets { + if held.insert(t) { + photos.push(t); + } } - }; - let al = s.catalog.album(id).ok_or_else(|| bad("album.toggleTarget", "no target album"))?; - // looked up, not searched for: thousands toggled in a big album stay linear - let mut held: std::collections::HashSet = al.photos.iter().copied().collect(); - let all_in = targets.iter().all(|t| held.contains(t)); - let photos: Vec = if all_in { - let targets: std::collections::HashSet = targets.iter().copied().collect(); - al.photos.iter().copied().filter(|x| !targets.contains(x)).collect() - } else { - al.photos.iter().copied().chain(targets.iter().copied().filter(|t| held.insert(*t))).collect() - }; - let name = al.name.clone(); - // photos that went in or came out: what a toast can say (not the ones that were in already) - let toggled = al.photos.len().abs_diff(photos.len()); - let cover = al.cover.filter(|c| photos.contains(c)).or(photos.first().copied()); - s.commit( - 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, "toggled": toggled, "count": s.catalog.album_count(id), "skipped": skipped})) - } - ), + let added = photos.len() - before; + // all of them were there already (or none was left to add): the album is unchanged, + // so no undo step, and redo stays + if added == 0 { + return Ok(json!({"added": 0, "skipped": skipped})); + } + 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})) + } + ) + }, + CommandSpec { + explicit_targets: true, + ..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, toggled, skipped}; Local photos are skipped, ids the library doesn't have are dropped (none left: nothing changes and `added` is null)", + has_library_selection, + |s, p| { + crate::cmd::enabled_unless_ids(s, p, "album.toggleTarget", has_library_selection)?; + let targets = album_targets_or_err( + s, + "album.toggleTarget", + crate::cmd::photo_ids(p, "album.toggleTarget")?.unwrap_or_else(|| s.selected()), + )?; + let (targets, skipped) = targets; + // nothing left to toggle (every id named is gone): no album is made or changed + if targets.is_empty() { + let existing = s + .target_album + .filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder)) + .or_else(|| s.catalog.quick_collection()); + let al = existing.and_then(|a| s.catalog.album(a)); + return Ok( + json!({"album": al.map(|a| a.id.0), "name": al.map(|a| a.name.clone()), "added": null, "toggled": 0, "count": al.map_or(0, |a| s.catalog.album_count(a.id)), "skipped": skipped}), + ); + } + 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, + None => { + let id = s.catalog.alloc_album_id(); + let album = Album { quick: true, ..Album::new(id, "Quick Collection") }; + s.commit("New Quick Collection", Op::AddAlbum { album })?; + id + } + }; + let al = s.catalog.album(id).ok_or_else(|| bad("album.toggleTarget", "no target album"))?; + // looked up, not searched for: thousands toggled in a big album stay linear + let mut held: std::collections::HashSet = al.photos.iter().copied().collect(); + let all_in = targets.iter().all(|t| held.contains(t)); + let photos: Vec = if all_in { + let targets: std::collections::HashSet = targets.iter().copied().collect(); + al.photos.iter().copied().filter(|x| !targets.contains(x)).collect() + } else { + al.photos.iter().copied().chain(targets.iter().copied().filter(|t| held.insert(*t))).collect() + }; + let name = al.name.clone(); + // photos that went in or came out: what a toast can say (not the ones that were in already) + let toggled = al.photos.len().abs_diff(photos.len()); + let cover = al.cover.filter(|c| photos.contains(c)).or(photos.first().copied()); + s.commit( + 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, "toggled": toggled, "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| { s.target_album = match p.get("id").and_then(Value::as_u64) { Some(a) => { diff --git a/crates/engine/src/tests/album_missing.rs b/crates/engine/src/tests/album_missing.rs index 560e2074c..451f7a407 100644 --- a/crates/engine/src/tests/album_missing.rs +++ b/crates/engine/src/tests/album_missing.rs @@ -133,3 +133,35 @@ fn a_toggle_counts_only_the_photos_it_added() { assert_eq!((r["added"].as_bool(), r["toggled"].as_u64()), (Some(true), Some(1)), "two were in already: {r}"); assert_eq!(s.catalog.album(a).unwrap().photos.len(), 3); } + +#[test] +fn photos_named_outright_reach_the_album_commands_with_nothing_selected() { + let mut s = Session::with_demo(); + let photo = s.catalog.photos().next().unwrap().id; + let a = album(&mut s, "Trip"); + s.execute("album.setTarget", &json!({"id": a.0})).unwrap(); + s.selection = Selection::default(); + + // ids the library lacks: the no-op result, not a refusal + let r = s.execute("album.addPhotos", &json!({"id": a.0, "ids": [GONE.0]})).unwrap(); + assert_eq!((r["added"].as_u64(), r["skipped"].as_u64()), (Some(0), Some(0)), "{r}"); + let r = s.execute("album.toggleTarget", &json!({"ids": [GONE.0]})).unwrap(); + assert!(r["added"].is_null(), "{r}"); + + // ids the library has: they go in + let r = s.execute("album.addPhotos", &json!({"id": a.0, "ids": [photo.0]})).unwrap(); + assert_eq!(r["added"], 1, "{r}"); + let r = s.execute("album.toggleTarget", &json!({"ids": [photo.0]})).unwrap(); + assert_eq!((r["added"].as_bool(), r["toggled"].as_u64()), (Some(false), Some(1)), "{r}"); +} + +#[test] +fn the_album_an_add_names_is_not_a_photo_that_skips_the_selection_check() { + let mut s = Session::with_demo(); + let a = album(&mut s, "Trip"); + s.selection = Selection::default(); + // no `ids`: the selection is what is added, and it is empty + assert!(matches!(s.execute("album.addPhotos", &json!({"id": a.0})), Err(crate::EngineError::Disabled(..)))); + assert!(matches!(s.execute("album.addPhotos", &json!({"id": a.0, "ids": null})), Err(crate::EngineError::Disabled(..)))); + assert!(matches!(s.execute("album.toggleTarget", &json!({})), Err(crate::EngineError::Disabled(..)))); +}