Browse / github.com/storytold/lightcraft / patch
LR-BEHAV-MULTISELECT: batch keyword delete and move look keywords up by level instead of scanning the selection (stacked on #2757)
LR-BEHAV-MULTISELECT: batch keyword delete and move look keywords up by level instead of scanning the selection (stacked on #2757) Stacked on #2757 (head `81aa2af214`): waits for it, then makes its batch keyword delete and move linear in the library's keywords. Own commits: `f36f44a43c` and `8b8d8bb72a` (batch keyword move looks every path up once per call). Restacked on 27 Oct onto #2757's head, with the tests moved to `crates/catalog/src/keywords/tests.rs`. ## Problem #2757's `keyword.delete` and `keyword.move` with several keywords (up to 10,000 per call) compare every keyword in the library with every selected keyword: `delete_keywords_ops` in its existence check and again in its filter, `move_keywords_ops` for ever
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- nledford (imported from pull request #3115, open)
- Base
662df66f1c08bdb1b1f1d0c1c638c4cef936d0b0- Size
- +906 −62 in 7 files
- Review
- pending
- Shared
- 2026-10-28
- Id
09950ab820c8e0ec0cad9551
How it went
No other operator has reported using it yet.
Diff
raw · crates/catalog/src/keywords.rs crates/catalog/src/keywords/tests.rs crates/engine/src/cmd/keywords.rs crates/engine/src/cmd/library/specs.rs crates/engine/src/tests.rs crates/engine/src/tests/batch_sidebar.rs docs/parity-checklist.md
@@ -10,7 +10,7 @@ //! photo has them, or given attributes ([`KeywordInfo`]: synonyms, export options, person). The //! tree is the keyword list together with the keywords photos carry. -use std::collections::BTreeMap;+use std::collections::{BTreeMap, HashMap, HashSet}; use serde::{Deserialize, Serialize}; @@ -35,6 +35,18 @@ pub fn is_under(k: &str, parent: &str) -> bool { k == p || k.strip_prefix(&p).is_some_and(|rest| rest.starts_with(SEP)) } +/// The upper levels of a case folded keyword path, each as a prefix of it ending before a `|`+/// (`a|b|c` gives `a`, `a|b`), the way [`is_under`] sees them.+fn upper_levels(folded: &str) -> impl Iterator<Item = &str> {+ folded.match_indices(SEP).map(|(i, _)| &folded[..i])+}++/// A case folded path and its [`upper_levels`] that are as many bytes as one of `lens`: the only+/// ones that can be in a set of paths of those lengths, so only those are worth hashing.+fn named_levels<'a>(folded: &'a str, lens: &'a HashSet<usize>) -> impl Iterator<Item = &'a str> {+ std::iter::once(folded).chain(upper_levels(folded)).filter(|level| lens.contains(&level.len()))+}+ /// Replace the `from` prefix of `k` (which [`is_under`] `from`) with `to`. By levels, not bytes: /// `from` may be written in another case, which can change a letter's length (ẞ / ß). pub fn reparent(k: &str, from: &str, to: &str) -> String {@@ -383,6 +395,63 @@ impl Catalog { }) } + /// What [`Self::list_move_ops`] of each `(from, to)` of `moves` in turn gives, one after the+ /// other, without a pass over the keyword list and a copy of it per move: the moves are keywords+ /// none of which is under another, so each listing leaves for at most one of them, found by+ /// its levels. Each move's ops are as `list_ops` would make them: what leaves and doesn't land+ /// again is taken off, then what lands (or was merged into) is set, both in key order.+ fn list_moves_ops(&self, moves: &[(String, String)]) -> Vec<Op> {+ let list = &self.keyword_list;+ let moving: HashMap<String, usize> = moves.iter().enumerate().map(|(i, (f, _))| (f.to_lowercase(), i)).collect();+ let lens: HashSet<usize> = moving.keys().map(String::len).collect();+ let mut leaving: Vec<Vec<&ListedKeyword>> = vec![Vec::new(); moves.len()];+ for l in list.values() {+ let folded = l.path.to_lowercase();+ if let Some(i) = named_levels(&folded, &lens).find_map(|level| moving.get(level)) {+ leaving[*i].push(l);+ }+ }+ let mut ops = Vec::new();+ for ((f, t), left) in moves.iter().zip(&leaving) {+ let leaves: HashSet<String> = left.iter().map(|l| l.path.to_lowercase()).collect();+ // the landings, and what they merged into, by key (what `list_move_ops` has in its copy+ // of the list that isn't what the list has)+ let mut after: BTreeMap<String, ListedKeyword> = BTreeMap::new();+ for l in left {+ let path = reparent(&l.path, f, t);+ let key = path.to_lowercase();+ if !after.contains_key(&key)+ && let Some(there) = list.get(&key).filter(|_| !leaves.contains(&key))+ {+ after.insert(key.clone(), there.clone());+ }+ match after.get_mut(&key) {+ Some(there) => {+ there.info.new_keywords_inside |= l.info.new_keywords_inside;+ for s in &l.info.synonyms {+ if !there.info.synonyms.iter().any(|x| same(x, s)) {+ there.info.synonyms.push(s.clone());+ }+ }+ }+ None => {+ after.insert(key, ListedKeyword { path, info: l.info.clone() });+ }+ }+ }+ ops.extend(+ left.iter().filter(|l| !after.contains_key(&l.path.to_lowercase())).map(|l| Op::SetKeyword { path: l.path.clone(), info: None }),+ );+ ops.extend(+ after+ .iter()+ .filter(|(k, l)| list.get(*k) != Some(*l))+ .map(|(_, l)| Op::SetKeyword { path: l.path.clone(), info: Some(l.info.clone()) }),+ );+ }+ ops+ }+ /// Rename `from` (and the keywords below it) to `to` on every photo and in the keyword list — /// one batch. Renaming onto an existing keyword merges the two. pub fn rename_keyword_ops(&self, from: &str, to: &str) -> Result<Op> {@@ -409,6 +478,118 @@ impl Catalog { Ok(Op::Batch { ops }) } + /// [`Self::delete_keyword_ops`] for several keywords at once: one batch, so one undo step.+ /// Unlike one keyword (which may be absent and then changes nothing), every keyword named must+ /// exist, or the whole batch is refused.+ pub fn delete_keywords_ops(&self, keywords: &[String]) -> Result<Op> {+ let gone: Vec<String> = keywords.iter().map(|k| clean(k)).collect();+ if gone.is_empty() || gone.iter().any(String::is_empty) {+ return Err(CatalogError::Invalid("empty keyword".into()));+ }+ // Case folded once here, and each candidate once below. A candidate is under a named keyword+ // when its folded path or one of its upper levels is in `named`: a lookup per level, not a+ // comparison with every keyword named. Only the levels as long as some named keyword are+ // hashed (see `named_levels`): at most one per named length, so a very deep keyword on a+ // photo costs its length plus at most the bytes of the keywords named, not every prefix.+ let mut named: HashMap<String, usize> = HashMap::with_capacity(gone.len());+ for (i, g) in gone.iter().enumerate() {+ named.entry(g.to_lowercase()).or_insert(i);+ }+ let lens: HashSet<usize> = named.keys().map(String::len).collect();+ let mut found = vec![false; gone.len()];+ let listed = self.keyword_list.values().map(|l| &l.path);+ for k in listed.chain(self.photos().filter(|p| p.in_library()).flat_map(|p| p.meta.keywords.iter())) {+ let k = k.to_lowercase();+ for hit in named_levels(&k, &lens).filter_map(|level| named.get(level)) {+ found[*hit] = true;+ }+ }+ // a keyword named twice (or in two cases) is one entry of `named`: it is found as that one+ for (i, g) in gone.iter().enumerate() {+ found[i] = found[named[&g.to_lowercase()]];+ }+ if let Some(i) = found.iter().position(|hit| !hit) {+ return Err(CatalogError::Invalid(format!("no keyword “{}”", gone[i])));+ }+ let goes = |k: &str| {+ let k = k.to_lowercase();+ named_levels(&k, &lens).any(|level| named.contains_key(level))+ };+ let mut ops = self.keyword_ops(|kws| kws.iter().filter(|x| !goes(x)).cloned().collect());+ ops.extend(self.list_ops(|list| list.retain(|_, l| !goes(&l.path))));+ Ok(Op::Batch { ops })+ }++ /// Move several keywords (each with the keywords below it) inside `parent`, or to the top+ /// level (`None`), as one batch. Each keeps its name. Of a keyword and one below it only the+ /// upper one moves (the lower goes with it). Nothing merges here: a name that is taken where+ /// they go ([`CatalogError::KeywordExists`]), or two of them with one name, refuses the whole+ /// move. Returns the batch and each move as `(from, to)`.+ pub fn move_keywords_ops(&self, keywords: &[String], parent: Option<&str>) -> Result<(Op, Vec<(String, String)>)> {+ // Every lookup is built once for the whole call: the keywords' own paths (and the parent's)+ // by `keyword_paths`, the names they move to by a second one; paths are case folded once and+ // kept in sets or maps, and each photo's keywords and each listing are matched to the moves+ // by level, not against every move. So the cost is the library's keywords, not their+ // product with the selection.+ let parent = parent.map(clean).filter(|p| !p.is_empty());+ let mut queries: Vec<&str> = keywords.iter().map(String::as_str).collect();+ queries.extend(parent.as_deref());+ let paths = self.keyword_paths(&queries);+ let path_of = |k: &str| paths.get(&clean(k).to_lowercase());+ let mut from: Vec<String> = Vec::new();+ let mut seen: HashSet<String> = HashSet::new();+ for k in keywords {+ let path = path_of(k).ok_or_else(|| CatalogError::Invalid(format!("no keyword “{}”", clean(k))))?;+ if seen.insert(path.to_lowercase()) {+ from.push(path.clone());+ }+ }+ if from.is_empty() {+ return Err(CatalogError::Invalid("no keyword to move".into()));+ }+ // the upper ones only: a keyword goes when one of its upper levels is named too+ from.retain(|f| !upper_levels(&f.to_lowercase()).any(|up| seen.contains(up)));+ let upper: HashSet<String> = from.iter().map(|f| f.to_lowercase()).collect();+ let parent = parent.map(|p| path_of(&p).cloned().unwrap_or(p));+ if let Some(p) = &parent {+ let p = p.to_lowercase();+ if upper.contains(&p) || upper_levels(&p).any(|up| upper.contains(up)) {+ return Err(CatalogError::Invalid("can't move a keyword inside itself".into()));+ }+ }+ let mut moves: Vec<(String, String)> = Vec::new();+ let mut names: HashSet<String> = HashSet::new();+ for f in &from {+ let leaf = f.rsplit(SEP).next().unwrap_or(f);+ let to = parent.as_deref().map_or_else(|| leaf.to_string(), |p| format!("{p}{SEP}{leaf}"));+ if !names.insert(to.to_lowercase()) {+ return Err(CatalogError::Invalid(format!("two of them are named “{leaf}”: move them one at a time")));+ }+ moves.push((f.clone(), to));+ }+ // one that is there already stays; the others must find their name free+ moves.retain(|(f, t)| !same(f, t));+ let targets: Vec<&str> = moves.iter().map(|(_, t)| t.as_str()).collect();+ let existing = self.keyword_paths(&targets);+ if let Some((_, taken)) = moves.iter().find(|(_, t)| existing.contains_key(&clean(t).to_lowercase())) {+ return Err(CatalogError::KeywordExists(taken.clone()));+ }+ // Case folded once per photo keyword; its path and its upper levels are looked up in `moving`+ // (folded from-path to its move), not compared with every move; only those as long as some+ // move's path are hashed (see `named_levels`). The moves are the upper keywords only, so at+ // most one level of a keyword is among them.+ let moving: HashMap<String, &(String, String)> = moves.iter().map(|m| (m.0.to_lowercase(), m)).collect();+ let lens: HashSet<usize> = moving.keys().map(String::len).collect();+ let moved = |k: &str| {+ let k_folded = k.to_lowercase();+ named_levels(&k_folded, &lens).find_map(|level| moving.get(level)).map(|(f, t)| reparent(k, f, t))+ };+ let mut ops = self.keyword_ops(|kws| kws.iter().map(|k| moved(k).unwrap_or_else(|| k.clone())).collect());+ // (the moves touch apart parts of the list: each one's listings are its own)+ ops.extend(self.list_moves_ops(&moves));+ Ok((Op::Batch { ops }, moves))+ }+ /// Merge several keywords (with their children) into `into`. pub fn merge_keywords_ops(&self, from: &[String], into: &str) -> Result<Op> { let into = clean(into);@@ -685,6 +866,45 @@ impl Catalog { .map(|x| x.split(SEP).map(str::trim).filter(|s| !s.is_empty()).take(levels).collect::<Vec<_>>().join("|")) } + /// [`Self::keyword_path`] of every one of `queries` in one pass over the keyword list and the+ /// photos, not one pass each: case folded cleaned query → its path. A query with no keyword (or+ /// an empty one) has no entry. Only the levels as long as some query are hashed+ /// (see `named_levels`).+ fn keyword_paths(&self, queries: &[&str]) -> HashMap<String, String> {+ let mut want: HashMap<String, Option<String>> = HashMap::with_capacity(queries.len());+ let mut missing = 0usize;+ for q in queries {+ let k = clean(q);+ if k.is_empty() {+ continue;+ }+ let folded = k.to_lowercase();+ if want.contains_key(&folded) {+ continue;+ }+ let listed = self.keyword_list.get(&folded).map(|l| l.path.clone());+ missing += usize::from(listed.is_none());+ want.insert(folded, listed);+ }+ let lens: HashSet<usize> = want.keys().map(String::len).collect();+ // the levels of a listed keyword below it, or of a photo's: the first one found names the path+ let listed = self.keyword_list.values().map(|l| &l.path);+ for x in listed.chain(self.photos().filter(|p| p.in_library()).flat_map(|p| p.meta.keywords.iter())) {+ if missing == 0 {+ break;+ }+ let folded = x.to_lowercase();+ for level in named_levels(&folded, &lens) {+ if let Some(slot @ None) = want.get_mut(level) {+ let levels = level.split(SEP).count();+ *slot = Some(x.split(SEP).map(str::trim).filter(|s| !s.is_empty()).take(levels).collect::<Vec<_>>().join("|"));+ missing -= 1;+ }+ }+ }+ want.into_iter().filter_map(|(k, v)| v.map(|v| (k, v))).collect()+ }+ /// Create a keyword with these attributes, and give it to `photos` — one batch. A keyword that /// exists already is refused. pub fn create_keyword_ops(&self, path: &str, info: KeywordInfo, photos: &[crate::PhotoId]) -> Result<Op> {@@ -704,3 +704,113 @@ fn keyword_tree_caps_a_keyword_at_the_list_depth() { let deepest = tree_paths(&c.keyword_tree()).iter().map(|(p, _)| p.split(SEP).count()).max().unwrap_or(0); assert_eq!(deepest, MAX_LEVELS); }++/// A batch delete takes each named keyword with what is below it, at any depth and in any case,+/// and leaves a keyword that only shares a name prefix ("Cats" next to "Cat").+#[test]+fn batch_delete_takes_nested_keywords_in_any_case_and_not_a_shared_name_prefix() {+ let (mut c, ids) = lib(&[&["Cat", "Cats", "Animals|CAT|Kitten|Tiny", "Animals|Dog", "Cat food"]]);+ let op = c.delete_keywords_ops(&["cat".to_string(), "animals|dog".to_string()]).unwrap();+ c.apply(op).unwrap();+ assert_eq!(kws(&c, ids[0]), ["Cats", "Animals|CAT|Kitten|Tiny", "Cat food"]);+ // Named twice, in two cases, and with one below the other: still one batch that works.+ let op = c.delete_keywords_ops(&["Animals".to_string(), "ANIMALS".to_string(), "animals|cat".to_string()]).unwrap();+ c.apply(op).unwrap();+ assert_eq!(kws(&c, ids[0]), ["Cats", "Cat food"]);+}++/// Every keyword named must exist: "Cat" isn't there because "Cats" is, and a keyword below a+/// named one doesn't make a second name exist.+#[test]+fn batch_delete_refuses_a_name_that_only_prefixes_another() {+ let (c, _) = lib(&[&["Cats", "Animals|Dog"]]);+ let err = c.delete_keywords_ops(&["Cats".to_string(), "Cat".to_string()]).unwrap_err();+ assert!(err.to_string().contains("no keyword “Cat”"), "{err}");+ let err = c.delete_keywords_ops(&["Animals".to_string(), "Animals|Do".to_string()]).unwrap_err();+ assert!(err.to_string().contains("no keyword “Animals|Do”"), "{err}");+}++/// A batch move reparents each moved keyword with what is below it, at any depth and in any+/// case, and leaves a keyword that only shares a name prefix with a moved one.+#[test]+fn batch_move_reparents_nested_keywords_in_any_case_and_not_a_shared_name_prefix() {+ let (mut c, ids) = lib(&[&["Cat", "Cats", "Pets|DOG|Puppy|Tiny", "Pets|Doge", "Home"], &["pets|dog|puppy", "PETS|DOG"]]);+ let (op, moves) = c.move_keywords_ops(&["cat".to_string(), "pets|dog".to_string()], Some("Home")).unwrap();+ c.apply(op).unwrap();+ assert_eq!(moves, [("Cat".to_string(), "Home|Cat".to_string()), ("Pets|DOG".to_string(), "Home|DOG".to_string())]);+ assert_eq!(kws(&c, ids[0]), ["Home|Cat", "Cats", "Home|DOG|Puppy|Tiny", "Pets|Doge", "Home"]);+ // a photo's own spelling of a moved keyword, in another case, moves too+ assert_eq!(kws(&c, ids[1]), ["Home|DOG|puppy", "Home|DOG"]);+}++fn list_it(c: &mut Catalog, path: &str, words: &str) {+ let words: Vec<&str> = words.split_whitespace().collect();+ c.apply(Op::SetKeyword { path: path.into(), info: Some(with_synonyms(&words)) }).unwrap();+}++/// A batch move refuses, with the same message as before, a keyword that isn't there, a name that+/// is taken where it goes (by a photo's keyword, or by one only below a listed one), a move+/// inside itself and two keywords with one name.+#[test]+fn batch_move_refusals() {+ let (mut c, _) = lib(&[&["Cat", "Pets|Dog", "Home|Cat", "A|Fish", "B|fish", "Top", "Pets"]]);+ list_it(&mut c, "Home|Zoo|Lion", "");+ let s = |v: &[&str]| v.iter().map(|k| k.to_string()).collect::<Vec<_>>();+ let err = c.move_keywords_ops(&s(&["cat", "Nope"]), Some("Home")).unwrap_err();+ assert!(err.to_string().contains("no keyword “Nope”"), "{err}");+ assert!(matches!(c.move_keywords_ops(&s(&["cat"]), Some("home")), Err(CatalogError::KeywordExists(t)) if t == "Home|Cat"));+ assert!(c.move_keywords_ops(&s(&["Pets"]), Some("Home|Zoo")).is_ok());+ list_it(&mut c, "Home|Pets|Bird", "");+ assert!(matches!(c.move_keywords_ops(&s(&["Pets"]), Some("Home")), Err(CatalogError::KeywordExists(t)) if t == "Home|Pets"));+ let err = c.move_keywords_ops(&s(&["Pets"]), Some("pets|dog")).unwrap_err();+ assert!(err.to_string().contains("can't move a keyword inside itself"), "{err}");+ let err = c.move_keywords_ops(&s(&["a|fish", "b|FISH"]), Some("Top")).unwrap_err();+ assert!(err.to_string().contains("two of them are named “fish”"), "{err}");+ let err = c.move_keywords_ops(&[], None).unwrap_err();+ assert!(err.to_string().contains("no keyword to move"), "{err}");+}++/// A batch move takes the listings (with their attributes) under each moved keyword along, and+/// leaves the others.+#[test]+fn batch_move_takes_listings_along() {+ let (mut c, _) = lib(&[&["Home"]]);+ for (p, w) in [("Pets|Dog", "hound"), ("Pets|Dog|Puppy", "pup"), ("Pets|Doge", "meme"), ("Cat", "kitty"), ("Home", "")] {+ list_it(&mut c, p, w);+ }+ let (op, _) = c.move_keywords_ops(&["pets|dog".to_string(), "CAT".to_string()], Some("Home")).unwrap();+ c.apply(op).unwrap();+ let now: Vec<(&str, Vec<&str>)> =+ c.keyword_list.values().map(|l| (l.path.as_str(), l.info.synonyms.iter().map(String::as_str).collect())).collect();+ assert_eq!(+ now,+ [("Home", vec![]), ("Home|Cat", vec!["kitty"]), ("Home|Dog", vec!["hound"]), ("Home|Dog|Puppy", vec!["pup"]), ("Pets|Doge", vec!["meme"])]+ );+}++/// The one pass over the keyword list that moves every listing at once gives the ops of one+/// `list_move_ops` per move, in the same order, also where a landing merges into a listing that+/// is there already.+#[test]+fn batch_list_moves_equal_one_list_move_per_move() {+ let (mut c, _) = lib(&[&[]]);+ for (p, w) in [+ ("Pets|Dog", "hound"),+ ("Pets|Dog|Puppy", "pup"),+ ("Pets|Doge", "meme"),+ ("Cat", "kitty"),+ ("Home|Dog", "canine"),+ ("Home|Dog|Puppy", "pup"),+ ("Home", ""),+ ] {+ list_it(&mut c, p, w);+ }+ for moves in [+ vec![("Pets|Dog".to_string(), "Home|Dog".to_string()), ("Cat".to_string(), "Home|Cat".to_string())],+ vec![("Cat".to_string(), "Top".to_string())],+ vec![],+ ] {+ let one_by_one: Vec<Op> = moves.iter().flat_map(|(f, t)| c.list_move_ops(std::slice::from_ref(f), t)).collect();+ assert_eq!(c.list_moves_ops(&moves), one_by_one, "{moves:?}");+ }+}@@ -57,6 +57,40 @@ fn info_from(p: &Value, base: KeywordInfo) -> KeywordInfo { } } +/// `keyword.move`'s `parent`: a keyword, or null for the top level.+fn move_parent(p: &Value) -> Result<Option<String>> {+ match p.get("parent") {+ Some(Value::String(k)) => Ok(Some(k.clone())),+ Some(Value::Null) => Ok(None),+ _ => Err(bad("keyword.move", "`parent` is a keyword, or null for the top level")),+ }+}++/// How many keywords one call may name (far more than a person chooses in a list).+const MAX_KEYWORDS: usize = 10_000;++/// The keywords a command names in `keywords`: a list of them, each once (whatever its case),+/// none empty.+fn keyword_list(p: &Value, command: &str) -> Result<Vec<String>> {+ let list = p.get("keywords").and_then(Value::as_array).ok_or_else(|| bad(command, "`keywords` must be a list of keywords"))?;+ if list.len() > MAX_KEYWORDS {+ return Err(bad(command, format!("`keywords` names more than {MAX_KEYWORDS} keywords")));+ }+ let mut out: Vec<String> = Vec::new();+ let mut seen = std::collections::HashSet::new();+ for k in list {+ let k =+ k.as_str().map(clean).filter(|k| !k.is_empty()).ok_or_else(|| bad(command, format!("`keywords` must be a list of keywords, not {k}")))?;+ if seen.insert(k.to_lowercase()) {+ out.push(k);+ }+ }+ if out.is_empty() {+ return Err(bad(command, "`keywords` names no keyword"));+ }+ Ok(out)+}+ /// The keyword a command names (`keyword`), as the library writes it. fn named_keyword(s: &Session, p: &Value, command: &str) -> Result<String> { let k = str_param(p, "keyword").ok_or_else(|| bad(command, "missing `keyword`"))?;@@ -408,15 +442,23 @@ pub fn specs() -> Vec<CommandSpec> { "Move Keyword", [], None,- "{keyword, parent: keyword | null (the top level), merge?: bool} — with the keywords below it; onto a name that is taken only with `merge: true`",+ "{keyword, parent: keyword | null (the top level), merge?: bool} — with the keywords below it; onto a name that is taken only with `merge: true`. Several at once: {keywords: [keyword], parent} as one undo step (of a keyword and one below it the upper one moves); nothing merges then: a name that is taken, or two of them with one name, refuses the whole move", always, |s, p| {+ if p.get("keywords").is_some_and(|k| !k.is_null()) {+ let parent = move_parent(p)?;+ let keywords = keyword_list(p, "keyword.move")?;+ let (op, moves) = s+ .catalog+ .move_keywords_ops(&keywords, parent.as_deref())+ .map_err(|e| refused("keyword.move", e, ": moving would merge the two (move that one by itself, with merge: true)"))?;+ let label = if moves.len() == 1 { "Move Keyword".to_string() } else { format!("Move {} Keywords", moves.len()) };+ return commit_keywords(s, &label, op, |k| {+ Some(moves.iter().find(|(f, _)| is_under(k, f)).map_or_else(|| k.to_string(), |(f, t)| reparent(k, f, t)))+ });+ } let from = named_keyword(s, p, "keyword.move")?;- let parent = match p.get("parent") {- Some(Value::String(k)) => Some(k.clone()),- Some(Value::Null) => None,- _ => return Err(bad("keyword.move", "`parent` is a keyword, or null for the top level")),- };+ let parent = move_parent(p)?; // where it lands, as the catalog spells it: a parent that doesn't exist yet is made let leaf = from.rsplit('|').next().unwrap_or(&from).to_string(); let to = match parent.as_deref().map(|k| s.catalog.keyword_path(k).unwrap_or_else(|| clean(k))).filter(|k| !k.is_empty()) {@@ -532,9 +574,16 @@ pub fn specs() -> Vec<CommandSpec> { "Delete Keyword", [], None,- "{keyword} — removes it (and the keywords below it) from every photo",+ "{keyword} — removes it (and the keywords below it) from every photo. Several at once: {keywords: [keyword]}, as one undo step", always, |s, p| {+ if p.get("keywords").is_some_and(|k| !k.is_null()) {+ let keywords = keyword_list(p, "keyword.delete")?;+ let op = s.catalog.delete_keywords_ops(&keywords).map_err(|e| bad("keyword.delete", e.to_string()))?;+ let gone: Vec<String> = keywords.iter().map(|k| clean(k)).collect();+ let label = if gone.len() == 1 { "Delete Keyword".to_string() } else { format!("Delete {} Keywords", gone.len()) };+ return commit_keywords(s, &label, op, |f| (!gone.iter().any(|g| is_under(f, g))).then(|| f.to_string()));+ } let k = str_param(p, "keyword").ok_or_else(|| bad("keyword.delete", "missing `keyword`"))?.to_string(); let op = s.catalog.delete_keyword_ops(&k).map_err(|e| bad("keyword.delete", e.to_string()))?; let c = clean(&k);@@ -26,6 +26,91 @@ fn next_seed(prev: u64, now: &str) -> u64 { (lightcraft_catalog::mix64(prev.wrapping_add(1)) ^ t) & (MAX_SEED - 1) } +/// `album.move` for several albums and folders (`ids`) at once: everything is checked first, then+/// they move as one undo step.+fn move_albums(s: &mut Session, p: &Value) -> Result<Value> {+ const C: &str = "album.move";+ let ids = album_ids(p.get("ids").unwrap_or(&Value::Null), C)?;+ if ids.is_empty() {+ return Err(bad(C, "`ids` names no album"));+ }+ if let Some(gone) = ids.iter().find(|a| s.catalog.album(**a).is_none()) {+ return Err(bad(C, format!("no such album {}", gone.0)));+ }+ let parent = opt_album_param(p, "parent", C)?;+ if let Some(folder) = parent+ && !s.catalog.album(folder).is_some_and(|a| a.folder)+ {+ return Err(bad(C, format!("album {} is not a folder of albums", folder.0)));+ }+ // where `parent` sits: itself and the folders above it (a ring, in a damaged library, ends the walk)+ let mut above: Vec<AlbumId> = Vec::new();+ let mut cur = parent;+ while let Some(c) = cur.filter(|c| !above.contains(c)) {+ above.push(c);+ cur = s.catalog.album(c).and_then(|a| a.parent);+ }+ // of a folder and what is inside it, the folder moves+ let inside_another = |id: AlbumId| {+ let mut seen: Vec<AlbumId> = Vec::new();+ let mut cur = s.catalog.album(id).and_then(|a| a.parent);+ while let Some(c) = cur.filter(|c| !seen.contains(c)) {+ if ids.contains(&c) {+ return true;+ }+ seen.push(c);+ cur = s.catalog.album(c).and_then(|a| a.parent);+ }+ false+ };+ let moving: Vec<AlbumId> = ids.iter().copied().filter(|id| !inside_another(*id)).collect();+ for id in &moving {+ let name = s.catalog.album(*id).map(|a| a.name.clone()).unwrap_or_default();+ if above.contains(id) {+ return Err(bad(C, format!("“{name}” can't go inside itself")));+ }+ }+ let ops: Vec<Op> = moving.iter().map(|id| Op::MoveAlbum { id: *id, parent }).collect();+ let label = if moving.len() == 1 { "Move Album".to_string() } else { format!("Move {} Albums", moving.len()) };+ s.commit(&label, Op::Batch { ops })?;+ Ok(json!({"moved": moving.len()}))+}++/// How many albums or folders one call may name: far more than a person+/// chooses in a sidebar, and a bound on what one call can make the engine check.+pub(crate) const MAX_SOURCES: usize = 1000;++/// A command's `ids`: album ids, each once (named twice, the last naming counts).+fn album_ids(v: &Value, c: &str) -> Result<Vec<AlbumId>> {+ let list = v.as_array().ok_or_else(|| bad(c, "`ids` must be a list of album ids"))?;+ if list.len() > MAX_SOURCES {+ return Err(bad(c, format!("`ids` names more than {MAX_SOURCES} albums")));+ }+ let mut out: Vec<AlbumId> = Vec::new();+ for x in list {+ let id = AlbumId(x.as_u64().ok_or_else(|| bad(c, format!("`ids` must be a list of album ids, not {x}")))?);+ out.retain(|o| *o != id);+ out.push(id);+ }+ Ok(out)+}++/// A command's `paths`: folder paths, each folder once (named twice, the last counts).+fn folder_paths(v: &Value, c: &str) -> Result<Vec<String>> {+ let list = v.as_array().ok_or_else(|| bad(c, "`paths` must be a list of folder paths"))?;+ if list.len() > MAX_SOURCES {+ return Err(bad(c, format!("`paths` names more than {MAX_SOURCES} folders")));+ }+ let mut out: Vec<String> = Vec::new();+ for x in list {+ let path = x.as_str().ok_or_else(|| bad(c, format!("`paths` must be a list of folder paths, not {x}")))?;+ let key = lightcraft_catalog::query::folder_key(path);+ out.retain(|o| lightcraft_catalog::query::folder_key(o) != key);+ out.push(path.to_string());+ }+ Ok(out)+}+ pub fn specs() -> Vec<CommandSpec> { vec![ // ---- view source / filter / sort@@ -125,35 +210,52 @@ pub fn specs() -> Vec<CommandSpec> { "Remove Folder from Library", [], None,- "{path, disk?: bool} — move the library photos imported from this folder (and the folders inside it) to Recently Deleted; one undo step, no file is touched. A whole disk or share (`/Volumes/nas`, `C:\\`, `\\\\srv\\share`) goes only with `disk: true`, the startup disk never → {removed}",+ "{path, disk?: bool} — move the library photos imported from this folder (and the folders inside it) to Recently Deleted; one undo step, no file is touched. A whole disk or share (`/Volumes/nas`, `C:\\`, `\\\\srv\\share`) goes only with `disk: true`, the startup disk never → {removed}. Several at once: {paths: [path]}, still one undo step; one that can't be removed refuses the whole call", always, |s, p| { const C: &str = "library.removeFolder";- let path = str_param(p, "path").filter(|d| !d.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;- if lightcraft_catalog::query::folder_key(path).is_empty() {- return Err(bad(C, format!("{path}: not a folder")));- }- if lightcraft_catalog::folders::is_startup_disk(path) {- return Err(bad(C, "choose a folder, not the whole startup disk"));- }- if lightcraft_catalog::folders::is_disk_root(path) && !bool_or(p, "disk", false) {- return Err(bad(C, format!("{path} is a whole disk or share: pass `disk: true` to remove everything on it")));- }- let f = Filter { library_folder: Some(path.to_string()), ..Default::default() };- let ids = s.catalog.query(&f, &Sort::default());- if ids.is_empty() {- return Err(bad(C, format!("{path}: no photo in the library was imported from it")));+ let several = p.get("paths").is_some_and(|v| !v.is_null());+ let paths = match p.get("paths") {+ Some(v) if several => folder_paths(v, C)?,+ _ => vec![str_param(p, "path").filter(|d| !d.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?.to_string()],+ };+ if paths.is_empty() {+ return Err(bad(C, "`paths` names no folder")); }- if covers_other_disks(s, path, &ids) && !bool_or(p, "disk", false) {- return Err(bad(C, format!("{path} holds whole disks: pass `disk: true` to remove everything on them")));+ // every folder is checked before a photo leaves; a photo in two of them leaves once+ let mut ids: Vec<PhotoId> = Vec::new();+ let mut seen = std::collections::HashSet::new();+ for path in &paths {+ let path = path.as_str();+ if path.trim().is_empty() || lightcraft_catalog::query::folder_key(path).is_empty() {+ return Err(bad(C, format!("{path}: not a folder")));+ }+ if lightcraft_catalog::folders::is_startup_disk(path) {+ return Err(bad(C, "choose a folder, not the whole startup disk"));+ }+ if lightcraft_catalog::folders::is_disk_root(path) && !bool_or(p, "disk", false) {+ return Err(bad(C, format!("{path} is a whole disk or share: pass `disk: true` to remove everything on it")));+ }+ let f = Filter { library_folder: Some(path.to_string()), ..Default::default() };+ let found = s.catalog.query(&f, &Sort::default());+ if found.is_empty() {+ return Err(bad(C, format!("{path}: no photo in the library was imported from it")));+ }+ if covers_other_disks(s, path, &found) && !bool_or(p, "disk", false) {+ return Err(bad(C, format!("{path} holds whole disks: pass `disk: true` to remove everything on them")));+ }+ ids.extend(found.into_iter().filter(|id| seen.insert(*id))); }- mark_deleted(s, &ids, "Remove Folder from Library")?;- if s.filter.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) {+ let label =+ if paths.len() == 1 { "Remove Folder from Library".to_string() } else { format!("Remove {} Folders from Library", paths.len()) };+ mark_deleted(s, &ids, &label)?;+ let within = |c: &str| paths.iter().any(|path| lightcraft_catalog::query::folder_within(c, path));+ if s.filter.library_folder.as_deref().is_some_and(within) { s.filter.library_folder = None; } // a filter put aside by Enable Filters must not bring the removed folder back if let Some(f) = s.shelved_filter.as_mut()- && f.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path))+ && f.library_folder.as_deref().is_some_and(within) { f.library_folder = None; }@@ -161,7 +263,7 @@ pub fn specs() -> Vec<CommandSpec> { s.shelved_filter = None; } // the folder being shown is gone: back to everything, as for a deleted album- if s.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) {+ if s.library_folder.as_deref().is_some_and(within) { s.library_folder = None; if s.source == LibrarySource::LibraryFolder { s.source = LibrarySource::All;@@ -177,26 +279,46 @@ pub fn specs() -> Vec<CommandSpec> { "Set Folder Color Label", [], None,- "{path, label: red|yellow|green|blue|purple|none} — give a folder of the library (one its photos were imported from, see library.folders) a colour label, or take it off (also once its photos are gone); one undo step. The label follows the folder when it is renamed or moved with folder.rename / folder.move → {path, label}",+ "{path, label: red|yellow|green|blue|purple|none} — give a folder of the library (one its photos were imported from, see library.folders) a colour label, or take it off (also once its photos are gone); one undo step. The label follows the folder when it is renamed or moved with folder.rename / folder.move → {path, label}. Several at once: {paths: [path], label}, as one undo step", always, |s, p| { const C: &str = "folder.label";- let path = str_param(p, "path").filter(|d| !d.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?;+ let several = p.get("paths").is_some_and(|v| !v.is_null());+ let paths = match p.get("paths") {+ Some(v) if several => folder_paths(v, C)?,+ _ => vec![str_param(p, "path").filter(|d| !d.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?.to_string()],+ };+ if paths.is_empty() {+ return Err(bad(C, "`paths` names no folder"));+ } let label = match str_param(p, "label").ok_or_else(|| bad(C, "missing `label` (red|yellow|green|blue|purple|none)"))? { "none" => None, x => Some(ColorLabel::parse(x).ok_or_else(|| bad(C, format!("unknown label {x:?}")))?), };- // taking a label off always works, also once the folder's photos are gone- let f = Filter { library_folder: Some(path.to_string()), ..Default::default() };- if label.is_some() && s.catalog.query(&f, &Sort::default()).is_empty() {- return Err(bad(C, format!("{path}: no photo in the library was imported from it")));+ let mut ops = Vec::new();+ for path in &paths {+ if path.trim().is_empty() {+ return Err(bad(C, "missing `path`"));+ }+ // taking a label off always works, also once the folder's photos are gone+ let f = Filter { library_folder: Some(path.clone()), ..Default::default() };+ if label.is_some() && s.catalog.query(&f, &Sort::default()).is_empty() {+ return Err(bad(C, format!("{path}: no photo in the library was imported from it")));+ }+ if label.is_some() || s.catalog.folder_record(path).is_some() {+ ops.push(s.catalog.folder_label_op(path, label));+ } }- if label.is_none() && s.catalog.folder_record(path).is_none() {- return Ok(json!({"path": path, "label": label}));+ let name = if label.is_some() { "Set Folder Color Label" } else { "Remove Folder Color Label" };+ match (ops.len(), ops.pop()) {+ (0, _) | (_, None) => {}+ (1, Some(op)) => s.commit(name, op)?,+ (_, Some(last)) => {+ ops.push(last);+ s.commit(name, Op::Batch { ops })?+ } }- let op = s.catalog.folder_label_op(path, label);- s.commit(if label.is_some() { "Set Folder Color Label" } else { "Remove Folder Color Label" }, op)?;- Ok(json!({"path": path, "label": label}))+ if several { Ok(json!({"paths": paths, "label": label})) } else { Ok(json!({"path": paths.first(), "label": label})) } } ), cmd!("library.clearFilter", "Clear Filters", ["View"], None, "{}", always, |s, _| {@@ -905,11 +1027,22 @@ pub fn specs() -> Vec<CommandSpec> { "Delete Album", [], None,- "{id} — deletes the album or folder and its descendants, keeping photos in the library; one undo step",+ "{id} — deletes the album or folder and its descendants, keeping photos in the library; one undo step. Several at once: {ids: [albumId]} → {deleted}, still one undo step; one that does not exist refuses the whole call", always, |s, p| {- let id = album_param(p, "id", "album.delete")?;- let folder = s.catalog.album(id).ok_or_else(|| bad("album.delete", "no such album"))?.folder;+ const C: &str = "album.delete";+ let several = p.get("ids").is_some_and(|v| !v.is_null());+ let ids = match p.get("ids") {+ Some(v) if several => album_ids(v, C)?,+ _ => vec![album_param(p, "id", C)?],+ };+ if ids.is_empty() {+ return Err(bad(C, "`ids` names no album"));+ }+ if let Some(gone) = ids.iter().find(|a| s.catalog.album(**a).is_none()) {+ return Err(bad(C, if several { format!("no such album {}", gone.0) } else { "no such album".into() }));+ }+ let folder = ids.first().and_then(|id| s.catalog.album(*id)).is_some_and(|a| a.folder); let mut children = std::collections::BTreeMap::<AlbumId, Vec<AlbumId>>::new(); for album in s.catalog.albums() { if let Some(parent) = album.parent {@@ -919,32 +1052,61 @@ pub fn specs() -> Vec<CommandSpec> { // Iterative traversal: even a malformed cycle is rejected before any change. // Reverse parent-first order removes children first; the batch inverse restores // parents first, including memberships, smart rules, covers and manual ordering.- let mut pending = vec![id]; let mut order = Vec::new(); let mut removed = std::collections::BTreeSet::new();- while let Some(next) = pending.pop() {- if !removed.insert(next) {- return Err(bad("album.delete", "cycle in album folders"));+ for id in &ids {+ // inside one named before it: it goes with that one+ if removed.contains(id) {+ continue; }- order.push(next);- if let Some(nested) = children.remove(&next) {- pending.extend(nested);+ let mut pending = vec![*id];+ let mut seen = std::collections::BTreeSet::new();+ while let Some(next) = pending.pop() {+ if !seen.insert(next) {+ return Err(bad(C, "cycle in album folders"));+ }+ // one named before, now found inside this one: it is listed once, here+ if removed.insert(next) {+ order.push(next);+ } else if let Some(at) = order.iter().position(|o| *o == next) {+ let moved = order.remove(at);+ order.push(moved);+ }+ if let Some(nested) = children.get(&next) {+ pending.extend(nested.iter().copied());+ } } } let ops = order.into_iter().rev().map(|id| Op::RemoveAlbum { id }).collect();- s.commit(if folder { "Delete Folder" } else { "Delete Album" }, Op::Batch { ops })?;+ let label = match (ids.len(), folder) {+ (1, true) => "Delete Folder".to_string(),+ (1, false) => "Delete Album".to_string(),+ (n, _) => format!("Delete {n} Albums"),+ };+ s.commit(&label, Op::Batch { ops })?; if matches!(s.source, LibrarySource::Album(current) if removed.contains(¤t)) { s.source = LibrarySource::All; }+ if several { Ok(json!({"deleted": ids.len()})) } else { ok() }+ }+ ),+ cmd!(+ "album.move",+ "Move Album",+ [],+ None,+ "{id, parent?: folderId|null} — into a folder, or to the top level. Several at once: {ids: [albumId], parent} as one undo step (of a folder and an album inside it the folder moves); one that can't go there refuses the whole call",+ always,+ |s, p| {+ if p.get("ids").is_some_and(|v| !v.is_null()) {+ return move_albums(s, p);+ }+ let id = album_param(p, "id", "album.move")?;+ let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);+ s.commit("Move Album", Op::MoveAlbum { id, parent })?; ok() } ),- cmd!("album.move", "Move Album", [], None, "{id, parent?: folderId|null}", always, |s, p| {- let id = album_param(p, "id", "album.move")?;- let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId);- s.commit("Move Album", Op::MoveAlbum { id, parent })?;- ok()- }), cmd!( "album.reorder", "Reorder Album",@@ -5,6 +5,7 @@ use crate::{Selection, Session, command_specs}; mod album_order; mod album_remove; mod albums_local;+mod batch_sidebar; mod color; mod constrain_crop; mod crop_angle;new file mode 100644@@ -0,0 +1,301 @@+//! Acting on several sidebar rows at once: the commands a context menu runs when several albums,+//! folders or keywords are chosen. Each is one step to undo, checks everything before it changes+//! anything, and a refusal names what it refused.+//!+//! Scenarios:+//! - Given three albums, when two are deleted at once (`album.delete {ids}`), then both are gone,+//! the third stays, and one undo brings both back.+//! - Given a folder of albums and an album inside it both named, then deleting them deletes the+//! folder with what is in it, once.+//! - An album that does not exist among those to delete is refused by name, and nothing is deleted.+//! - Given two albums, when both are moved into a folder (`album.move {ids, parent}`), then both+//! are inside it, and one undo puts both back; a folder is not moved into itself, and then+//! nothing moves.+//! - Given keywords on photos, when several are deleted at once (`keyword.delete {keywords}`),+//! then every photo loses them and one undo gives them back; one that is no keyword refuses the+//! whole call and nothing is deleted (one keyword that is no keyword still changes nothing).+//! - Given several keywords, when they are moved inside another (`keyword.move {keywords,+//! parent}`), then each is nested there with its photos, as one undo step; a name that is taken+//! there, or two of them with one name, is refused and nothing moves.+//! - Given two folders of the library, when both are removed at once (`library.removeFolder+//! {paths}`), then their photos go to Recently Deleted as one undo step; one that is not a+//! folder of the library refuses the whole call.+//! - Given two folders, when both get a colour label at once (`folder.label {paths}`), then both+//! show it, and one undo takes both off.++use lightcraft_catalog::{AlbumId, Op, Photo, PhotoId, Source};+use serde_json::json;++use crate::Session;++fn photos(s: &mut Session, n: u64) -> Vec<u64> {+ (1..=n)+ .map(|day| {+ let id = s.catalog.alloc_photo_id();+ let mut p = Photo::new(id, Source::Demo { scene: 1 }, &format!("p{day}.jpg"), "JPEG", 60, 40, "2026-02-01T10:00:00");+ p.captured = Some(format!("2026-01-{day:02}T10:00:00"));+ s.commit("Add Photo", Op::AddPhoto { photo: Box::new(p) }).unwrap();+ id.0+ })+ .collect()+}++fn album(s: &mut Session, params: serde_json::Value) -> u64 {+ s.execute("album.create", ¶ms).unwrap()["id"].as_u64().unwrap()+}++fn has_album(s: &Session, id: u64) -> bool {+ s.catalog.album(AlbumId(id)).is_some()+}++fn parent_of(s: &Session, id: u64) -> Option<u64> {+ s.catalog.album(AlbumId(id)).unwrap().parent.map(|p| p.0)+}++fn tag(s: &mut Session, id: u64, keywords: &[&str]) {+ // the command wants a selection to be enabled, whatever `ids` says+ s.selection.ids = vec![PhotoId(id)];+ s.execute("photo.setMeta", &json!({"ids": [id], "addKeywords": keywords})).unwrap();+ s.selection.ids.clear();+}++fn keywords_of(s: &Session, id: u64) -> Vec<String> {+ let mut k = s.catalog.photo(PhotoId(id)).unwrap().meta.keywords.clone();+ k.sort();+ k+}++fn undo(s: &mut Session) {+ s.execute("edit.undo", &json!({})).unwrap();+}++#[test]+fn several_albums_are_deleted_as_one_step() {+ let mut s = Session::new();+ let (a, b, c) = (album(&mut s, json!({"name": "A"})), album(&mut s, json!({"name": "B"})), album(&mut s, json!({"name": "C"})));+ let steps = s.undo.len();+ let r = s.execute("album.delete", &json!({"ids": [a, c]})).unwrap();+ assert_eq!(r["deleted"], 2);+ assert_eq!((has_album(&s, a), has_album(&s, b), has_album(&s, c)), (false, true, false));+ assert_eq!(s.undo.len(), steps + 1, "one step");+ assert_eq!(s.undo.last().unwrap().label, "Delete 2 Albums");+ undo(&mut s);+ assert!(has_album(&s, a) && has_album(&s, c));+}++#[test]+fn a_folder_and_an_album_inside_it_are_deleted_once() {+ let mut s = Session::new();+ let folder = album(&mut s, json!({"name": "Trips", "folder": true}));+ let inside = album(&mut s, json!({"name": "Alps", "parent": folder}));+ let other = album(&mut s, json!({"name": "Other"}));+ s.execute("album.delete", &json!({"ids": [inside, folder, other]})).unwrap();+ assert!(!has_album(&s, folder) && !has_album(&s, inside) && !has_album(&s, other));+ undo(&mut s);+ assert_eq!(parent_of(&s, inside), Some(folder), "back where it was");+ assert!(has_album(&s, other));+}++#[test]+fn deleting_several_albums_checks_them_all_first() {+ let mut s = Session::new();+ let a = album(&mut s, json!({"name": "A"}));+ let steps = s.undo.len();+ let e = s.execute("album.delete", &json!({"ids": [a, 4242]})).unwrap_err().to_string();+ assert!(e.contains("4242"), "{e}");+ assert!(has_album(&s, a), "nothing was deleted");+ assert_eq!(s.undo.len(), steps);+ for bad in [json!([]), json!("a"), json!([a, "b"])] {+ assert!(s.execute("album.delete", &json!({"ids": bad})).is_err(), "{bad}");+ }+ assert!(has_album(&s, a));+ // one alone, the way it always was+ s.execute("album.delete", &json!({"id": a})).unwrap();+ assert!(!has_album(&s, a));+ assert_eq!(s.undo.last().unwrap().label, "Delete Album");+}++#[test]+fn several_albums_move_into_a_folder_as_one_step() {+ let mut s = Session::new();+ let (a, b) = (album(&mut s, json!({"name": "A"})), album(&mut s, json!({"name": "B"})));+ let folder = album(&mut s, json!({"name": "Trips", "folder": true}));+ let steps = s.undo.len();+ s.execute("album.move", &json!({"ids": [a, b], "parent": folder})).unwrap();+ assert_eq!((parent_of(&s, a), parent_of(&s, b)), (Some(folder), Some(folder)));+ assert_eq!(s.undo.len(), steps + 1);+ assert_eq!(s.undo.last().unwrap().label, "Move 2 Albums");+ undo(&mut s);+ assert_eq!((parent_of(&s, a), parent_of(&s, b)), (None, None));+ // back to the top level, both at once+ s.execute("album.move", &json!({"ids": [a, b], "parent": folder})).unwrap();+ s.execute("album.move", &json!({"ids": [a, b], "parent": null})).unwrap();+ assert_eq!((parent_of(&s, a), parent_of(&s, b)), (None, None));+}++#[test]+fn moving_several_albums_checks_them_all_first() {+ let mut s = Session::new();+ let a = album(&mut s, json!({"name": "A"}));+ let folder = album(&mut s, json!({"name": "Trips", "folder": true}));+ let inner = album(&mut s, json!({"name": "Inner", "folder": true, "parent": folder}));+ // a folder can't go inside itself or inside a folder it holds: then nothing moves+ for parent in [folder, inner] {+ let e = s.execute("album.move", &json!({"ids": [a, folder], "parent": parent})).unwrap_err().to_string();+ assert!(e.contains("Trips"), "the refusal names it: {e}");+ assert_eq!(parent_of(&s, a), None, "nothing moved");+ }+ // into something that is not a folder, or not there+ assert!(s.execute("album.move", &json!({"ids": [folder], "parent": a})).is_err());+ assert!(s.execute("album.move", &json!({"ids": [a], "parent": 4242})).is_err());+ assert!(s.execute("album.move", &json!({"ids": [a, 4242], "parent": folder})).is_err());+ assert_eq!(parent_of(&s, a), None);+ // a folder and an album already inside it: the folder moves, and the album with it+ let outer = album(&mut s, json!({"name": "Outer", "folder": true}));+ let within = album(&mut s, json!({"name": "Within", "parent": inner}));+ s.execute("album.move", &json!({"ids": [within, inner], "parent": outer})).unwrap();+ assert_eq!((parent_of(&s, inner), parent_of(&s, within)), (Some(outer), Some(inner)));+}++#[test]+fn several_keywords_are_deleted_as_one_step() {+ let mut s = Session::new();+ let ids = photos(&mut s, 2);+ tag(&mut s, ids[0], &["alps", "beach", "trips|italy"]);+ tag(&mut s, ids[1], &["beach", "town"]);+ let steps = s.undo.len();+ let r = s.execute("keyword.delete", &json!({"keywords": ["alps", "beach", "trips"]})).unwrap();+ assert_eq!(r["changed"], 2);+ assert_eq!(keywords_of(&s, ids[0]), Vec::<String>::new());+ assert_eq!(keywords_of(&s, ids[1]), vec!["town"]);+ assert_eq!(s.undo.len(), steps + 1);+ assert_eq!(s.undo.last().unwrap().label, "Delete 3 Keywords");+ undo(&mut s);+ assert_eq!(keywords_of(&s, ids[0]), vec!["alps", "beach", "trips|italy"]);+ assert_eq!(keywords_of(&s, ids[1]), vec!["beach", "town"]);+ // nothing named: refused, as is something that is no keyword+ for bad in [json!([]), json!([""]), json!(7), json!(["alps", 7])] {+ assert!(s.execute("keyword.delete", &json!({"keywords": bad})).is_err(), "{bad}");+ }+ assert_eq!(keywords_of(&s, ids[1]), vec!["beach", "town"]);+}++#[test]+fn deleting_several_keywords_checks_them_all_first() {+ let mut s = Session::new();+ let ids = photos(&mut s, 1);+ tag(&mut s, ids[0], &["alps", "trips|italy"]);+ let steps = s.undo.len();+ let e = s.execute("keyword.delete", &json!({"keywords": ["alps", "nowhere"]})).unwrap_err().to_string();+ assert!(e.contains("no keyword") && e.contains("nowhere"), "names the one that is missing: {e}");+ assert_eq!(keywords_of(&s, ids[0]), vec!["alps", "trips|italy"]);+ assert_eq!(s.undo.len(), steps);+ // a keyword that is only the upper level of one, in another case, is there+ s.execute("keyword.delete", &json!({"keywords": ["ALPS", "Trips"]})).unwrap();+ assert_eq!(keywords_of(&s, ids[0]), Vec::<String>::new());+ // one keyword that is not there is still a no-op, as before+ let steps = s.undo.len();+ s.execute("keyword.delete", &json!({"keyword": "nowhere"})).unwrap();+ assert_eq!(s.undo.len(), steps);+}++#[test]+fn several_keywords_move_inside_another_as_one_step() {+ let mut s = Session::new();+ let ids = photos(&mut s, 2);+ tag(&mut s, ids[0], &["alps", "beach"]);+ tag(&mut s, ids[1], &["beach", "places"]);+ let steps = s.undo.len();+ let r = s.execute("keyword.move", &json!({"keywords": ["alps", "beach"], "parent": "places"})).unwrap();+ assert_eq!(r["changed"], 2);+ assert_eq!(keywords_of(&s, ids[0]), vec!["places|alps", "places|beach"]);+ assert_eq!(keywords_of(&s, ids[1]), vec!["places", "places|beach"]);+ assert_eq!(s.undo.len(), steps + 1);+ assert_eq!(s.undo.last().unwrap().label, "Move 2 Keywords");+ undo(&mut s);+ assert_eq!(keywords_of(&s, ids[0]), vec!["alps", "beach"]);+ // …and back out to the top level+ s.execute("keyword.move", &json!({"keywords": ["alps", "beach"], "parent": "places"})).unwrap();+ s.execute("keyword.move", &json!({"keywords": ["places|alps", "places|beach"], "parent": null})).unwrap();+ assert_eq!(keywords_of(&s, ids[0]), vec!["alps", "beach"]);+}++#[test]+fn moving_several_keywords_checks_them_all_first() {+ let mut s = Session::new();+ let ids = photos(&mut s, 2);+ tag(&mut s, ids[0], &["alps", "europe|alps", "beach", "asia|beach", "places"]);+ let before = keywords_of(&s, ids[0]);+ let steps = s.undo.len();+ // a name that is taken where they go: moving would merge the two, which is not done in a batch+ let e = s.execute("keyword.move", &json!({"keywords": ["beach", "alps"], "parent": "europe"})).unwrap_err().to_string();+ assert!(e.contains("europe|alps"), "names the one in the way: {e}");+ // two of them with one name+ let e = s.execute("keyword.move", &json!({"keywords": ["beach", "asia|beach"], "parent": "places"})).unwrap_err().to_string();+ assert!(e.to_lowercase().contains("beach"), "{e}");+ // inside one of themselves; one that is no keyword+ assert!(s.execute("keyword.move", &json!({"keywords": ["places", "alps"], "parent": "places"})).is_err());+ assert!(s.execute("keyword.move", &json!({"keywords": ["alps", "nope"], "parent": "places"})).is_err());+ assert!(s.execute("keyword.move", &json!({"keywords": [], "parent": "places"})).is_err());+ assert_eq!(keywords_of(&s, ids[0]), before, "nothing moved");+ assert_eq!(s.undo.len(), steps);+ // a keyword and one below it: the upper one moves, and the lower with it+ s.execute("keyword.move", &json!({"keywords": ["europe|alps", "europe"], "parent": "places"})).unwrap();+ assert!(keywords_of(&s, ids[0]).contains(&"places|europe|alps".to_string()));+}++fn add_file(s: &mut Session, path: &str) -> u64 {+ let id = s.catalog.alloc_photo_id();+ let p = Photo::new(id, Source::File { path: path.into() }, "x.jpg", "JPEG", 60, 40, "2026-01-01T10:00:00");+ s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap();+ id.0+}++fn deleted(s: &Session, id: u64) -> bool {+ s.catalog.photo(PhotoId(id)).unwrap().deleted+}++#[test]+fn several_folders_leave_the_library_as_one_step() {+ let mut s = Session::new();+ let alps = add_file(&mut s, "/pics/2025/alps/a.jpg");+ let beach = add_file(&mut s, "/pics/2026/beach/c.jpg");+ let town = add_file(&mut s, "/pics/2026/town/d.jpg");+ let steps = s.undo.len();+ // refused as a whole when one is not a folder of the library+ let e = s.execute("library.removeFolder", &json!({"paths": ["/pics/2025/alps", "/nowhere"]})).unwrap_err().to_string();+ assert!(e.contains("/nowhere"), "{e}");+ assert!(!deleted(&s, alps));+ let r = s.execute("library.removeFolder", &json!({"paths": ["/pics/2025/alps", "/pics/2026/beach"]})).unwrap();+ assert_eq!(r["removed"], 2);+ assert_eq!((deleted(&s, alps), deleted(&s, beach), deleted(&s, town)), (true, true, false));+ assert_eq!(s.undo.len(), steps + 1);+ assert_eq!(s.undo.last().unwrap().label, "Remove 2 Folders from Library");+ undo(&mut s);+ assert!(!deleted(&s, alps) && !deleted(&s, beach));+ // a folder and one inside it: each photo once+ add_file(&mut s, "/pics/2025/alps/day1/b.jpg");+ let r = s.execute("library.removeFolder", &json!({"paths": ["/pics/2025/alps/day1", "/pics/2025/alps"]})).unwrap();+ assert_eq!(r["removed"], 2);+}++#[test]+fn several_folders_get_a_colour_label_as_one_step() {+ let mut s = Session::new();+ add_file(&mut s, "/pics/2025/alps/a.jpg");+ add_file(&mut s, "/pics/2026/beach/c.jpg");+ let label = |s: &Session, path: &str| s.catalog.folder_record(path).and_then(|r| r.label);+ let steps = s.undo.len();+ let e = s.execute("folder.label", &json!({"paths": ["/pics/2025/alps", "/nowhere"], "label": "red"})).unwrap_err().to_string();+ assert!(e.contains("/nowhere"), "{e}");+ assert!(label(&s, "/pics/2025/alps").is_none(), "nothing was labelled");+ s.execute("folder.label", &json!({"paths": ["/pics/2025/alps", "/pics/2026/beach"], "label": "red"})).unwrap();+ assert!(label(&s, "/pics/2025/alps").is_some() && label(&s, "/pics/2026/beach").is_some());+ assert_eq!(s.undo.len(), steps + 1);+ undo(&mut s);+ assert!(label(&s, "/pics/2025/alps").is_none() && label(&s, "/pics/2026/beach").is_none());+ // taking the labels off, both at once+ s.execute("folder.label", &json!({"paths": ["/pics/2025/alps", "/pics/2026/beach"], "label": "blue"})).unwrap();+ s.execute("folder.label", &json!({"paths": ["/pics/2025/alps", "/pics/2026/beach"], "label": "none"})).unwrap();+ assert!(label(&s, "/pics/2025/alps").is_none() && label(&s, "/pics/2026/beach").is_none());+}@@ -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-BEHAV-MULTISELECT: several sidebar albums, keywords or folders are deleted, moved or labeled as one undo step in the engine, no sidebar caller yet; 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@@ -49,13 +49,13 @@ cited here still exists. Formerly docs/parity.md. | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | | V. Preferences (PREF) | 6 | 2 | 2 | 2 | 1/1 (100%) | 4/5 (80%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — |-| X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) |+| X. Cross-cutting behaviours (BEHAV) | 22 | 7 | 1 | 1 | 7/8 (88%) | 7/10 (70%) | | Y. Menus | 85 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 78 | 4 | 2 | 1 | 49/52 (94%) | 24/26 (92%) | | Lightroom Classic extras | 41 | 25 | 21 | 8 | — | 24/26 (92%) |-| **Total** | 421 | 65 | 57 | 35 | 196/203 (97%) | 146/161 (91%) |+| **Total** | 420 | 66 | 57 | 35 | 195/203 (96%) | 146/161 (91%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.5%** of 543 in-scope rows — P0 98.3% of 203 · P1 94.4% of 161 · P2 56.7% of 178.+Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.4%** of 543 in-scope rows — P0 98.0% of 203 · P1 94.4% of 161 · P2 56.7% of 178. <!-- /parity:summary --> ## Top gaps and shortcut notes@@ -457,7 +457,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). |---|---|---|---|---|---| | LR-BEHAV-AUTOSAVE | Instant autosave | P0 | ✅ | `crates/catalog/src/journal.rs`, `crates/engine/src/library.rs` | every change fsynced before the command returns; a failed write fails the command ("saved in memory but not written to disk"), keeps the change queued and retries; top-bar warning until saved; a write that fails part-way is cut back to the last whole record so the retry never reads back as damage (logs from older builds with such a fragment load in full); quitting saves queued changes in the closing snapshot even when the log can't be appended to; versioned catalog format (`journal::VERSION`): older libraries are upgraded on open, a library from a newer LightCraft is refused untouched ("written by a newer version"); a damaged or hand-edited snapshot that still reads is made consistent on open (`Catalog::repair`: records filed under another id, stacks of missing, repeated or too few photos, folders inside themselves, id counters behind the ids in use) instead of panicking or hanging later | | LR-BEHAV-UNDO | Global undo | P0 | ✅ | `cmd:edit.undo`, `crates/engine/src/tests.rs` (`rating_flag_undo_redo`), `crates/engine/src/tests/filesystem_history.rs` | covers ratings, albums, deletes, edits; partial file/XMP moves keep unfinished repairs across retries and discarded history; merged renames move directly to their final catalog path |-| LR-BEHAV-MULTISELECT | Multi-selection | P0 | ✅ | `cmd:library.select` (`replace`/`add`/`toggle`/`range`), `cmd:library.selectAll` | |+| LR-BEHAV-MULTISELECT | Multi-selection | P0 | 🟡 | `cmd:library.select` (`replace`/`add`/`toggle`/`range`), `cmd:library.selectAll`, `crates/engine/src/tests/batch_sidebar.rs` | several albums, folders of albums, keywords or library folders chosen in the sidebar are deleted, moved or labeled as one undo step: `album.delete` and `album.move` take `ids`, `keyword.delete` and `keyword.move` take `keywords`, `library.removeFolder` and `folder.label` take `paths`; each checks everything first and names what it refuses (engine only, no sidebar caller yet) | | LR-BEHAV-BATCH | Batch apply to selection | P0 | ✅ | `cmd:photo.rate`, `cmd:develop.paste`, `cmd:preset.apply`, `cmd:app.export` | | | LR-BEHAV-PREVIEW-HOVER | Hover previews | P1 | ✅ | `crates/ui-egui/src/panels/presets.rs`, `crates/ui-egui/src/panels/profiles.rs`, `crates/ui-egui/src/panels/right.rs` (`versions`), `crates/ui-egui/src/panels/detail.rs` | presets, profile menu, profile browser and versions preview in the loupe | | LR-BEHAV-PROGRESSIVE | Progressive rendering | P0 | ✅ | `crates/engine/src/media.rs`, `crates/preview/src/lib.rs` | |@@ -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-23 | patch | LR-BEHAV-MULTISELECT: `album.delete` and `album.move` take `ids`, `keyword.delete` and `keyword.move` take `keywords`, `library.removeFolder` and `folder.label` take `paths`, each as one undo step that checks everything before it changes anything (engine only; the sidebar's Cmd/Shift-click rows are the caller to come); downgraded ✅ → 🟡 until they exist |