Import a phone's label beside its number
The importer half of docs/plans/2026-09-18-phone-labels.md. A plan
still to land was written before this and must be deleted and
re-planned: its cards hold the bare-string shape Card.read now refuses.

Assisted-by: GLM-5.3 via pi
change pvxlzorkpmxvosnwsxqyyzkslqyrokzv
commit 2db2752256690c1a1dc57c6932ce3658afbb1355
author Alpha Chen <alpha@kejadlen.dev>
date
parent uuxpuznk
diff --git a/lib/pro_tacts/import/card.rb b/lib/pro_tacts/import/card.rb
index 024094d..6892a24 100644
--- a/lib/pro_tacts/import/card.rb
+++ b/lib/pro_tacts/import/card.rb
@@ -4,6 +4,7 @@ require "yaml"
 require "pro_tacts/admin/card_form"
 require "pro_tacts/birthday"
 require "pro_tacts/contact"
+require "pro_tacts/vcard"
 
 module ProTacts
   module Import
@@ -32,6 +33,12 @@ module ProTacts
       # No post office box: no screen shows one either.
       ADDRESS_KEYS = %w[extended street locality region postal_code country].freeze #: Array[String]
 
+      # A phone's parts: a number, and the label the source card's
+      # X-ABLabel gave the row, left out rather than written empty
+      # (docs/plans/2026-09-18-phone-labels.md, "The plan card carries
+      # one").
+      PHONE_KEYS = %w[number label].freeze #: Array[String]
+
       BIRTHDAY = /\A(\d{4})-(\d{2})-(\d{2})\z/ #: Regexp
 
       # A picture's format as `PHOTO`'s TYPE names it, read off the
@@ -59,7 +66,7 @@ module ProTacts
         invalid.("needs a first or last name") if first.strip.empty? && last.strip.empty?
         invalid.("nickname must be text, or blank") unless nickname.nil? || nickname.is_a?(String)
         invalid.("birthday must be a quoted YYYY-MM-DD, or blank") unless birthday.nil? || birthday.is_a?(String) && birthday.match?(BIRTHDAY)
-        invalid.("phones must be a list of quoted numbers") unless texts?(phones)
+        invalid.("phones must be a list, each with a quoted number and an optional label") unless phones.is_a?(Array) && phones.all? { phone?(it) }
         invalid.("emails must be a list of addresses") unless texts?(emails)
         addresses.is_a?(Array) && addresses.all? { address?(it) } or
           invalid.("addresses must be a list, each with any of #{ADDRESS_KEYS.join(", ")}")
@@ -86,6 +93,16 @@ module ProTacts
           value.values.all?(String) && value.values.any? { !it.strip.empty? }
       end
 
+      # The shape Macos.entry writes and no other: a number is the one
+      # fact a phone cannot lack, and a label written at all cannot be
+      # blank — the builder leaves it out rather than writing it empty.
+      #: (untyped value) -> bool
+      def self.phone?(value)
+        value.is_a?(Hash) && (value.keys - PHONE_KEYS).empty? && value.values.all?(String) &&
+          !value.fetch("number", "").strip.empty? &&
+          (value["label"].nil? || !value["label"].strip.empty?)
+      end
+
       #: (untyped value) -> bool
       def self.source?(value)
         value.is_a?(Hash) && value.keys.sort == SOURCE_KEYS.sort &&
@@ -93,7 +110,7 @@ module ProTacts
           [NilClass, String].include?(value.fetch("note").class) && value.fetch("contact").is_a?(Hash)
       end
 
-      private_class_method :texts?, :address?, :source?
+      private_class_method :texts?, :address?, :phone?, :source?
 
       # The contact the web create and then the editor's add rows would
       # make of these fields. The birthday rides beside the card as the
@@ -107,14 +124,31 @@ module ProTacts
         created = Contact.new(id:, stored: Admin::CardForm.new_card(id, first, "", last), birthday: nil, inherited: [])
         stored = Admin::CardForm.contact_card(created, first, "", last, {
           "nickname" => nickname.to_s, "note" => note.to_s,
-          "new_phone" => phones, "new_email" => emails, "new_address" => addresses
+          "new_phone" => phones.filter_map { it["label"] ? nil : it.fetch("number") },
+          "new_email" => emails, "new_address" => addresses
         })
-        # The picture has no field on any form, the editor showing one
-        # rather than taking one, so its line is written here.
-        stored = stored.insert([picture].compact)
+        # The picture and a labeled phone are written here for the same
+        # reason: no form has a field for either. A label is two lines
+        # under one property group, the shape macOS keeps
+        # (docs/plans/2026-09-18-phone-labels.md, "The plan card carries
+        # one"), the numbers counting up from one on a card built from
+        # nothing — finding a free number in a card that already has
+        # groups is the editor's problem, and waits for it.
+        stored = stored.insert([picture, *labeled_phone_lines].compact)
         Contact.new(id:, stored:, birthday: born_on, inherited: [])
       end
 
+      # The labeled phones as their grouped lines: the number and its
+      # X-ABLabel under one `item` prefix each, escaped as text is.
+      #: () -> Array[String]
+      def labeled_phone_lines
+        phones.select { it["label"] }.each.with_index(1).flat_map { |phone, index|
+          group = "item#{index}"
+          ["#{group}.TEL:#{VCard.escape(phone.fetch("number"))}\r\n",
+           "#{group}.X-ABLabel:#{VCard.escape(phone.fetch("label"))}\r\n"]
+        }
+      end
+
       # The source's picture as a PHOTO line, or nil for a card that
       # carries none and for data that decodes to no format the server
       # knows — which Card.read refuses rather than dropping.
diff --git a/lib/pro_tacts/import/macos.rb b/lib/pro_tacts/import/macos.rb
index 314aeda..ae8455e 100644
--- a/lib/pro_tacts/import/macos.rb
+++ b/lib/pro_tacts/import/macos.rb
@@ -123,10 +123,17 @@ module ProTacts
         first, last = name(carried, source_id, unknown)
         nickname = only(carried, "NICKNAME", source_id, unknown)
         birthday = only(carried, "BDAY", source_id, unknown)
-        # A number the card left empty is a row Contacts kept the label
-        # of and nothing else, and an empty add row inserts nothing
-        # (Admin::CardForm).
-        phones = values(carried, "TEL").reject(&:empty?)
+        labels = labels_by_group(lines)
+        phones = properties(carried, "TEL").filter_map { |property|
+          number = property.text
+          # A number the card left empty is a row Contacts kept the
+          # label of and nothing else, and an empty add row inserts
+          # nothing (Admin::CardForm).
+          next if number.empty?
+
+          label = labels[property.group]
+          label ? {"number" => number, "label" => label} : {"number" => number} #: Hash[String, String]
+        }
         emails = values(carried, "EMAIL")
         addresses = properties(carried, "ADR").map { address(it) }
         source = {"identifier" => source_id, "vcard" => vcard, "note" => note, "contact" => contact}
@@ -158,15 +165,24 @@ module ProTacts
       # a label being dropped with the group it annotates.
       #: (Array[VCard::Parser::Line] lines, Array[VCard::Parser::Line] carried) -> Array[String]
       def self.related_names(lines, carried)
-        labels = properties(lines, "X-ABLabel").to_h {
-          [it.group, VCard.unescape(it.value).sub(/\A_\$!<(.*)>!\$_\z/, "\\1")] #: [String?, String]
-        }
+        labels = labels_by_group(lines)
         properties(carried, "X-ABRELATEDNAMES").map {
           label = labels[it.group]
           label ? "#{label}: #{it.text}" : it.text
         }
       end
 
+      # Each `item` group to the label its X-ABLabel gives it, unescaped
+      # and unwrapped of Apple's own vocabulary. The map both label
+      # readers walk — a carried TEL's label and a related name's —
+      # read off every line, an X-ABLabel never being carried itself.
+      #: (Array[VCard::Parser::Line] lines) -> Hash[String?, String]
+      def self.labels_by_group(lines)
+        properties(lines, "X-ABLabel").to_h {
+          [it.group, VCard.unwrap(VCard.unescape(it.value))] #: [String?, String]
+        }
+      end
+
       # The properties of the carried lines naming one property, in the
       # order the card wrote them.
       #: (Array[VCard::Parser::Line] lines, String name) -> Array[VCard::Parser::Property]
@@ -381,7 +397,7 @@ module ProTacts
         example.delete_suffix(":#{property.value}").sub(/\Aitem\d+\./, "item#.").delete_suffix(";type=pref")
       end
 
-      private_class_method :entry, :grouped, :carried?, :name, :noted, :related_names, :only, :properties, :values, :address, :value?, :dropped_value?, :example, :form
+      private_class_method :entry, :grouped, :carried?, :name, :noted, :related_names, :labels_by_group, :only, :properties, :values, :address, :value?, :dropped_value?, :example, :form
     end
   end
 end
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index ebc837a..d648b9a 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -42,6 +42,16 @@ module ProTacts
       text.gsub(/\\[\\;,n]/) { it == "\\n" ? "\n" : it[1..].to_s }
     end
 
+    # Takes Apple's own label wrapping back off: the vocabulary Contacts
+    # owns arrives as `_$!<Spouse>!$_`, and a person's own label as they
+    # typed it, which passes through untouched. Not vCard's syntax but
+    # a label's reading, so it lives beside the escapes both its
+    # callers reach it through.
+    #: (String text) -> String
+    def self.unwrap(text)
+      text.sub(/\A_\$!<(.*)>!\$_\z/, "\\1")
+    end
+
     # Splits a structured value's ";"-delimited components (RFC 2426
     # section 3.2.1, e.g. ADR and N) without breaking on an escaped
     # "\;" — the writer's half of the split, over the still-escaped
diff --git a/sig/pro_tacts/import.rbs b/sig/pro_tacts/import.rbs
index 190e3df..fec56e6 100644
--- a/sig/pro_tacts/import.rbs
+++ b/sig/pro_tacts/import.rbs
@@ -10,7 +10,7 @@ module ProTacts
       attr_reader last: String
       attr_reader nickname: String?
       attr_reader birthday: String?
-      attr_reader phones: Array[String]
+      attr_reader phones: Array[Hash[String, String]]
       attr_reader emails: Array[String]
       attr_reader addresses: Array[Hash[String, String]]
       attr_reader note: String?
@@ -18,7 +18,7 @@ module ProTacts
       attr_reader groups: Array[String]
       attr_reader source: Hash[String, untyped]
 
-      def self.new: (first: String, last: String, nickname: String?, birthday: String?, phones: Array[String], emails: Array[String], addresses: Array[Hash[String, String]], note: String?, photo: bool, groups: Array[String], source: Hash[String, untyped]) -> instance
+      def self.new: (first: String, last: String, nickname: String?, birthday: String?, phones: Array[Hash[String, String]], emails: Array[String], addresses: Array[Hash[String, String]], note: String?, photo: bool, groups: Array[String], source: Hash[String, untyped]) -> instance
 
       # Data#with, for the one member a plan fills in itself.
       def with: (groups: Array[String]) -> Card
diff --git a/tasks/import.rake b/tasks/import.rake
index 580f178..98bfcce 100644
--- a/tasks/import.rake
+++ b/tasks/import.rake
@@ -44,9 +44,10 @@ namespace :import do
 
       records = ProTacts::Import::Macos.read(limit:)
       plan = ProTacts::Import::Macos.plan(dir, records, created_at:)
+      phone = ->(row) { [row.fetch("number"), row["label"]].compact.join(" ") }
       plan.contacts.each do |contact|
         card = plan.card(contact.id)
-        puts "#{contact.id}  #{contact.name}#{card.phones.map { "  #{it}" }.join}"
+        puts "#{contact.id}  #{contact.name}#{card.phones.map { "  #{phone.call(it)}" }.join}"
       end
       puts "#{plan.contacts.size} contacts planned in #{dir}"
     rescue ProTacts::Import::Macos::Unknown => error
diff --git a/test/pro_tacts/import/test_card.rb b/test/pro_tacts/import/test_card.rb
index 74e1ad8..69e703f 100644
--- a/test/pro_tacts/import/test_card.rb
+++ b/test/pro_tacts/import/test_card.rb
@@ -14,7 +14,9 @@ class ImportCardTest < Minitest::Test
     nickname: The Countess
     birthday: '1815-12-10'
     phones:
-    - "+12532189075"
+    - number: "+12532189075"
+    - number: "+17863533882"
+      label: Google Voice
     emails:
     - ada@example.com
     addresses:
@@ -48,7 +50,7 @@ class ImportCardTest < Minitest::Test
   end
 
   def test_a_card_reads_its_fields
-    assert_equal Card.new(first: "Ada", last: "Lovelace", nickname: "The Countess", birthday: "1815-12-10", phones: ["+12532189075"], emails: ["ada@example.com"], addresses: [{"street" => "12 Marylebone", "locality" => "London"}], note: "An analyst.", photo: false, groups: ["sync:*", "import-20260916T180412Z"], source: SOURCE), read(ADA)
+    assert_equal Card.new(first: "Ada", last: "Lovelace", nickname: "The Countess", birthday: "1815-12-10", phones: [{"number" => "+12532189075"}, {"number" => "+17863533882", "label" => "Google Voice"}], emails: ["ada@example.com"], addresses: [{"street" => "12 Marylebone", "locality" => "London"}], note: "An analyst.", photo: false, groups: ["sync:*", "import-20260916T180412Z"], source: SOURCE), read(ADA)
   end
 
   def test_a_card_is_the_contact_the_web_create_and_add_rows_make
@@ -66,6 +68,8 @@ class ImportCardTest < Minitest::Test
       TEL:+12532189075
       EMAIL:ada@example.com
       ADR:;;12 Marylebone;London;;;
+      item1.TEL:+17863533882
+      item1.X-ABLabel:Google Voice
       BDAY:1815-12-10
       END:VCARD
     CARD
@@ -84,7 +88,7 @@ class ImportCardTest < Minitest::Test
   end
 
   def test_a_missing_field_is_refused
-    assert_invalid ADA.sub("phones:\n- \"+12532189075\"\n", ""), "has no phones"
+    assert_invalid ADA.sub("phones:\n- number: \"+12532189075\"\n- number: \"+17863533882\"\n  label: Google Voice\n", ""), "has no phones"
   end
 
   def test_a_field_no_card_takes_is_refused
@@ -92,7 +96,34 @@ class ImportCardTest < Minitest::Test
   end
 
   def test_an_unquoted_number_is_refused
-    assert_invalid ADA.sub('"+12532189075"', "+12532189075"), "phones must be a list of quoted numbers"
+    assert_invalid ADA.sub('number: "+12532189075"', "number: +12532189075"), "phones must be a list, each with a quoted number and an optional label"
+  end
+
+  # The shape a plan built before labels carried, refused rather than
+  # read leniently: no plan is re-read, but a stale one a person lands
+  # by hand is news, not a silent loss of every number
+  # (docs/plans/2026-09-18-phone-labels.md, "The plan card carries
+  # one").
+  def test_a_bare_string_phone_is_refused
+    assert_invalid ADA.sub("phones:\n- number: \"+12532189075\"\n- number: \"+17863533882\"\n  label: Google Voice\n", "phones:\n- \"+12532189075\"\n"), "phones must be a list, each with a quoted number and an optional label"
+  end
+
+  def test_a_phone_with_no_number_is_refused
+    assert_invalid ADA.sub("- number: \"+17863533882\"\n  label: Google Voice", "- number: ''\n  label: Google Voice"), "phones must be a list"
+  end
+
+  # The label the builder leaves out rather than writes empty, an edit
+  # putting back as one.
+  def test_a_blank_label_is_refused
+    assert_invalid ADA.sub("label: Google Voice", "label: ''"), "phones must be a list"
+  end
+
+  # A label is text, escaped as text is: a comma in one must not read
+  # as structure when the card is read back.
+  def test_a_label_is_escaped_into_its_line
+    labeled = ADA.sub("label: Google Voice", "label: Work, mobile")
+
+    assert_includes read(labeled).contact("kmnuqmzxylru").vcard.to_s, "item1.X-ABLabel:Work\\, mobile\r\n"
   end
 
   def test_a_name_yaml_reads_as_something_else_is_refused
diff --git a/test/pro_tacts/import/test_macos.rb b/test/pro_tacts/import/test_macos.rb
index 3136bae..597b9c2 100644
--- a/test/pro_tacts/import/test_macos.rb
+++ b/test/pro_tacts/import/test_macos.rb
@@ -101,7 +101,7 @@ class ImportMacosTest < Minitest::Test
       lines = ["TEL;type=CELL;type=VOICE;type=pref:+12532189075", "TEL;type=CELL;type=VOICE;type=pref:+12532189076"]
       plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
 
-      assert_equal ["+12532189075", "+12532189076"], plan.card(plan.contacts.first.id).phones
+      assert_equal [{"number" => "+12532189075"}, {"number" => "+12532189076"}], plan.card(plan.contacts.first.id).phones
     end
   end
 
@@ -160,7 +160,9 @@ class ImportMacosTest < Minitest::Test
   end
 
   # A work number is a number, and one Contacts labelled is too — the
-  # label going where a labelled email's and address's go.
+  # label riding the annotation rail to the row it names, where a
+  # labelled email's and address's are still dropped
+  # (docs/plans/2026-09-18-phone-labels.md).
   def test_a_work_number_and_a_labelled_one_are_phones
     in_tmpdir do |dir|
       lines = [
@@ -170,7 +172,22 @@ class ImportMacosTest < Minitest::Test
       ]
       plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
 
-      assert_equal ["+1 203-536-3941", "(206) 651-4359"], plan.card(plan.contacts.first.id).phones
+      assert_equal [{"number" => "+1 203-536-3941"}, {"number" => "(206) 651-4359", "label" => "school"}],
+        plan.card(plan.contacts.first.id).phones
+    end
+  end
+
+  # Apple's own label vocabulary arrives wrapped, and a phone's label
+  # unwraps the way a related name's does.
+  def test_a_phone_with_an_apple_label_unwraps_it
+    in_tmpdir do |dir|
+      lines = [
+        "item1.TEL;type=pref:+12532189075",
+        "item1.X-ABLabel:_$!<Other>!$_"
+      ]
+      plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
+
+      assert_equal [{"number" => "+12532189075", "label" => "Other"}], plan.card(plan.contacts.first.id).phones
     end
   end
 
@@ -185,7 +202,7 @@ class ImportMacosTest < Minitest::Test
       ]
       plan = Macos.plan(dir, records, created_at: CREATED_AT)
 
-      assert_equal [["+12532189075"], ["(206) 651-4359"]],
+      assert_equal [[{"number" => "+12532189075"}], [{"number" => "(206) 651-4359"}]],
         plan.contacts.map { plan.card(it.id).phones }
     end
   end
@@ -226,7 +243,7 @@ class ImportMacosTest < Minitest::Test
       ]
       plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
 
-      assert_equal ["+1 203-536-3941", "(206) 651-4359", "+12532189075"], plan.card(plan.contacts.first.id).phones
+      assert_equal [{"number" => "+1 203-536-3941"}, {"number" => "(206) 651-4359"}, {"number" => "+12532189075"}], plan.card(plan.contacts.first.id).phones
     end
   end
 
@@ -367,7 +384,7 @@ class ImportMacosTest < Minitest::Test
       lines = ["TEL;type=WORK;type=VOICE:+1 (425) 707-1712 X71712"]
       plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
 
-      assert_equal ["+1 (425) 707-1712 X71712"], plan.card(plan.contacts.first.id).phones
+      assert_equal [{"number" => "+1 (425) 707-1712 X71712"}], plan.card(plan.contacts.first.id).phones
     end
   end
 
diff --git a/test/pro_tacts/import/test_plan.rb b/test/pro_tacts/import/test_plan.rb
index 5ba6a5e..bac9c06 100644
--- a/test/pro_tacts/import/test_plan.rb
+++ b/test/pro_tacts/import/test_plan.rb
@@ -13,7 +13,7 @@ class ImportPlanTest < Minitest::Test
   CREATED_AT = Time.utc(2026, 9, 16, 18, 4, 12)
 
   SOURCE = {"identifier" => "A:ABPerson", "vcard" => "BEGIN:VCARD\r\nEND:VCARD\r\n", "note" => nil, "contact" => {}}
-  ADA = ProTacts::Import::Card.new(first: "Ada", last: "Lovelace", nickname: nil, birthday: nil, phones: ["+12532189075"], emails: [], addresses: [], note: nil, photo: false, groups: [], source: SOURCE)
+  ADA = ProTacts::Import::Card.new(first: "Ada", last: "Lovelace", nickname: nil, birthday: nil, phones: [{"number" => "+12532189075"}], emails: [], addresses: [], note: nil, photo: false, groups: [], source: SOURCE)
 
   def entry(id, source_id: "#{id}:ABPerson", card: ADA)
     Plan::Entry.new(id:, source_id:, card:)
@@ -66,7 +66,7 @@ class ImportPlanTest < Minitest::Test
         nickname:
         birthday:
         phones:
-        - "+12532189075"
+        - number: "+12532189075"
         emails: []
         addresses: []
         note:
diff --git a/test/pro_tacts/test_vcard.rb b/test/pro_tacts/test_vcard.rb
index d278b7b..4878ef9 100644
--- a/test/pro_tacts/test_vcard.rb
+++ b/test/pro_tacts/test_vcard.rb
@@ -35,6 +35,14 @@ class VCardTest < Minitest::Test
     assert_equal "a\nb\nc", ProTacts::VCard.unescape("a\\nb\\nc")
   end
 
+  # Apple wraps its own label vocabulary and nobody else's does; a
+  # person's own label passes through untouched.
+  def test_unwrap_takes_apples_own_label_wrapping_off
+    assert_equal "Spouse", ProTacts::VCard.unwrap("_$!<Spouse>!$_")
+    assert_equal "Google Voice", ProTacts::VCard.unwrap("Google Voice")
+    assert_equal "_$!<Spouse", ProTacts::VCard.unwrap("_$!<Spouse")
+  end
+
   def test_escape_and_unescape_round_trip
     Hegel.test do |tc|
       value = tc.draw(text(max_size: 300))