contact, import: a group name compares without case
`item1.TEL` beside `ITEM1.X-ABLabel` read no label on the contact and
lost the label on import, while EditedContact#label_of and Merge
already paired them without case. Contact#labels_by_group and
Vcf.orphaned_groups now key a group by its lowercased name.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAiyEheRBdzbBjZUDWZUPk
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 0b99194..e1c7762 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -368,7 +368,7 @@ module ProTacts
# by default, so it names none.
Phone.new(
value:,
- label: group && labels[group],
+ label: group && labels[group.downcase],
types: property.types - %w[voice],
line:,
lent_by:,
@@ -489,7 +489,9 @@ module ProTacts
# 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").
+ # (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
@@ -500,7 +502,7 @@ module ProTacts
text = text_of(property)
next if text.nil?
- [group, text] #: [String, String]
+ [group.downcase, text] #: [String, String]
}
.to_h
end
diff --git a/lib/pro_tacts/import/vcf.rb b/lib/pro_tacts/import/vcf.rb
index e524c71..cc98d8a 100644
--- a/lib/pro_tacts/import/vcf.rb
+++ b/lib/pro_tacts/import/vcf.rb
@@ -174,7 +174,7 @@ module ProTacts
orphaned = orphaned_groups(lines)
kept, dropped = lines.partition { |line|
property = line.property
- property.nil? || (known?(property.name) && !orphaned.include?(property.group))
+ property.nil? || (known?(property.name) && !orphaned.include?(property.group&.downcase))
}
Reading.new(card: VCard.of(kept), dropped:)
end
@@ -199,7 +199,11 @@ module ProTacts
# A label is in its group without anchoring it, so a group
# holding nothing else is one with nothing but a label in it.
anchors = !property.name.casecmp?(LABEL) && known?(property.name)
- anchored[group] = anchored.fetch(group, false) || anchors
+ # Without case, as a property name compares and as
+ # EditedContact#label_of and Merge pair a label with its
+ # line: `ITEM1.X-ABLabel` names `item1.TEL`'s group.
+ key = group.downcase
+ anchored[key] = anchored.fetch(key, false) || anchors
end
anchored.reject { |_group, kept| kept }.keys
end
diff --git a/test/pro_tacts/import/test_vcf.rb b/test/pro_tacts/import/test_vcf.rb
index de25279..ca1c1f9 100644
--- a/test/pro_tacts/import/test_vcf.rb
+++ b/test/pro_tacts/import/test_vcf.rb
@@ -303,8 +303,13 @@ class ImportVcfTest < Minitest::Test
"item4.X-ABLabel:_$!<Home>!$_", "X-SOCIALPROFILE:x", "PRODID:-//Apple", "CATEGORIES:Family",
].freeze #: Array[String]
+ # Each line's group spelt in either case, as a client may write it
+ # on one line and not the next: a group name compares without case.
def card_text
- arrays(sampled_from(LINES), max_size: 8).map { |lines|
+ line = tuples(sampled_from(LINES), sampled_from(%i[itself upcase capitalize])).map { |text, spelling|
+ text.sub(/\A\w+\./) { it.public_send(spelling) }
+ }
+ arrays(line, max_size: 8).map { |lines|
["BEGIN:VCARD", "VERSION:3.0", *lines, "END:VCARD"].map { "#{it}\r\n" }.join
}
end
@@ -335,9 +340,9 @@ class ImportVcfTest < Minitest::Test
# The groups a reading's card keeps a label in, and the groups it
# keeps anything else in.
- def labeled_groups(card) = card.properties.select { it.name.casecmp?(Vcf::LABEL) }.map(&:group).uniq
+ def labeled_groups(card) = card.properties.select { it.name.casecmp?(Vcf::LABEL) }.map { it.group&.downcase }.uniq
- def anchored_groups(card) = card.properties.reject { it.name.casecmp?(Vcf::LABEL) }.filter_map(&:group).uniq
+ def anchored_groups(card) = card.properties.reject { it.name.casecmp?(Vcf::LABEL) }.filter_map { it.group&.downcase }.uniq
# A label names the line beside it, so none comes in without one.
def test_no_label_comes_in_without_a_line_to_name
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index 84368ff..3ef1a7c 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -168,6 +168,17 @@ class ContactTest < Minitest::Test
assert_equal %w[work], phones.fetch(1).types
end
+ # A group name compares without case, so a label spelt `ITEM1.` names
+ # `item1.TEL`, as the editor and the import pair them.
+ def test_a_phones_label_reads_whatever_case_its_group_is_spelt_in
+ 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",
+ )
+
+ 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
grouped = STRUCTURED.sub("TEL;TYPE=work:", "item2.TEL;TYPE=work:")