contact: every row answers its label
Contact.label_of reads the X-ABLabel beside a row, unwrapped, else
its types; Phone, Email, and Address answer it and Handle its
service. Format.type_label goes, the screens reading row.label, so
emails and addresses show their labels for the first time.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138CM3cNKnfJZ8LbDsCPRaf
diff --git a/lib/pro_tacts/admin/contact_card.rb b/lib/pro_tacts/admin/contact_card.rb
index 353d66c..5d9dc3f 100644
--- a/lib/pro_tacts/admin/contact_card.rb
+++ b/lib/pro_tacts/admin/contact_card.rb
@@ -92,16 +92,16 @@ module ProTacts
def rows
groups_row
@contact.phones.each do |phone|
- row(Format.type_label(phone.label, phone.types, "phone"), phone.value, phone.lent_by)
+ row(phone.label || "phone", phone.value, phone.lent_by)
end
@contact.emails.each do |email|
- row(Format.type_label(nil, email.types, "email"), email.value, email.lent_by)
+ row(email.label || "email", email.value, email.lent_by)
end
@contact.handles.each do |handle|
- row(Format.type_label(handle.service, [], "handle"), handle.value, handle.lent_by)
+ row(handle.label || "handle", handle.value, handle.lent_by)
end
@contact.addresses.each do |address|
- row(Format.type_label(nil, address.types, "address"), Format.address_lines(address), address.lent_by)
+ row(address.label || "address", Format.address_lines(address), address.lent_by)
end
row("birthday", @birthday) if @birthday
note_rows
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 67db4d2..0aac558 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -250,11 +250,11 @@ module ProTacts
placeholder: @own.nickname ? "removed on save" : nil)
end
@own.phones.each do |phone|
- value_row(Format.type_label(phone.label, phone.types, "phone"),
+ value_row(phone.label || "phone",
type: "tel", name: "phone[#{phone.line.digest}]", value: phone.value)
end
@own.emails.each do |email|
- value_row(Format.type_label(nil, email.types, "email"),
+ value_row(email.label || "email",
type: "email", name: "email[#{email.line.digest}]", value: email.value)
end
@own.handles.each do
@@ -528,7 +528,7 @@ module ProTacts
#: (Contact::Address address) -> void
def address_row(address)
div(class: "field", data: {blank_removes: address.po_box.nil?}) do
- span { Format.type_label(nil, address.types, "address") }
+ span { address.label || "address" }
div(class: "field-stack") do
ADDRESS_FIELDS.each do |component, label|
input(type: "text", name: "address[#{address.line.digest}][#{component}]",
diff --git a/lib/pro_tacts/admin/format.rb b/lib/pro_tacts/admin/format.rb
index d6826bd..7c11ca9 100644
--- a/lib/pro_tacts/admin/format.rb
+++ b/lib/pro_tacts/admin/format.rb
@@ -80,18 +80,6 @@ module ProTacts
].compact.reject { it.empty? }
end
- # A row's key: the label it carries, else its types, else the
- # kind of value, so no row renders unlabeled in the grid. Where
- # a row has both a label and types the label wins, which is what
- # Contacts itself shows (docs/plans/2026-09-18-phone-labels.md,
- # "Reading"); a row with no label of its own passes nil.
- #: (String? label, Array[String] types, String kind) -> String
- def self.type_label(label, types, kind)
- return label if label
-
- types.empty? ? kind : types.join(", ")
- end
-
# A value — a property's or a parameter's — at or under this many
# octets renders whole. Anything longer is a wall of folded or
# unbroken octets (an inline PHOTO payload, a memoji's binary
diff --git a/lib/pro_tacts/admin/groups_edit.rb b/lib/pro_tacts/admin/groups_edit.rb
index f71a53a..308c653 100644
--- a/lib/pro_tacts/admin/groups_edit.rb
+++ b/lib/pro_tacts/admin/groups_edit.rb
@@ -126,7 +126,7 @@ module ProTacts
#: (Contact::Address address) -> void
def address_row(address)
div(class: "field", data: {blank_removes: address.po_box.nil?}) do
- span { Format.type_label(nil, address.types, "address") }
+ span { address.label || "address" }
div(class: "field-stack") do
ContactsEdit::ADDRESS_FIELDS.each do |component, label|
input(type: "text", name: "address[#{address.line.digest}][#{component}]",
diff --git a/lib/pro_tacts/admin/groups_show.rb b/lib/pro_tacts/admin/groups_show.rb
index 828dcb0..ac06722 100644
--- a/lib/pro_tacts/admin/groups_show.rb
+++ b/lib/pro_tacts/admin/groups_show.rb
@@ -75,7 +75,7 @@ module ProTacts
#: () -> void
def rows
@reading.addresses.each do |address|
- row(Format.type_label(nil, address.types, "address")) do
+ row(address.label || "address") do
Format.address_lines(address).each { |line| div { line } }
end
end
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 95c55b6..483a128 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -121,15 +121,44 @@ module ProTacts
# here.
ADDRESS_COMPONENTS = %w[extended street locality region postal_code country].freeze #: Array[String]
- # Reopened rather than defined in the block above, for the reason
+ # The word a row's type column says: the `X-ABLabel` sharing its
+ # group, unwrapped from Apple's own vocabulary (`_$!<Other>!$_`
+ # reads `Other`), else the types the reader shows, else nil. One
+ # word to a reader, whichever of the two spellings the line carries
+ # (docs/plans/2026-10-22-a-row-edits-its-label.md, "One word, three
+ # spellings"); where a row has both the label wins, which is what
+ # Contacts itself shows (docs/plans/2026-09-18-phone-labels.md,
+ # "Reading").
+ #: (VCard::Parser::Line? label_line, Array[String] types) -> String?
+ def self.label_of(label_line, types)
+ text = label_line&.property&.text
+ return VCard.unwrap(text) unless text.nil? || text.empty?
+
+ types.join(", ") unless types.empty?
+ end
+
+ # Reopened rather than defined in the blocks above, for the reason
# VCard::Parser::Property is.
class Phone
- # The `X-ABLabel`'s text, nil for a number that has none.
#: () -> String?
- def label
- text = label_line&.property&.text
- text unless text.nil? || text.empty?
- end
+ def label = Contact.label_of(label_line, types)
+ end
+
+ class Email
+ #: () -> String?
+ def label = Contact.label_of(label_line, types)
+ end
+
+ class Address
+ #: () -> String?
+ def label = Contact.label_of(label_line, types)
+ end
+
+ class Handle
+ # A handle's word is its service, the column the record card has
+ # always shown it in.
+ #: () -> String?
+ def label = service
end
# A handle's line in Contacts.app's spelling, the service in
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 7505cbf..7df8edd 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -2063,8 +2063,8 @@ class AdminContactsPagesTest < Minitest::Test
end
# The edit screen's phone row captions render a label the same way
- # the show screen's keys do — both render that span through
- # Format.type_label — while the field keeps editing the number alone.
+ # the show screen's keys do — both read Contact::Phone#label — while
+ # the field keeps editing the number alone.
def test_the_edit_screen_captions_a_labeled_phone_with_its_label
labeled = ADA.sub("TEL;TYPE=mobile:+1-555-0100\r\n",
"item1.TEL;type=CELL:+1-555-0100\r\nitem1.X-ABLabel:Google Voice\r\n")
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index 25b87e9..950cb54 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -154,8 +154,9 @@ class ContactTest < Minitest::Test
# The label is the `X-ABLabel` sharing the phone's property group — a
# different line than the row it describes — and the types are a
- # different fact: a row can carry both
- # (docs/plans/2026-09-18-phone-labels.md).
+ # different fact: a row can carry both, and the label wins
+ # (docs/plans/2026-09-18-phone-labels.md). A row with no such line
+ # reads its types as its label.
def test_reads_a_phones_label_off_the_line_sharing_its_group
labeled = STRUCTURED.sub(
"TEL;TYPE=work:+1-555-0199\r\n",
@@ -163,7 +164,7 @@ class ContactTest < Minitest::Test
)
phones = contact(labeled).phones
- assert_nil phones.fetch(0).label
+ assert_equal "mobile", phones.fetch(0).label
assert_equal "Google Voice", phones.fetch(1).label
assert_equal %w[work], phones.fetch(1).types
end
@@ -179,10 +180,23 @@ class ContactTest < Minitest::Test
assert_equal "Google Voice", contact(labeled).phones.fetch(1).label
end
- def test_a_phone_in_a_group_with_no_labeling_line_reads_no_label
+ def test_a_phone_in_a_group_with_no_labeling_line_reads_its_types
grouped = STRUCTURED.sub("TEL;TYPE=work:", "item2.TEL;TYPE=work:")
- assert_equal [nil, nil], contact(grouped).phones.map(&:label)
+ assert_equal %w[mobile work], contact(grouped).phones.map(&:label)
+ end
+
+ # Emails and addresses read the label beside them as a phone does,
+ # and Apple's wrapped vocabulary reads as its word
+ # (docs/plans/2026-10-22-a-row-edits-its-label.md).
+ def test_an_email_and_an_address_read_their_labels
+ labeled = STRUCTURED
+ .sub(/^EMAIL[^\r]*/) { "item3.#{it}\r\nitem3.X-ABLabel:_$!<Other>!$_" }
+ .sub(/^ADR[^\r]*/) { "item4.#{it}\r\nitem4.X-ABLabel:lab" }
+ contact = contact(labeled)
+
+ assert_equal "Other", contact.emails.fetch(0).label
+ assert_equal "lab", contact.addresses.fetch(0).label
end
# Each phone carries the line it was read from — the address a save