diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index d351941d6..debdec9d8 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -32,7 +32,7 @@ pub fn is_under(k: &str, parent: &str) -> bool { /// 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 (ẞ / ß). -fn reparent(k: &str, from: &str, to: &str) -> String { +pub fn reparent(k: &str, from: &str, to: &str) -> String { let skip = from.split(SEP).filter(|s| !s.trim().is_empty()).count(); let rest: Vec<&str> = k.split(SEP).filter(|s| !s.trim().is_empty()).skip(skip).collect(); std::iter::once(to).filter(|t| !t.is_empty()).chain(rest).collect::>().join("|") diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index 876cf485e..8c21d6579 100644 --- a/crates/engine/src/cmd/keywords.rs +++ b/crates/engine/src/cmd/keywords.rs @@ -1,7 +1,7 @@ //! Library-wide keyword commands: list (tree with counts), suggestions, rename, delete, merge; //! keyword sets (nine keywords a keystroke away: ⌥1–⌥9) and Recent Keywords. -use lightcraft_catalog::keywords::{clean, is_under}; +use lightcraft_catalog::keywords::{clean, is_under, reparent}; use serde_json::{Value, json}; use super::{CommandSpec, always, bad, cmd, str_param}; @@ -197,9 +197,7 @@ pub fn specs() -> Vec { let to = str_param(p, "to").ok_or_else(|| bad("keyword.rename", "missing `to`"))?.to_string(); let op = s.catalog.rename_keyword_ops(&from, &to).map_err(|e| bad("keyword.rename", e.to_string()))?; let (f, t) = (clean(&from), clean(&to)); - commit_keywords(s, "Rename Keyword", op, |k| { - Some(if is_under(k, &f) { format!("{t}{}", &k[f.len().min(k.len())..]) } else { k.to_string() }) - }) + commit_keywords(s, "Rename Keyword", op, |k| Some(if is_under(k, &f) { reparent(k, &f, &t) } else { k.to_string() })) } ), cmd!( @@ -230,7 +228,7 @@ pub fn specs() -> Vec { let (from, into) = (from.iter().map(|f| clean(f)).collect::>(), clean(&into)); commit_keywords(s, "Merge Keywords", op, |k| { Some(match from.iter().find(|f| is_under(k, f)) { - Some(f) => format!("{into}{}", &k[f.len().min(k.len())..]), + Some(f) => reparent(k, f, &into), None => k.to_string(), }) }) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a6c5f6420..aab6c0277 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -859,6 +859,8 @@ mod tests_import; #[cfg(test)] mod tests_import_move; #[cfg(test)] +mod tests_keyword_list; +#[cfg(test)] mod tests_libops; #[cfg(test)] mod tests_library; diff --git a/crates/engine/src/tests_keyword_list.rs b/crates/engine/src/tests_keyword_list.rs new file mode 100644 index 000000000..04a78fa8c --- /dev/null +++ b/crates/engine/src/tests_keyword_list.rs @@ -0,0 +1,21 @@ +//! The keyword list's commands (Lightroom Classic's Keyword List): create, edit, move and purge +//! keywords, their attributes, and the default parent for new keywords. + +use serde_json::json; + +use crate::Session; + +/// The keyword filter follows a renamed keyword whatever the case it was typed in, also where a +/// letter's case changes its length (ẞ / ß): it used to be cut by bytes, which could land inside +/// a letter and panic. +#[test] +fn the_filter_follows_a_rename_whatever_the_case() { + let mut s = Session::with_demo(); + let id = s.visible_cloned()[0].0; + s.execute("photo.setMeta", &json!({"ids": [id], "keywords": ["ßßa|é"]})).unwrap(); + s.execute("library.filter", &json!({"keyword": "ßßa|é"})).unwrap(); + s.execute("keyword.rename", &json!({"from": "ẞẞA", "to": "Road"})).unwrap(); + assert_eq!(s.filter.keyword.as_deref(), Some("Road|é")); + s.execute("keyword.merge", &json!({"from": ["ROAD"], "into": "Weg"})).unwrap(); + assert_eq!(s.filter.keyword.as_deref(), Some("Weg|é")); +}