contacts edit: a removed row takes its X-ABLabel with it
Phone, Email, and Address carry the label's parsed line rather than
its text, so a blanked row removes both lines instead of leaving the
label naming nothing.
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/card_form.rb b/lib/pro_tacts/admin/card_form.rb
index e60728a..f027eaf 100644
--- a/lib/pro_tacts/admin/card_form.rb
+++ b/lib/pro_tacts/admin/card_form.rb
@@ -172,9 +172,8 @@ module ProTacts
next if submitted.nil? || submitted.strip == row.value.strip
value = submitted.strip
- vcard = vcard.substitute(
- row.line.digest,
- value.empty? ? [] : ["#{VCard.header_of(row.property)}#{VCard.escape(value)}"],
+ vcard = value.empty? ? removed(vcard, row) : vcard.substitute(
+ row.line.digest, ["#{VCard.header_of(row.property)}#{VCard.escape(value)}"],
)
end
@@ -241,7 +240,7 @@ module ProTacts
next if submitted.nil? || address_unchanged?(address, submitted)
line = address_line(address, submitted)
- vcard = vcard.substitute(address.line.digest, line ? [line] : [])
+ vcard = line ? vcard.substitute(address.line.digest, [line]) : removed(vcard, address)
end
# The add dialog names an added address by its add index
@@ -254,6 +253,17 @@ module ProTacts
vcard.insert(added.filter_map { address_line(nil, it) })
end
+ # A row's lines gone from the card: its own, and the `X-ABLabel`
+ # sharing its group, which would otherwise be left naming nothing
+ # (docs/plans/2026-10-22-a-row-edits-its-label.md, "Removing a row
+ # removes its label").
+ #: (VCard vcard, Contact::Phone | Contact::Email | Contact::Address row) -> VCard
+ def self.removed(vcard, row)
+ vcard = vcard.substitute(row.line.digest, [])
+ label = row.label_line
+ label ? vcard.substitute(label.digest, []) : vcard
+ end
+
# A field the editors post as a hash keyed by row: none where the
# form rendered no such rows, and a raise for any other shape.
# Web#card_form_params answers that shape as a 400 before a save
@@ -453,7 +463,7 @@ module ProTacts
"N:#{components.join(";")}"
end
- private_class_method :edited_values, :edited_handles, :edited_addresses, :hash_field, :address_unchanged?,
+ private_class_method :edited_values, :edited_handles, :edited_addresses, :removed, :hash_field, :address_unchanged?,
:address_line, :birthday_component, :text_lines, :edited_text, :edited_fn, :display_name,
:n_line, :company_name, :person_name, :edited_organization
end
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 87d5d02..95c55b6 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -58,9 +58,11 @@ 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's `label` is the `X-ABLabel` sharing its property group,
- # a different fact from its types
- # (docs/plans/2026-09-18-phone-labels.md, "Reading"), every
+ # A row's `label_line` is the `X-ABLabel` sharing its property
+ # group, a different fact from its types
+ # (docs/plans/2026-09-18-phone-labels.md, "Reading"), carried as
+ # the line so that a save removing the row removes its label with
+ # it (docs/plans/2026-10-22-a-row-edits-its-label.md), every
# shape's `line` is the parsed line it was read from, the address
# a save names that row by
# (docs/plans/2026-09-05-web-card-editor.md), a repeatable row's
@@ -71,9 +73,9 @@ 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, :label, :types, :line, :property, :lent_by)
+ Phone = Data.define(:value, :label_line, :types, :line, :property, :lent_by)
# @rbs skip
- Email = Data.define(:value, :types, :line, :property, :lent_by)
+ Email = Data.define(:value, :label_line, :types, :line, :property, :lent_by)
# A handle: an IMPP (RFC 6350 section 6.4.3, from RFC 4770 for
# 3.0) as Contacts.app spells one, the service its X-SERVICE-TYPE
# parameter and the value the handle, with the URI scheme it sits
@@ -105,6 +107,7 @@ module ProTacts
:postal_code,
:country,
:types,
+ :label_line,
:line,
:property,
:lent_by,
@@ -118,6 +121,17 @@ 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
+ # 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
+ end
+
# A handle's line in Contacts.app's spelling, the service in
# X-SERVICE-TYPE and the handle under `x-apple:`
# (docs/plans/2026-10-18-signal-handles.md), keeping the group and
@@ -405,15 +419,14 @@ module ProTacts
#: () -> Array[Phone]
def phones
- labels = labels_by_group
+ labels = label_lines
rows("TEL") { |property, line, lent_by|
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:,
- label: group && labels[group.downcase],
+ label_line: label_line_of(property, labels),
types: property.types - %w[voice],
line:,
property:,
@@ -424,11 +437,19 @@ module ProTacts
#: () -> Array[Email]
def emails
+ labels = label_lines
rows("EMAIL") { |property, line, lent_by|
value = text_of(property)
# RFC 2426 section 3.3.2: `internet` is the format every address
# has by default, so it names none.
- Email.new(value:, types: property.types - %w[internet], line:, property:, lent_by:) if value
+ Email.new(
+ value:,
+ label_line: label_line_of(property, labels),
+ types: property.types - %w[internet],
+ line:,
+ property:,
+ lent_by:,
+ ) if value
}
end
@@ -447,7 +468,10 @@ module ProTacts
#: () -> Array[Address]
def addresses
- rows("ADR") { |property, line, lent_by| address_of(property, line:, lent_by:) }
+ labels = label_lines
+ rows("ADR") { |property, line, lent_by|
+ address_of(property, label_line: label_line_of(property, labels), line:, lent_by:)
+ }
end
# The card's NOTEs (RFC 2426 section 3.6.2), in text form. On the
@@ -549,29 +573,36 @@ module ProTacts
"NOTE:#{VCard.escape(NoteSections.join(member, sections))}"
end
- # A property group to the `X-ABLabel` text anchoring it — the
+ # A property group to the `X-ABLabel` line 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"). Keyed
# lowercased, a group name comparing without case as a property
- # name does and as EditedContact#label_of pairs them.
- #: () -> Hash[String, String]
- def labels_by_group
- properties
- .filter_map { |property|
- group = property.group
- next if group.nil? || !property.name.casecmp?("X-ABLabel")
+ # name does and as EditedContact#label_of pairs them. A label with
+ # no text labels nothing, and is left out.
+ #: () -> Hash[String, VCard::Parser::Line]
+ def label_lines
+ vcard.lines
+ .filter_map { |line|
+ property = line.property
+ next if property.nil? || !property.name.casecmp?("X-ABLabel")
- text = text_of(property)
- next if text.nil?
+ group = property.group
+ next if group.nil? || text_of(property).nil?
- [group.downcase, text] #: [String, String]
+ [group.downcase, line] #: [String, VCard::Parser::Line]
}
.to_h
end
+ #: (VCard::Parser::Property property, Hash[String, VCard::Parser::Line] labels) -> VCard::Parser::Line?
+ def label_line_of(property, labels)
+ group = property.group
+ labels[group.downcase] if group
+ 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 — and told which group lends the line, the provenance a
@@ -610,13 +641,14 @@ module ProTacts
# as no address at all. Carries the line it was read from and its
# reading, the other provenance-carrying shapes' own address for a
# save.
- #: (VCard::Parser::Property property, line: VCard::Parser::Line, lent_by: Group?) -> Address?
- def address_of(property, line:, lent_by:)
+ #: (VCard::Parser::Property property, label_line: VCard::Parser::Line?, line: VCard::Parser::Line, lent_by: Group?) -> Address?
+ def address_of(property, label_line:, line:, lent_by:)
components = components_of(property)
return if components.none?
po_box, extended, street, locality, region, postal_code, country = components
- Address.new(po_box:, extended:, street:, locality:, region:, postal_code:, country:, types: property.types, line:, property:, lent_by:)
+ Address.new(po_box:, extended:, street:, locality:, region:, postal_code:, country:, types: property.types, label_line:, line:,
+ property:, lent_by:)
end
end
diff --git a/sig/pro_tacts/contact.rbs b/sig/pro_tacts/contact.rbs
index c16405c..8b71b91 100644
--- a/sig/pro_tacts/contact.rbs
+++ b/sig/pro_tacts/contact.rbs
@@ -7,23 +7,24 @@ module ProTacts
class Contact
class Phone
attr_reader value: String
- attr_reader label: String?
+ attr_reader label_line: VCard::Parser::Line?
attr_reader types: Array[String]
attr_reader line: VCard::Parser::Line
attr_reader property: VCard::Parser::Property
attr_reader lent_by: Group?
- def self.new: (value: String, label: String?, types: Array[String], line: VCard::Parser::Line, property: VCard::Parser::Property, lent_by: Group?) -> instance
+ def self.new: (value: String, label_line: VCard::Parser::Line?, types: Array[String], line: VCard::Parser::Line, property: VCard::Parser::Property, lent_by: Group?) -> instance
end
class Email
attr_reader value: String
+ attr_reader label_line: VCard::Parser::Line?
attr_reader types: Array[String]
attr_reader line: VCard::Parser::Line
attr_reader property: VCard::Parser::Property
attr_reader lent_by: Group?
- def self.new: (value: String, types: Array[String], line: VCard::Parser::Line, property: VCard::Parser::Property, lent_by: Group?) -> instance
+ def self.new: (value: String, label_line: VCard::Parser::Line?, types: Array[String], line: VCard::Parser::Line, property: VCard::Parser::Property, lent_by: Group?) -> instance
end
class Handle
@@ -68,6 +69,7 @@ module ProTacts
attr_reader postal_code: String?
attr_reader country: String?
attr_reader types: Array[String]
+ attr_reader label_line: VCard::Parser::Line?
attr_reader line: VCard::Parser::Line
attr_reader property: VCard::Parser::Property
attr_reader lent_by: Group?
@@ -81,6 +83,7 @@ module ProTacts
postal_code: String?,
country: String?,
types: Array[String],
+ label_line: VCard::Parser::Line?,
line: VCard::Parser::Line,
property: VCard::Parser::Property,
lent_by: Group?
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index edc0a06..7505cbf 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -2079,6 +2079,25 @@ class AdminContactsPagesTest < Minitest::Test
end
end
+ # A labeled number is two lines, and blanking it removes both: the
+ # label left behind would name nothing, and the next line Contacts
+ # files under its group would wear it.
+ def test_a_blank_labeled_phone_removes_its_label_too
+ 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", company: "0", etag: store.contact("ada").etag,
+ phone: {digest => ""}
+
+ assert_equal 303, last_response.status
+ card = store.contact("ada").vcard.to_s
+ refute_includes card, "TEL"
+ refute_includes card, "X-ABLabel"
+ end
+ end
+
## Editing emails
# The email rows: the phone row's own shape over EMAIL — one value