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>
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