From 96dcc2f6437ec7ea575612373b587cb71be91a9d Mon Sep 17 00:00:00 2001 From: Nathaniel Ledford Date: Fri, 9 Oct 2026 14:16:53 -0400 Subject: [PATCH] LRC-LIB-KEYWORDS: keyword names match whatever the case of any letter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `is_under` and `dedupe` compared names by lower-casing them, but other checks used `eq_ignore_ascii_case`: changing only the case of "Ärzte" was refused as a clash with itself, and moving a keyword where it is wasn't seen as staying put. Every comparison now uses `keywords::same`. Co-Authored-By: Claude Opus 5.5 --- crates/catalog/src/keywords.rs | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/crates/catalog/src/keywords.rs b/crates/catalog/src/keywords.rs index debdec9d..d297d34b 100644 --- a/crates/catalog/src/keywords.rs +++ b/crates/catalog/src/keywords.rs @@ -24,6 +24,11 @@ pub fn clean(k: &str) -> String { k.split(SEP).map(str::trim).filter(|s| !s.is_empty()).collect::>().join("|") } +/// The same name, whatever the case of any of its letters (not only ASCII ones). +pub fn same(a: &str, b: &str) -> bool { + a == b || a.to_lowercase() == b.to_lowercase() +} + /// `k` is `parent` or one of its descendants (`parent|…`), ignoring case. pub fn is_under(k: &str, parent: &str) -> bool { let (k, p) = (k.to_lowercase(), parent.to_lowercase()); @@ -204,7 +209,7 @@ impl Catalog { match list.get_mut(&path.to_lowercase()) { Some(there) => { for s in l.info.synonyms { - if !there.info.synonyms.iter().any(|x| x.eq_ignore_ascii_case(&s)) { + if !there.info.synonyms.iter().any(|x| same(x, &s)) { there.info.synonyms.push(s); } } @@ -224,7 +229,7 @@ impl Catalog { if from.is_empty() || to.is_empty() { return Err(CatalogError::Invalid("keyword names can't be empty".into())); } - if is_under(&to, &from) && !to.eq_ignore_ascii_case(&from) { + if is_under(&to, &from) && !same(&to, &from) { return Err(CatalogError::Invalid("can't move a keyword below itself".into())); } let mut ops = self.keyword_ops(|kws| kws.iter().map(|k| if is_under(k, &from) { reparent(k, &from, &to) } else { k.clone() }).collect()); @@ -246,7 +251,7 @@ impl Catalog { /// Merge several keywords (with their children) into `into`. pub fn merge_keywords_ops(&self, from: &[String], into: &str) -> Result { let into = clean(into); - let from: Vec = from.iter().map(|f| clean(f)).filter(|f| !f.is_empty() && !f.eq_ignore_ascii_case(&into)).collect(); + let from: Vec = from.iter().map(|f| clean(f)).filter(|f| !f.is_empty() && !same(f, &into)).collect(); if into.is_empty() || from.is_empty() { return Err(CatalogError::Invalid("merge needs keywords and a target".into())); } @@ -270,7 +275,7 @@ impl Catalog { let info = |path: &str| self.keyword_info(path).unwrap_or(&defaults); let mut out = ExportKeywords::default(); let push = |v: &mut Vec, s: &str| { - if !s.is_empty() && !v.iter().any(|x| x.eq_ignore_ascii_case(s)) { + if !s.is_empty() && !v.iter().any(|x| same(x, s)) { v.push(s.to_string()); } }; @@ -363,7 +368,7 @@ impl Catalog { Some(p) => format!("{}{SEP}{leaf}", self.keyword_path(&p).unwrap_or(p)), None => leaf.to_string(), }; - if to.eq_ignore_ascii_case(&from) { + if same(&to, &from) { return Ok(Op::Batch { ops: vec![] }); } if !merge && self.has_keyword(&to) { @@ -389,7 +394,7 @@ impl Catalog { }; let mut ops = Vec::new(); if to != from { - if !to.eq_ignore_ascii_case(&from) && self.has_keyword(&to) { + if !same(&to, &from) && self.has_keyword(&to) { return Err(CatalogError::KeywordExists(to)); } if let Op::Batch { ops: renamed } = self.rename_keyword_ops(&from, &to)? { @@ -412,7 +417,7 @@ impl Catalog { /// first, at most `n`, never one of `current`. pub fn keyword_suggestions(&self, current: &[String], prefix: &str, n: usize) -> Vec { let all = self.keywords(); - let has = |k: &str| current.iter().any(|c| c.eq_ignore_ascii_case(k)); + let has = |k: &str| current.iter().any(|c| same(c, k)); let q = prefix.trim().to_lowercase(); let mut scored: Vec<(i64, String)> = if !q.is_empty() { all.into_iter() @@ -779,6 +784,21 @@ mod tests { assert_eq!(flat, ["Lisbon", "Lisboa", "Weddings", "Events"]); } + /// Names match whatever the case of any letter, not only ASCII ones: changing only the case of + /// "Ärzte" is a rename, not a clash with itself, and moving it where it is changes nothing. + #[test] + fn names_match_whatever_the_case_of_any_letter() { + let (mut c, ids) = lib(&[&["Ärzte|Wien"]]); + let op = c.edit_keyword_ops("ärzte", "ÄRZTE", KeywordInfo::default()).unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["ÄRZTE|Wien"]); + assert_eq!(c.move_keyword_ops("ärzte|wien", Some("ärzte"), false).unwrap(), Op::Batch { ops: vec![] }); + let op = c.rename_keyword_ops("ärzte", "Ärzte").unwrap(); + c.apply(op).unwrap(); + assert_eq!(kws(&c, ids[0]), ["Ärzte|Wien"]); + assert!(c.create_keyword_ops("ärzte", KeywordInfo::default(), &[]).is_err(), "exists"); + } + #[test] fn suggestions_rank_co_occurrence_and_prefixes() { let (c, _) = lib(&[&["dog", "park"], &["dog", "park", "ball"], &["dog", "beach"], &["cat"], &["cat"], &["cat"], &["parade"], &["eagle"]]);