Read a phone's label off the X-ABLabel group
The reading half of docs/plans/2026-09-18-phone-labels.md; the
importer half follows.
Assisted-by: GLM-5.3 via pi
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index de2de01..34ae5ab 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -187,7 +187,7 @@ module ProTacts
# one, saving, then editing the other.
@own.phones.each do |phone|
label(class: "field", data: {blank_removes: true}) do
- span { Format.type_label(phone.types, "phone") }
+ span { Format.type_label(phone.label, phone.types, "phone") }
input(type: "tel", name: "phone[#{phone.line.digest}]",
value: phone.value, placeholder: "removed on save")
end
@@ -195,7 +195,7 @@ module ProTacts
# An email row, the phone row's own shape over EMAIL.
@own.emails.each do |email|
label(class: "field", data: {blank_removes: true}) do
- span { Format.type_label(email.types, "email") }
+ span { Format.type_label(nil, email.types, "email") }
input(type: "email", name: "email[#{email.line.digest}]",
value: email.value, placeholder: "removed on save")
end
@@ -431,7 +431,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(address.types, "address") }
+ span { Format.type_label(nil, address.types, "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/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index d6a35a7..66b0155 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -130,13 +130,13 @@ module ProTacts
def rows
groups_row
@contact.phones.each do |phone|
- row(Format.type_label(phone.types, "phone"), phone.value, phone.line)
+ row(Format.type_label(phone.label, phone.types, "phone"), phone.value, phone.line)
end
@contact.emails.each do |email|
- row(Format.type_label(email.types, "email"), email.value, email.line)
+ row(Format.type_label(nil, email.types, "email"), email.value, email.line)
end
@contact.addresses.each do |address|
- row(Format.type_label(address.types, "address"), Format.address_lines(address), address.line)
+ row(Format.type_label(nil, address.types, "address"), Format.address_lines(address), address.line)
end
row("birthday", @birthday) if @birthday
@contact.notes.each do |note|
diff --git a/lib/pro_tacts/admin/format.rb b/lib/pro_tacts/admin/format.rb
index 18e2758..3f0a7db 100644
--- a/lib/pro_tacts/admin/format.rb
+++ b/lib/pro_tacts/admin/format.rb
@@ -65,10 +65,15 @@ module ProTacts
].compact.reject { it.empty? }
end
- # A row's types as its key, or the kind of value when it has
- # none, so no row renders unlabeled in the grid.
- #: (Array[String] types, String kind) -> String
- def self.type_label(types, kind)
+ # 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
diff --git a/lib/pro_tacts/admin/groups_edit.rb b/lib/pro_tacts/admin/groups_edit.rb
index 95e12cc..7bdc52b 100644
--- a/lib/pro_tacts/admin/groups_edit.rb
+++ b/lib/pro_tacts/admin/groups_edit.rb
@@ -90,7 +90,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(address.types, "address") }
+ span { Format.type_label(nil, address.types, "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 9183380..fcc250d 100644
--- a/lib/pro_tacts/admin/groups_show.rb
+++ b/lib/pro_tacts/admin/groups_show.rb
@@ -53,7 +53,7 @@ module ProTacts
def rows
members_row
@reading.addresses.each do |address|
- row(Format.type_label(address.types, "address")) do
+ row(Format.type_label(nil, address.types, "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 f66b610..e17dcc6 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -66,6 +66,10 @@ module ProTacts
# ADR's seven components (section 3.2.1: post office box, extended
# address, street, locality, region, postal code, country — nil
# where the card left the position blank or stopped short of it).
+ # A phone also carries a `label`, the `X-ABLabel` sharing its
+ # property group — macOS's spelling of a custom label, a different
+ # fact from the types and carried beside them
+ # (docs/plans/2026-09-18-phone-labels.md, "Reading").
# Each also carries `line`, the parsed line the value was read
# from: the address a save names that row by
# (docs/plans/2026-09-05-web-card-editor.md) — the accessors fold
@@ -74,7 +78,7 @@ module ProTacts
# Data classes, whose members the inline syntax cannot read; the
# signatures live in sig/pro_tacts/contact.rbs.
# @rbs skip
- Phone = Data.define(:value, :types, :line)
+ Phone = Data.define(:value, :label, :types, :line)
# @rbs skip
Email = Data.define(:value, :types, :line)
# @rbs skip
@@ -287,11 +291,18 @@ module ProTacts
#: () -> Array[Phone]
def phones
+ labels = labels_by_group
rows("TEL") { |property, line|
value = text_of(property)
+ group = property.group
# RFC 2426 section 3.3.1: `voice` is the type every number has
# by default, so it names none.
- Phone.new(value:, types: types_of(property, except: %w[voice]), line:) if value
+ Phone.new(
+ value:,
+ label: group && labels[group],
+ types: types_of(property, except: %w[voice]),
+ line:,
+ ) if value
}
end
@@ -356,6 +367,27 @@ module ProTacts
}
end
+ # A property group to the `X-ABLabel` text anchoring it — the
+ # label a group's lines carry, on a different line than the row it
+ # describes. Read off the composed card: a group lends only bare
+ # `ADR` and `NOTE` (db/migrations/004_groups.rb), so an inherited
+ # line can never collide with a member's own `item` numbering
+ # (docs/plans/2026-09-18-phone-labels.md, "Reading").
+ #: () -> Hash[String, String]
+ def labels_by_group
+ properties
+ .filter_map { |property|
+ group = property.group
+ next if group.nil? || !property.name.casecmp?("X-ABLabel")
+
+ text = text_of(property)
+ next if text.nil?
+
+ [group, text] #: [String, String]
+ }
+ .to_h
+ end
+
# The rows a repeatable property reads as: each line naming it
# folded by the block, which answers nil for a row with nothing
# to show.
diff --git a/sig/pro_tacts/contact.rbs b/sig/pro_tacts/contact.rbs
index 717eff1..702e9ef 100644
--- a/sig/pro_tacts/contact.rbs
+++ b/sig/pro_tacts/contact.rbs
@@ -8,10 +8,11 @@ module ProTacts
class Contact
class Phone
attr_reader value: String
+ attr_reader label: String?
attr_reader types: Array[String]
attr_reader line: VCard::Parser::Line
- def self.new: (value: String, types: Array[String], line: VCard::Parser::Line) -> instance
+ def self.new: (value: String, label: String?, types: Array[String], line: VCard::Parser::Line) -> instance
end
class Email
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index c5afa6c..98937c9 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -587,6 +587,21 @@ class AdminContactsPagesTest < Minitest::Test
end
end
+ # A phone's label wins its row's key where the row carries types
+ # too — which is what Contacts itself shows
+ # (docs/plans/2026-09-18-phone-labels.md).
+ def test_show_labels_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")
+
+ with_contacts({"ada" => labeled}) do
+ get "/contacts/ada"
+
+ assert_includes last_response.body, '<dt class="type-label">Google Voice</dt>'
+ refute_includes last_response.body, '<dt class="type-label">cell</dt>'
+ end
+ end
+
# Empty attributes do not render (docs/DESIGN.md) — a bare card shows
# only the header, not a scaffold of blank rows.
def test_show_hides_attributes_the_contact_has_no_data_for
@@ -1420,6 +1435,23 @@ class AdminContactsPagesTest < Minitest::Test
end
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.
+ 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")
+
+ with_contacts({"ada" => labeled}) do |store|
+ get "/contacts/ada/edit"
+
+ digest = store.contact("ada").phones.first.line.digest
+ assert_includes last_response.body,
+ '<label class="field" data-blank-removes><span>Google Voice</span>' \
+ '<input type="tel" name="phone[' + digest + ']" value="+1-555-0100" placeholder="removed on save"></label>'
+ end
+ end
+
## Editing emails
# The email rows: the phone row's own shape over EMAIL — one value
@@ -2018,6 +2050,27 @@ class AdminContactsPagesTest < Minitest::Test
end
end
+ # A labeled phone's value edit swaps the number under the line's own
+ # header — group prefix included — so the X-ABLabel stays bound to
+ # the row it names. Removing the row is the editor's own unsolved
+ # rule, and waits for it
+ # (docs/plans/2026-09-18-phone-labels.md, "What this leaves").
+ def test_saving_a_changed_labeled_phone_keeps_its_label_bound
+ 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")
+
+ with_contacts({"ada" => labeled}) do |store|
+ digest = store.contact("ada").phones.first.line.digest
+ post "/contacts/ada", first: "Ada", last: "Lovelace", etag: store.contact("ada").etag,
+ phone: {digest => "+1-555-0150"}
+
+ assert_equal 303, last_response.status
+ card = store.contact("ada").vcard.to_s
+ assert_includes card, "item1.TEL;type=CELL:+1-555-0150\r\n"
+ assert_includes card, "item1.X-ABLabel:Google Voice\r\n"
+ end
+ end
+
# The header is the line's own, kept byte for byte: macOS writes
# three TYPE parameters on one TEL, and a save that rebuilt the
# header from a type field would drop the two the form never
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index 91ac99d..daa675c 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -133,6 +133,28 @@ class ContactTest < Minitest::Test
assert_equal %w[work fax], contact(apple).phones.fetch(1).types
end
+ # 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).
+ def test_reads_a_phones_label_off_the_line_sharing_its_group
+ labeled = STRUCTURED.sub(
+ "TEL;TYPE=work:+1-555-0199\r\n",
+ "item1.TEL;TYPE=work:+1-555-0199\r\nitem1.X-ABLabel:Google Voice\r\n",
+ )
+
+ phones = contact(labeled).phones
+ assert_nil phones.fetch(0).label
+ assert_equal "Google Voice", phones.fetch(1).label
+ assert_equal %w[work], phones.fetch(1).types
+ end
+
+ def test_a_phone_in_a_group_with_no_labeling_line_reads_no_label
+ grouped = STRUCTURED.sub("TEL;TYPE=work:", "item2.TEL;TYPE=work:")
+
+ assert_equal [nil, nil], contact(grouped).phones.map(&:label)
+ end
+
# Each phone carries the line it was read from — the address a save
# names the row by, and the bytes a value-only edit spares. The digest
# is over the served card's line; composition only inserts the BDAY