search: fold accents when matching
A stopgap ahead of more search work.
Assisted-by: GLM-5.3 via pi
diff --git a/lib/pro_tacts/admin/dashboard.rb b/lib/pro_tacts/admin/dashboard.rb
index 777ddf4..92b66c2 100644
--- a/lib/pro_tacts/admin/dashboard.rb
+++ b/lib/pro_tacts/admin/dashboard.rb
@@ -41,8 +41,8 @@ module ProTacts
@notice = notice
@labels = {}
groups.each { |group| group.members.each { (@labels[it] ||= []) << group.label } }
- q = @query.downcase
- @groups = @query.empty? ? [] : groups.select { it.label.downcase.include?(q) }.sort
+ q = Format.search_key(@query)
+ @groups = @query.empty? ? [] : groups.select { Format.search_key(it.label).include?(q) }.sort
@rows = @query.empty? ? recent.first(RECENT_LIMIT) : recent.select { matches?(it, @query) }
end
@@ -81,16 +81,17 @@ module ProTacts
# Match generously (docs/DESIGN.md): a contact is findable by
# name or nickname, any of its values, or the groups it
- # belongs to.
+ # belongs to — every comparison against the fold
+ # Format.search_key makes of both sides, accents with the case.
#: (Store::RecentContact row, String query) -> bool
def matches?(row, query)
- q = query.downcase
- return true if row.contact.name&.downcase&.include?(q)
- return true if row.contact.nickname&.downcase&.include?(q)
- return true if row.contact.organization&.downcase&.include?(q)
- return true if row.contact.phones.any? { it.value.downcase.include?(q) }
- return true if row.contact.emails.any? { it.value.downcase.include?(q) }
- return true if @labels.fetch(row.contact.id, []).any? { it.downcase.include?(q) }
+ q = Format.search_key(query)
+ return true if Format.search_key(row.contact.name.to_s).include?(q)
+ return true if Format.search_key(row.contact.nickname.to_s).include?(q)
+ return true if Format.search_key(row.contact.organization.to_s).include?(q)
+ return true if row.contact.phones.any? { Format.search_key(it.value).include?(q) }
+ return true if row.contact.emails.any? { Format.search_key(it.value).include?(q) }
+ return true if @labels.fetch(row.contact.id, []).any? { Format.search_key(it).include?(q) }
false
end
diff --git a/lib/pro_tacts/admin/format.rb b/lib/pro_tacts/admin/format.rb
index e13ed5f..7aa3a9d 100644
--- a/lib/pro_tacts/admin/format.rb
+++ b/lib/pro_tacts/admin/format.rb
@@ -35,6 +35,20 @@ module ProTacts
(family || contact.name || contact.id).to_s.downcase
end
+ # The string a search compares on: the query and the value it
+ # is matched against both lowercased and accents folded to the
+ # letters they sit on, so "munoz" finds "Muñoz". NFD plus
+ # \p{Mn} carries an accent off as a combining mark and leaves
+ # every other script findable as itself — deliberately not
+ # ASCII-only, which would strip a name no ASCII letter survives
+ # down to nothing a query could reach. A letter that is whole
+ # rather than base-plus-accent (ß, æ) stays: folding it is
+ # transliteration, not this.
+ #: (String value) -> String
+ def self.search_key(value)
+ value.downcase.unicode_normalize(:nfd).gsub(/\p{Mn}+/, "")
+ end
+
# A contact's initials. N's given and family names (RFC 2426
# section 3.1.2) are structured data and the real answer to "what
# are this contact's initials" — falling back, when the card
diff --git a/lib/pro_tacts/admin/group_filter.rb b/lib/pro_tacts/admin/group_filter.rb
index 6e2dd7d..8847ac6 100644
--- a/lib/pro_tacts/admin/group_filter.rb
+++ b/lib/pro_tacts/admin/group_filter.rb
@@ -1,5 +1,6 @@
require "pro_tacts/admin/phlex"
+require "pro_tacts/admin/format"
require "pro_tacts/admin/icons"
module ProTacts
@@ -28,8 +29,10 @@ module ProTacts
# contacts page renders the filter alone: its rows have no boxes,
# and nothing caps a list whose whole point is the whole book.
#
- # Alpine does the filtering, matching each row's lowercased label
- # in `data-label` so no group's name is ever spliced into script.
+ # Alpine does the filtering, matching each row's label as
+ # Format.search_key folds it — lowercased, accents folded —
+ # against a needle folded the same way in JS, so no group's name
+ # is ever spliced into script.
# A row it hides still submits its box: hiding is not unticking,
# and a contact's other groups are not being answered for by a
# word typed to find one of them. Every row stays in the page for
@@ -65,7 +68,7 @@ module ProTacts
# two only re-read when the filter changes — a name committed
# since would be invisible to both until the next keystroke.
STATE = "{ filter: '', all: false, named: [], " \
- "get needle() { return this.filter.trim().toLowerCase() }, " \
+ "get needle() { return this.filter.trim().toLowerCase().normalize('NFD').replace(/\\p{Mn}/gu, '') }, " \
"get labels() { return [...this.$refs.options.querySelectorAll('[data-label]')]" \
".map(row => row.dataset.label) }, " \
"get rows() { return [...this.$refs.options.querySelectorAll('[data-label]')] }, " \
@@ -87,7 +90,7 @@ module ProTacts
# cannot see is a row it would offer to create twice.
#: (String label, ?capped: bool) -> Hash[untyped, untyped]
def self.row(label, capped: false)
- {data: {label: label.downcase, capped:}, ":hidden": "!visible($el)"}
+ {data: {label: Format.search_key(label), capped:}, ":hidden": "!visible($el)"}
end
# Which of `ids` the cap holds back, in the order they render.
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 55f8318..d07e624 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -458,6 +458,28 @@ class AdminContactsPagesTest < Minitest::Test
end
end
+ # Both sides of the match fold (Format.search_key): a query without
+ # the accents finds a name with them, and a query with them a name
+ # without.
+ def test_search_folds_accents
+ emile = ADA.sub("FN:Ada Lovelace", "FN:Émile Muñoz")
+ .sub("N:Lovelace;Ada;;;", "N:Muñoz;Émile;;;").sub("UID:ada", "UID:emile")
+ jose = ADA.sub("FN:Ada Lovelace", "FN:Jose Alvarez")
+ .sub("N:Lovelace;Ada;;;", "N:Alvarez;Jose;;;").sub("UID:ada", "UID:jose")
+
+ with_contacts({"emile" => emile, "jose" => jose}) do
+ get "/", q: "munoz"
+
+ contacts_column = last_response.body.split("upcoming birthdays").first
+ assert_includes contacts_column, "Émile Muñoz"
+ refute_includes contacts_column, "Jose Alvarez"
+
+ get "/", q: "josé"
+
+ assert_includes last_response.body.split("upcoming birthdays").first, "Jose Alvarez"
+ end
+ end
+
def test_search_with_no_matches_says_so
with_contacts({"ada" => ADA}) do
get "/", q: "nobody"
diff --git a/test/pro_tacts/admin/test_format.rb b/test/pro_tacts/admin/test_format.rb
index db00a82..db7dd2e 100644
--- a/test/pro_tacts/admin/test_format.rb
+++ b/test/pro_tacts/admin/test_format.rb
@@ -51,6 +51,15 @@ class FormatTest < Minitest::Test
assert_equal "L", ProTacts::Admin::Format.initials(contact(family_only))
end
+ # Search compares on the fold, not the spelling: an accent never
+ # hides the letter it sits on, and a letter another script spells
+ # stays — ASCII-only matching would make that name unfindable.
+ def test_search_key_folds_accents_and_keeps_whole_letters
+ assert_equal "emile munoz", ProTacts::Admin::Format.search_key("Émile Muñoz")
+ assert_equal "νικος", ProTacts::Admin::Format.search_key("Νίκος")
+ assert_equal "straße", ProTacts::Admin::Format.search_key("Straße")
+ end
+
def test_formats_the_birthday
birthday = ProTacts::Birthday.new(year: 1985, month: 12, day: 10)
diff --git a/test/pro_tacts/admin/test_groups_pages.rb b/test/pro_tacts/admin/test_groups_pages.rb
index fe3aa47..9aa0408 100644
--- a/test/pro_tacts/admin/test_groups_pages.rb
+++ b/test/pro_tacts/admin/test_groups_pages.rb
@@ -382,6 +382,20 @@ class AdminGroupsPagesTest < Minitest::Test
end
end
+ # The client-side filter folds the same way the dashboard's does
+ # (Format.search_key on the label, the same fold in JS on the
+ # needle), so an accent hides a row from neither.
+ def test_the_members_filter_folds_accents
+ with_contacts({"emile" => "Émile Muñoz"}) do |store|
+ id = store.create_group
+
+ get "/groups/#{id}/members"
+
+ assert_includes last_response.body, %(data-label="emile munoz")
+ assert_includes last_response.body, "normalize('NFD')"
+ end
+ end
+
def test_the_members_list_in_name_order_on_the_screen_too
with_contacts(BOOLES.merge("zz" => "Alice Aardvark")) do |store|
id = household(store, members: %w[mary zz george])
@@ -543,4 +557,18 @@ class AdminGroupsPagesTest < Minitest::Test
refute_includes last_response.body, %(<a href="/contacts/ada">)
end
end
+
+ # The fold reaches a group's own name and, through it, the group's
+ # members: the label matches folded on both sides.
+ def test_search_folds_accents_in_a_groups_name
+ with_contacts(BOOLES) do |store|
+ id = store.create_group(name: "Família")
+ store.add_member(id, "george")
+
+ get "/", q: "familia"
+
+ assert_includes last_response.body, %(<a href="/groups/#{id}">)
+ assert_includes last_response.body, %(<a href="/contacts/george">)
+ end
+ end
end