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
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))