Compute initials from N, not a guess at the display name
Review feedback: Format.initials was splitting FN on whitespace to
guess at a first/last initial, when RFC 2426's N property already
carries Family/Given as structured fields. ContactFields now reads N
directly for initials, falling back to the old FN-splitting guess only
when a card has no N at all (an organization's own entry, say, which
has a name but no person to split in two).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYiigu3aFNaiTP9SyKW9mX
diff --git a/lib/pro_tacts/admin/contact_fields.rb b/lib/pro_tacts/admin/contact_fields.rb
index 45763a4..5a261d9 100644
--- a/lib/pro_tacts/admin/contact_fields.rb
+++ b/lib/pro_tacts/admin/contact_fields.rb
@@ -1,5 +1,6 @@
require "date"
+require "pro_tacts/admin/format"
require "pro_tacts/birthday"
require "pro_tacts/vcard"
require "pro_tacts/vcard/parser"
@@ -16,7 +17,7 @@ module ProTacts
Email = Data.define(:value, :type)
Address = Data.define(:lines, :type)
- attr_reader :name, :phones, :emails, :addresses, :birthday, :notes
+ attr_reader :name, :initials, :phones, :emails, :addresses, :birthday, :notes
# Lets VCard::ParseError raise rather than showing an empty card:
# a stored card that fails to parse here is not the ordinary case
@@ -36,6 +37,7 @@ module ProTacts
def initialize(properties)
by_name = properties.group_by { it.name.upcase }
@name = by_name.fetch("FN", []).first&.value
+ @initials = initials_from(by_name.fetch("N", []).first) || (@name && Format.initials(@name))
@phones = by_name.fetch("TEL", []).map { |p| Phone.new(value: VCard.unescape(p.value), type: type_of(p)) }
@emails = by_name.fetch("EMAIL", []).map { |p| Email.new(value: VCard.unescape(p.value), type: type_of(p)) }
@addresses = by_name.fetch("ADR", []).map { |p| address_from(p) }
@@ -46,6 +48,22 @@ module ProTacts
private
+ # RFC 2426 section 3.1.2: N is Family;Given;Additional;Prefixes;
+ # Suffixes. A given and family name are structured data — reading
+ # them is the real answer to "what are this contact's initials,"
+ # not guessing at word boundaries in the free-text FN the way
+ # Format.initials does. That guess is kept only as the fallback
+ # for a card with no N at all (an organization's own address-book
+ # entry, say, which has a name but no person to split in two).
+ #: (VCard::Property? property) -> String?
+ def initials_from(property)
+ return nil if property.nil?
+
+ family, given = VCard.split_components(property.value)
+ letters = [given, family].filter_map { |part| part[0] unless part.to_s.empty? }
+ letters.empty? ? nil : letters.join.upcase
+ end
+
# RFC 2426 section 3.3.1: TYPE can repeat (TYPE=work;TYPE=voice) or
# comma-list (TYPE=work,voice) — either arrives here as one
# parameter per value, so the first is the one this shows.
diff --git a/lib/pro_tacts/admin/contacts_index.rb b/lib/pro_tacts/admin/contacts_index.rb
index 43f95a6..28b587a 100644
--- a/lib/pro_tacts/admin/contacts_index.rb
+++ b/lib/pro_tacts/admin/contacts_index.rb
@@ -54,7 +54,7 @@ module ProTacts
def render_row(row)
li do
a(href: "/contacts/#{row.contact.id}") do
- span(class: "avatar") { Format.initials(row.fields.name || row.contact.id) }
+ span(class: "avatar") { row.fields.initials || Format.initials(row.contact.id) }
div(style: "flex: 1; min-width: 0;") do
div(style: "font-weight: 550;") { row.fields.name || row.contact.id }
end
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index ecca1e6..d91d196 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -29,7 +29,7 @@ module ProTacts
div(class: "card") do
div(class: "card-body") do
div(class: "detail-header") do
- span(class: "avatar", data_size: "lg") { Format.initials(@fields.name || @contact.id) }
+ span(class: "avatar", data_size: "lg") { @fields.initials || Format.initials(@contact.id) }
h1(class: "type-h2", style: "margin: 0;") { @fields.name || @contact.id }
end
# Only rendered when there's something to show: an empty
diff --git a/test/pro_tacts/admin/test_contact_fields.rb b/test/pro_tacts/admin/test_contact_fields.rb
index f5e44d9..0e68165 100644
--- a/test/pro_tacts/admin/test_contact_fields.rb
+++ b/test/pro_tacts/admin/test_contact_fields.rb
@@ -8,7 +8,7 @@ class ContactFieldsTest < Minitest::Test
ProTacts::Admin::ContactFields.from(ProTacts::Contact.for(id: "aiden", vcard:))
end
- CARD = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\n" \
+ CARD = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Ada Lovelace\r\nN:Lovelace;Ada;;;\r\n" \
"TEL;TYPE=mobile:+1-555-0100\r\nTEL;TYPE=work:+1-555-0199\r\n" \
"EMAIL;TYPE=home:ada@example.com\r\n" \
"ADR;TYPE=home:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom\r\n" \
@@ -18,6 +18,29 @@ class ContactFieldsTest < Minitest::Test
assert_equal "Ada Lovelace", fields(CARD).name
end
+ # Initials come from the structured N property (given + family),
+ # not a guess at word boundaries in the free-text FN.
+ def test_initials_come_from_the_structured_name
+ assert_equal "AL", fields(CARD).initials
+ end
+
+ # A card with no N at all — an organization's own entry, say — falls
+ # back to Format.initials' word-splitting guess on FN.
+ def test_initials_fall_back_to_the_display_name_with_no_n_property
+ no_n = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Riverside Elementary\r\nUID:office\r\nEND:VCARD\r\n"
+
+ assert_equal "RE", fields(no_n).initials
+ end
+
+ # N with only one of given/family present (a mononym, or a card that
+ # only bothered with a family name) still gives one initial rather
+ # than raising on the missing half.
+ def test_initials_from_a_partial_n_property
+ family_only = CARD.sub("N:Lovelace;Ada;;;", "N:Lovelace;;;;")
+
+ assert_equal "L", fields(family_only).initials
+ end
+
def test_reads_every_phone_with_its_type
assert_equal [["+1-555-0100", "mobile"], ["+1-555-0199", "work"]],
fields(CARD).phones.map { [it.value, it.type] }