search: fold accents when matching
A stopgap ahead of more search work.

Assisted-by: GLM-5.3 via pi
change uyyxynntorknxrkuwmnrqtmsvnpmprok
commit 96eca1c02378ae68c1972720be4e92c462a699f1
author Alpha Chen <alpha@kejadlen.dev>
date
parent 12e581e9
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