store: match a book's display name in any case
Alpha or ALPHA, the same book comes through;
SQLite folds ASCII only, so the ë is left for Ruby to do.

Assisted-by: Claude Opus 5 via Claude Code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
change kuknplqswmnyknwnvpmwvumqwsqkttsw
commit bce8f633fd87b0d74f258be67abd4f96bfa93987
author Alpha Chen <alpha@kejadlen.dev>
date
parent qosnours
diff --git a/README.md b/README.md
index 3492779..a40e657 100644
--- a/README.md
+++ b/README.md
@@ -67,8 +67,9 @@ Funnel traffic, which is public, and traffic from tagged devices.
 
 Each user syncs a book of their own rather than every card: the members
 of the group `sync:*`, which everyone gets, and of `sync:<name>`, where
-the name is the user's Tailscale display name. A card a client creates
-joins its writer's group. See `docs/plans/2026-09-12-per-user-books.md`.
+the name is the user's Tailscale display name in any case. A card a
+client creates joins its writer's group. See
+`docs/plans/2026-09-12-per-user-books.md`.
 
 DAV exchanges that go wrong — a status of 400 or more other than the 401
 above, a crash, or a request that reported to Sentry — are written whole
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index a4ecad4..642aa8d 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -358,15 +358,16 @@ module ProTacts
 
     # The cards one user's client syncs: every member of `sync:*` and of
     # `sync:<name>`, each once (docs/plans/2026-09-12-per-user-books.md).
+    # The name matches in any case, folded here in Ruby because SQLite's
+    # lower() folds ASCII alone; the prefix only as #sync_name? reads it,
+    # since no other group's moves are logged.
     #: (String name) -> Set[String]
     def book(name)
-      Set.new(
-        group_members
-          .join(:groups, id: :group_id)
-          .where(Sequel[:groups][:name] => [EVERYONE, "#{SYNC_PREFIX}#{name}"])
-          .select(Sequel[:group_members][:card_id])
-          .map { it.fetch(:card_id).to_s },
-      )
+      own = "#{SYNC_PREFIX}#{name}"
+      ids = groups.where(Sequel.function(:glob, "#{SYNC_PREFIX}*", :name)).all
+        .select { |group| (label = group.fetch(:name).to_s) == EVERYONE || label.casecmp?(own) }
+        .map { it.fetch(:id) }
+      Set.new(group_members.where(group_id: ids).select_map(:card_id).map(&:to_s))
     end
 
     # The id of the card whose UID property holds this value, if one
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 21027ad..ac7cb16 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1359,6 +1359,20 @@ class StoreTest < Minitest::Test
     end
   end
 
+  # The display name in any case, beyond ASCII too; the prefix only as
+  # spelled, since a group that is no `sync:` group puts nothing in a
+  # book.
+  def test_a_book_matches_the_display_name_in_any_case
+    with_store({"aiden" => AIDEN, "znorth" => ZED, "yuki" => YUKI}) do |store|
+      FixtureData.seed_group(store, name: "sync:alpha chen", members: ["aiden"])
+      FixtureData.seed_group(store, name: "SYNC:Alpha Chen", members: ["znorth"])
+      FixtureData.seed_group(store, name: "sync:zoë chen", members: ["yuki"])
+
+      assert_equal Set["aiden"], store.book("Alpha Chen")
+      assert_equal Set["yuki"], store.book("ZOË CHEN")
+    end
+  end
+
   # The put's own entry is the card's arrival in the book, so the join
   # logs nothing beside it.
   def test_a_client_create_joins_the_writers_book