Accent-insensitive card search

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
This commit is contained in:
2026-10-23 23:05:19 -05:00
parent d4d19dcdb4
commit 945e5ed766
9 changed files with 163 additions and 32 deletions

View File

@@ -8,7 +8,7 @@ edition = "2021"
license = "MIT" license = "MIT"
[workspace.dependencies] [workspace.dependencies]
rusqlite = { version = "0.31", features = ["bundled"] } rusqlite = { version = "0.31", features = ["bundled", "functions"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
nucleo = "0.5" nucleo = "0.5"

View File

@@ -134,6 +134,7 @@ pub fn open_library(library_path: &Path) -> Result<Connection> {
pub fn open(library_path: &Path, reference_path: &Path) -> Result<Connection> { pub fn open(library_path: &Path, reference_path: &Path) -> Result<Connection> {
let conn = Connection::open(library_path)?; let conn = Connection::open(library_path)?;
conn.pragma_update(None, "foreign_keys", true)?; conn.pragma_update(None, "foreign_keys", true)?;
crate::text::register_sql_fold(&conn)?;
migrate_library(&conn)?; migrate_library(&conn)?;
attach_reference(&conn, reference_path)?; attach_reference(&conn, reference_path)?;
Ok(conn) Ok(conn)

View File

@@ -310,23 +310,31 @@ pub fn format_library_text(entries: &[LibraryExportLine]) -> String {
impl Catalog { impl Catalog {
/// Case-insensitive exact-name lookup against the reference db's /// Case-insensitive exact-name lookup against the reference db's
/// precomputed `name_lower` column. No fuzzy matching — an unresolved /// precomputed `name_lower` column, falling back to an accent-insensitive
/// name is reported back to the caller rather than guessed at, so a /// match so a list written without them ("Lim-Dul's Cohort") still
/// typo never silently imports the wrong card. When several cards share /// resolves. No fuzzy matching — an unresolved name is reported back to
/// the name, a real card wins over a token or an art-series-style "Card". /// 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<Option<String>> { pub fn find_oracle_id_by_name(&self, name: &str) -> Result<Option<String>> {
let lookup = |column: &str, key: String| -> Result<Option<String>> {
Ok(self Ok(self
.conn .conn
.query_row( .query_row(
&format!( &format!(
"SELECT oracle_id FROM {REF_DB}.oracle_cards WHERE name_lower = ?1 "SELECT oracle_id FROM {REF_DB}.oracle_cards WHERE {column} = ?1
ORDER BY (type_line LIKE 'Token%' OR type_line = 'Card'), rowid ORDER BY (type_line LIKE 'Token%' OR type_line = 'Card'), rowid
LIMIT 1" LIMIT 1"
), ),
params![name.trim().to_lowercase()], params![key],
|r| r.get(0), |r| r.get(0),
) )
.optional()?) .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)`. /// Resolve a decklist line to `(oracle_id, scryfall_id of its printing)`.
@@ -897,6 +905,23 @@ mod tests {
assert!(angel.is_commander); 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] #[test]
fn import_deck_takes_two_commanders_and_folds_the_rest_into_mainboard() { fn import_deck_takes_two_commanders_and_folds_the_rest_into_mainboard() {
let fx = test_support::make(); let fx = test_support::make();

View File

@@ -7,6 +7,7 @@ use rusqlite::types::Value;
use crate::db::REF_DB; use crate::db::REF_DB;
use crate::error::{CoreError, Result}; use crate::error::{CoreError, Result};
use crate::model::{LibraryCard, LibraryFilter}; use crate::model::{LibraryCard, LibraryFilter};
use crate::text::fold;
use crate::Catalog; use crate::Catalog;
/// Append the card-data predicates shared by [`Catalog::search_library`] and /// 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<Value>, filter: &Lib
vals.push(Value::Integer(deck_id)); vals.push(Value::Integer(deck_id));
} }
if let Some(loc) = filter.location.as_ref().map(|l| l.trim()).filter(|l| !l.is_empty()) { 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 ?"); sql.push_str(" AND fold(lc.location) LIKE ?");
vals.push(Value::Text(format!("%{}%", loc.to_lowercase()))); vals.push(Value::Text(format!("%{}%", fold(loc))));
} }
if filter.spares_only { if filter.spares_only {
sql.push_str(SPARES_PREDICATE); 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()) { 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 ?"); // Folded on both sides, so "khazad-dum" finds Troll of Khazad-dûm.
vals.push(Value::Text(format!("%{}%", text.to_lowercase()))); 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()) { if let Some(code) = filter.set_code.as_ref().filter(|c| !c.is_empty()) {
sql.push_str(&format!( sql.push_str(&format!(
@@ -188,8 +190,8 @@ fn push_card_filters(
vals.push(Value::Text(k.clone())); vals.push(Value::Text(k.clone()));
} }
if let Some(txt) = filter.oracle_text.as_ref().map(|t| t.trim()).filter(|t| !t.is_empty()) { 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 ?"); sql.push_str(" AND fold(oc.oracle_text) LIKE ?");
vals.push(Value::Text(format!("%{}%", txt.to_lowercase()))); 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 // off by the LIMIT. Bound after the WHERE values and before the LIMIT, so
// the positional order still lines up. // the positional order still lines up.
if let Some(text) = filter.text.as_ref().map(|t| t.trim()).filter(|t| !t.is_empty()) { if let Some(text) = filter.text.as_ref().map(|t| t.trim()).filter(|t| !t.is_empty()) {
sql.push_str("oc.name_lower <> ?, "); sql.push_str("fold(oc.name) <> ?, ");
vals.push(Value::Text(text.to_lowercase())); vals.push(Value::Text(fold(text)));
} }
sql.push_str("oc.edhrec_rank IS NULL, oc.edhrec_rank, oc.name COLLATE NOCASE LIMIT ?"); sql.push_str("oc.edhrec_rank IS NULL, oc.edhrec_rank, oc.name COLLATE NOCASE LIMIT ?");
vals.push(Value::Integer(limit as i64)); vals.push(Value::Integer(limit as i64));
@@ -340,6 +342,36 @@ mod tests {
cards.iter().map(|c| c.card.name.clone()).collect() 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] #[test]
fn empty_filter_returns_all_owned() { fn empty_filter_returns_all_owned() {
let fx = owned_fixture(); let fx = owned_fixture();

View File

@@ -21,6 +21,7 @@ pub mod printings;
pub mod search; pub mod search;
pub mod sync; pub mod sync;
pub mod synergy; pub mod synergy;
pub mod text;
use std::path::Path; use std::path::Path;

View File

@@ -91,17 +91,21 @@ impl NameIndex {
return Vec::new(); return Vec::new();
} }
let mut matcher = Matcher::new(Config::DEFAULT); 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); let matches = pattern.match_list(self.entries.iter(), &mut matcher);
// Typing a card's full name means *that* card, whatever the fuzzy score // Typing a card's full name means *that* card, whatever the fuzzy score
// says. "Explore" otherwise sits below "Explorer's Scope", "Explore the // says. "Explore" otherwise sits below "Explorer's Scope", "Explore the
// Vastlands" and a dozen other names that merely contain the word — and // 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 // with a small `limit` it can fall off the end entirely, making the card
// unreachable. Promote before truncating, for that reason. // 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 let (mut ranked, rest): (Vec<_>, Vec<_>) = matches
.into_iter() .into_iter()
.partition(|(e, _)| e.card.name.to_lowercase() == needle); .partition(|(e, _)| crate::text::fold(&e.card.name) == needle);
ranked.extend(rest); ranked.extend(rest);
ranked.truncate(limit); ranked.truncate(limit);
ranked.into_iter().map(|(e, _score)| e.card.clone()).collect() ranked.into_iter().map(|(e, _score)| e.card.clone()).collect()
@@ -174,6 +178,25 @@ mod tests {
assert_eq!(hits[0].name, "Explore"); 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] #[test]
fn prefix_and_limit() { fn prefix_and_limit() {
let fx = test_support::make(); let fx = test_support::make();

View File

@@ -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::<Option<String>>(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");
}
}

View File

@@ -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 * A chip-based multi-value text filter with autocomplete: selected values
* render as removable chips, and typing filters `suggestions` into a * render as removable chips, and typing filters `suggestions` into a
@@ -653,11 +659,11 @@ export function TagInput({
const [text, setText] = useState(""); const [text, setText] = useState("");
const [sel, setSel] = useState(0); const [sel, setSel] = useState(0);
const query = text.trim().toLowerCase(); const query = fold(text.trim());
const filtered = query const filtered = query
? suggestions ? suggestions
.filter((s) => !values.some((v) => v.toLowerCase() === s.toLowerCase())) .filter((s) => !values.some((v) => v.toLowerCase() === s.toLowerCase()))
.filter((s) => s.toLowerCase().includes(query)) .filter((s) => fold(s).includes(query))
.slice(0, 8) .slice(0, 8)
: []; : [];

View File

@@ -37,6 +37,7 @@ import {
usePersistedState, usePersistedState,
useTileColumns, useTileColumns,
useViewMode, useViewMode,
fold,
} from "../components"; } from "../components";
import { SuggestionsModal } from "../SuggestionsModal"; import { SuggestionsModal } from "../SuggestionsModal";
import { useBackButton } from "../backButton"; import { useBackButton } from "../backButton";
@@ -878,9 +879,9 @@ export function DeckView({ refreshSignal }: { refreshSignal?: number }) {
// filter only, distinct from the CardSearchBox boxes below (which search // filter only, distinct from the CardSearchBox boxes below (which search
// the library/reference db to find NEW cards to add). // the library/reference db to find NEW cards to add).
function matchesFilter(e: DeckEntry): boolean { function matchesFilter(e: DeckEntry): boolean {
const q = filterQuery.trim().toLowerCase(); const q = fold(filterQuery.trim());
if (!q) return true; 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)); const owned = contents.filter((e) => !e.is_maybeboard && e.quantity_owned > 0 && matchesFilter(e));