From 945e5ed7664143b39ea4623e63fbd827ca63d528 Mon Sep 17 00:00:00 2001 From: Garrett Mills Date: Fri, 23 Oct 2026 23:05:19 -0500 Subject: [PATCH] Accent-insensitive card search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fold() (lowercase + strip accents, using the same table as nucleo's own normalization) is registered as a SQLite function and used by the name, oracle-text and location filters, so "khazad-dum" finds Troll of Khazad-dûm. The fuzzy name search folds the query too (accented queries used to match nothing) and its exact-name promotion; decklist/ManaBox import falls back to a folded name match; the deck quick filter and tag suggestions fold client-side. Closes #17 --- Cargo.toml | 2 +- crates/mtgc-core/src/db.rs | 1 + crates/mtgc-core/src/decklist.rs | 57 +++++++++++++++++++++++--------- crates/mtgc-core/src/filters.rs | 48 ++++++++++++++++++++++----- crates/mtgc-core/src/lib.rs | 1 + crates/mtgc-core/src/search.rs | 29 ++++++++++++++-- crates/mtgc-core/src/text.rs | 42 +++++++++++++++++++++++ src/components.tsx | 10 ++++-- src/views/DeckView.tsx | 5 +-- 9 files changed, 163 insertions(+), 32 deletions(-) create mode 100644 crates/mtgc-core/src/text.rs diff --git a/Cargo.toml b/Cargo.toml index 2b2f404..1042c9a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ edition = "2021" license = "MIT" [workspace.dependencies] -rusqlite = { version = "0.31", features = ["bundled"] } +rusqlite = { version = "0.31", features = ["bundled", "functions"] } serde = { version = "1", features = ["derive"] } serde_json = "1" nucleo = "0.5" diff --git a/crates/mtgc-core/src/db.rs b/crates/mtgc-core/src/db.rs index f44b53c..04ba05c 100644 --- a/crates/mtgc-core/src/db.rs +++ b/crates/mtgc-core/src/db.rs @@ -134,6 +134,7 @@ pub fn open_library(library_path: &Path) -> Result { pub fn open(library_path: &Path, reference_path: &Path) -> Result { let conn = Connection::open(library_path)?; conn.pragma_update(None, "foreign_keys", true)?; + crate::text::register_sql_fold(&conn)?; migrate_library(&conn)?; attach_reference(&conn, reference_path)?; Ok(conn) diff --git a/crates/mtgc-core/src/decklist.rs b/crates/mtgc-core/src/decklist.rs index f60cf65..9d1eaec 100644 --- a/crates/mtgc-core/src/decklist.rs +++ b/crates/mtgc-core/src/decklist.rs @@ -310,23 +310,31 @@ pub fn format_library_text(entries: &[LibraryExportLine]) -> String { impl Catalog { /// Case-insensitive exact-name lookup against the reference db's - /// precomputed `name_lower` column. No fuzzy matching — an unresolved - /// name is reported back to the caller rather than guessed at, so a - /// typo never silently imports the wrong card. When several cards share - /// the name, a real card wins over a token or an art-series-style "Card". + /// precomputed `name_lower` column, falling back to an accent-insensitive + /// match so a list written without them ("Lim-Dul's Cohort") still + /// resolves. No fuzzy matching — an unresolved name is reported back to + /// the caller rather than guessed at, so a typo never silently imports + /// the wrong card. When several cards share the name, a real card wins + /// over a token or an art-series-style "Card". pub fn find_oracle_id_by_name(&self, name: &str) -> Result> { - Ok(self - .conn - .query_row( - &format!( - "SELECT oracle_id FROM {REF_DB}.oracle_cards WHERE name_lower = ?1 - ORDER BY (type_line LIKE 'Token%' OR type_line = 'Card'), rowid - LIMIT 1" - ), - params![name.trim().to_lowercase()], - |r| r.get(0), - ) - .optional()?) + let lookup = |column: &str, key: String| -> Result> { + Ok(self + .conn + .query_row( + &format!( + "SELECT oracle_id FROM {REF_DB}.oracle_cards WHERE {column} = ?1 + ORDER BY (type_line LIKE 'Token%' OR type_line = 'Card'), rowid + LIMIT 1" + ), + params![key], + |r| r.get(0), + ) + .optional()?) + }; + match lookup("name_lower", name.trim().to_lowercase())? { + Some(oid) => Ok(Some(oid)), + None => lookup("fold(name)", crate::text::fold(name.trim())), + } } /// Resolve a decklist line to `(oracle_id, scryfall_id of its printing)`. @@ -897,6 +905,23 @@ mod tests { assert!(angel.is_commander); } + #[test] + fn import_resolves_a_name_written_without_its_accents() { + let fx = test_support::make(); + let cat = &fx.catalog; + cat.connection() + .execute( + "UPDATE ref.oracle_cards SET name = 'Lim-Dûl''s Cohort', name_lower = 'lim-dûl''s cohort' + WHERE oracle_id = 'serra-angel'", + [], + ) + .unwrap(); + let (deck, unresolved) = cat.import_deck("Plain", "1 Lim-Dul's Cohort\n").unwrap(); + assert!(unresolved.is_empty()); + let contents = cat.deck_contents(deck.id).unwrap(); + assert_eq!(contents[0].card.oracle_id, "serra-angel"); + } + #[test] fn import_deck_takes_two_commanders_and_folds_the_rest_into_mainboard() { let fx = test_support::make(); diff --git a/crates/mtgc-core/src/filters.rs b/crates/mtgc-core/src/filters.rs index 7dc8162..e579443 100644 --- a/crates/mtgc-core/src/filters.rs +++ b/crates/mtgc-core/src/filters.rs @@ -7,6 +7,7 @@ use rusqlite::types::Value; use crate::db::REF_DB; use crate::error::{CoreError, Result}; use crate::model::{LibraryCard, LibraryFilter}; +use crate::text::fold; use crate::Catalog; /// Append the card-data predicates shared by [`Catalog::search_library`] and @@ -26,8 +27,8 @@ fn push_collection_filters(sql: &mut String, vals: &mut Vec, filter: &Lib vals.push(Value::Integer(deck_id)); } if let Some(loc) = filter.location.as_ref().map(|l| l.trim()).filter(|l| !l.is_empty()) { - sql.push_str(" AND LOWER(lc.location) LIKE ?"); - vals.push(Value::Text(format!("%{}%", loc.to_lowercase()))); + sql.push_str(" AND fold(lc.location) LIKE ?"); + vals.push(Value::Text(format!("%{}%", fold(loc)))); } if filter.spares_only { sql.push_str(SPARES_PREDICATE); @@ -106,8 +107,9 @@ fn push_card_filters( ); } if let Some(text) = filter.text.as_ref().map(|t| t.trim()).filter(|t| !t.is_empty()) { - sql.push_str(" AND oc.name_lower LIKE ?"); - vals.push(Value::Text(format!("%{}%", text.to_lowercase()))); + // Folded on both sides, so "khazad-dum" finds Troll of Khazad-dûm. + sql.push_str(" AND fold(oc.name) LIKE ?"); + vals.push(Value::Text(format!("%{}%", fold(text)))); } if let Some(code) = filter.set_code.as_ref().filter(|c| !c.is_empty()) { sql.push_str(&format!( @@ -188,8 +190,8 @@ fn push_card_filters( vals.push(Value::Text(k.clone())); } if let Some(txt) = filter.oracle_text.as_ref().map(|t| t.trim()).filter(|t| !t.is_empty()) { - sql.push_str(" AND LOWER(oc.oracle_text) LIKE ?"); - vals.push(Value::Text(format!("%{}%", txt.to_lowercase()))); + sql.push_str(" AND fold(oc.oracle_text) LIKE ?"); + vals.push(Value::Text(format!("%{}%", fold(txt)))); } } @@ -304,8 +306,8 @@ impl Catalog { // off by the LIMIT. Bound after the WHERE values and before the LIMIT, so // the positional order still lines up. if let Some(text) = filter.text.as_ref().map(|t| t.trim()).filter(|t| !t.is_empty()) { - sql.push_str("oc.name_lower <> ?, "); - vals.push(Value::Text(text.to_lowercase())); + sql.push_str("fold(oc.name) <> ?, "); + vals.push(Value::Text(fold(text))); } sql.push_str("oc.edhrec_rank IS NULL, oc.edhrec_rank, oc.name COLLATE NOCASE LIMIT ?"); vals.push(Value::Integer(limit as i64)); @@ -340,6 +342,36 @@ mod tests { cards.iter().map(|c| c.card.name.clone()).collect() } + #[test] + fn name_and_oracle_text_match_regardless_of_accents() { + let fx = test_support::make(); + let cat = &fx.catalog; + cat.connection() + .execute( + "UPDATE ref.oracle_cards + SET name = 'Troll of Khazad-dûm', name_lower = 'troll of khazad-dûm', + oracle_text = 'Swampcycling. Lim-Dûl approves.' + WHERE oracle_id = 'serra-angel'", + [], + ) + .unwrap(); + cat.add_owned("serra-angel", 1).unwrap(); + cat.add_owned("lightning-bolt", 1).unwrap(); + + let by_name = |text: &str| LibraryFilter { text: Some(text.into()), ..Default::default() }; + let by_text = |text: &str| LibraryFilter { oracle_text: Some(text.into()), ..Default::default() }; + + // Plain query, accented card — in the library and the whole card pool. + assert_eq!(names(&cat.search_library(&by_name("khazad-dum")).unwrap()), vec!["Troll of Khazad-dûm"]); + assert_eq!( + names(&cat.search_reference(&by_name("KHAZAD-DUM"), 10).unwrap()), + vec!["Troll of Khazad-dûm"] + ); + // Accented query, plain card. + assert_eq!(names(&cat.search_library(&by_name("líghtning")).unwrap()), vec!["Lightning Bolt"]); + assert_eq!(names(&cat.search_reference(&by_text("lim-dul"), 10).unwrap()), vec!["Troll of Khazad-dûm"]); + } + #[test] fn empty_filter_returns_all_owned() { let fx = owned_fixture(); diff --git a/crates/mtgc-core/src/lib.rs b/crates/mtgc-core/src/lib.rs index 5167c2e..d1e98f2 100644 --- a/crates/mtgc-core/src/lib.rs +++ b/crates/mtgc-core/src/lib.rs @@ -21,6 +21,7 @@ pub mod printings; pub mod search; pub mod sync; pub mod synergy; +pub mod text; use std::path::Path; diff --git a/crates/mtgc-core/src/search.rs b/crates/mtgc-core/src/search.rs index dda849e..0174192 100644 --- a/crates/mtgc-core/src/search.rs +++ b/crates/mtgc-core/src/search.rs @@ -91,17 +91,21 @@ impl NameIndex { return Vec::new(); } let mut matcher = Matcher::new(Config::DEFAULT); - let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart); + // nucleo's Smart normalization turns itself off when the query has an + // accent, so fold it first: "Élves" finds "Elves" as well as the + // reverse. A query without accents passes through unchanged. + let pattern = Pattern::parse(&crate::text::fold(query), CaseMatching::Ignore, Normalization::Smart); let matches = pattern.match_list(self.entries.iter(), &mut matcher); // Typing a card's full name means *that* card, whatever the fuzzy score // says. "Explore" otherwise sits below "Explorer's Scope", "Explore the // Vastlands" and a dozen other names that merely contain the word — and // with a small `limit` it can fall off the end entirely, making the card // unreachable. Promote before truncating, for that reason. - let needle = query.trim().to_lowercase(); + // Folded, so typing an accented name without its accents still counts. + let needle = crate::text::fold(query.trim()); let (mut ranked, rest): (Vec<_>, Vec<_>) = matches .into_iter() - .partition(|(e, _)| e.card.name.to_lowercase() == needle); + .partition(|(e, _)| crate::text::fold(&e.card.name) == needle); ranked.extend(rest); ranked.truncate(limit); ranked.into_iter().map(|(e, _score)| e.card.clone()).collect() @@ -174,6 +178,25 @@ mod tests { assert_eq!(hits[0].name, "Explore"); } + #[test] + fn accents_dont_matter_in_either_direction() { + let fx = test_support::make(); + fx.catalog + .conn + .execute( + "UPDATE ref.oracle_cards SET name = 'Lim-Dûl''s Cohort', name_lower = 'lim-dûl''s cohort' + WHERE oracle_id = 'serra-angel'", + [], + ) + .unwrap(); + let idx = fx.catalog.build_name_index().unwrap(); + + assert_eq!(idx.search("lim-dul", 5)[0].name, "Lim-Dûl's Cohort"); + // Typed in full without the accent, it's still the exact match. + assert_eq!(idx.search("lim-dul's cohort", 1)[0].name, "Lim-Dûl's Cohort"); + assert_eq!(idx.search("Llanowar Élves", 5)[0].name, "Llanowar Elves"); + } + #[test] fn prefix_and_limit() { let fx = test_support::make(); diff --git a/crates/mtgc-core/src/text.rs b/crates/mtgc-core/src/text.rs new file mode 100644 index 0000000..786e981 --- /dev/null +++ b/crates/mtgc-core/src/text.rs @@ -0,0 +1,42 @@ +//! Accent- and case-insensitive text matching. + +/// Lowercase `s` and strip accents ("Lim-Dûl's Cohort" → "lim-dul's cohort"), +/// so typed text matches card names however they're accented. Uses the same +/// per-character table as the fuzzy name search's own normalization, so both +/// search paths agree on what counts as a match. +pub fn fold(s: &str) -> String { + s.chars() + .map(|c| nucleo::chars::normalize(nucleo::chars::to_lower_case(c))) + .collect() +} + +/// Register [`fold`] as the SQL function `fold(text)` on `conn`, so queries +/// can compare `fold(oc.name)` against a folded pattern. +pub(crate) fn register_sql_fold(conn: &rusqlite::Connection) -> rusqlite::Result<()> { + use rusqlite::functions::FunctionFlags; + conn.create_scalar_function( + "fold", + 1, + FunctionFlags::SQLITE_UTF8 | FunctionFlags::SQLITE_DETERMINISTIC, + |ctx| { + Ok(match ctx.get::>(0)? { + Some(s) => Some(fold(&s)), + None => None, + }) + }, + ) +} + +#[cfg(test)] +mod tests { + use super::fold; + + #[test] + fn folds_case_and_accents_and_leaves_ascii_alone() { + assert_eq!(fold("Lim-Dûl's Cohort"), "lim-dul's cohort"); + assert_eq!(fold("Éowyn, Lady of Rohan"), "eowyn, lady of rohan"); + assert_eq!(fold("Hamato Ninpō"), "hamato ninpo"); + assert_eq!(fold("Jakub Šlemr"), "jakub slemr"); + assert_eq!(fold("Lightning Bolt"), "lightning bolt"); + } +} diff --git a/src/components.tsx b/src/components.tsx index 1ba0f14..09cb296 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -632,6 +632,12 @@ export const CardSearchBox = forwardRef< ); }); +/** Lowercase and strip accents, so "khazad-dum" matches "Khazad-dûm". The + * frontend twin of `mtgc_core::text::fold`, for matching done client-side. */ +export function fold(s: string): string { + return s.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase(); +} + /** * A chip-based multi-value text filter with autocomplete: selected values * render as removable chips, and typing filters `suggestions` into a @@ -653,11 +659,11 @@ export function TagInput({ const [text, setText] = useState(""); const [sel, setSel] = useState(0); - const query = text.trim().toLowerCase(); + const query = fold(text.trim()); const filtered = query ? suggestions .filter((s) => !values.some((v) => v.toLowerCase() === s.toLowerCase())) - .filter((s) => s.toLowerCase().includes(query)) + .filter((s) => fold(s).includes(query)) .slice(0, 8) : []; diff --git a/src/views/DeckView.tsx b/src/views/DeckView.tsx index 7096425..9c37617 100644 --- a/src/views/DeckView.tsx +++ b/src/views/DeckView.tsx @@ -37,6 +37,7 @@ import { usePersistedState, useTileColumns, useViewMode, + fold, } from "../components"; import { SuggestionsModal } from "../SuggestionsModal"; import { useBackButton } from "../backButton"; @@ -878,9 +879,9 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) { // filter only, distinct from the CardSearchBox boxes below (which search // the library/reference db to find NEW cards to add). function matchesFilter(e: DeckEntry): boolean { - const q = filterQuery.trim().toLowerCase(); + const q = fold(filterQuery.trim()); if (!q) return true; - return e.name.toLowerCase().includes(q) || e.type_line.toLowerCase().includes(q); + return fold(e.name).includes(q) || fold(e.type_line).includes(q); } const owned = contents.filter((e) => !e.is_maybeboard && e.quantity_owned > 0 && matchesFilter(e));