import: carry what a real Mac's cards actually hold
An address with no group, a work line, a number Contacts named;
TITLE and ORG leave with URL — no field here to be claimed.
An EMAIL with no @ is no address at all, so it drops;
a number keeps the extension typed behind it, and the refusing stops.
Assisted-by: Claude Opus 5 via Claude Code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
diff --git a/lib/pro_tacts/import/macos.rb b/lib/pro_tacts/import/macos.rb
index 5a8b459..04982c8 100644
--- a/lib/pro_tacts/import/macos.rb
+++ b/lib/pro_tacts/import/macos.rb
@@ -254,7 +254,7 @@ module ProTacts
refused << form unless FORMS.fetch(name).include?(form)
refused << "#{name} value" unless value?(name, property)
refused.each { unknown.add(it, source_id, example) }
- refused.empty?
+ refused.empty? && !dropped_value?(name, property)
else
unknown.add(name, source_id, example)
false
@@ -301,7 +301,9 @@ module ProTacts
"TEL",
"TEL;type=CELL;type=VOICE",
"TEL;type=HOME;type=VOICE",
- "TEL;type=IPHONE;type=CELL;type=VOICE"
+ "TEL;type=IPHONE;type=CELL;type=VOICE",
+ "TEL;type=WORK;type=VOICE",
+ "item#.TEL"
],
"EMAIL" => [
"EMAIL;type=INTERNET",
@@ -309,7 +311,7 @@ module ProTacts
"EMAIL;type=INTERNET;type=WORK",
"item#.EMAIL;type=INTERNET"
],
- "ADR" => ["item#.ADR;type=HOME"],
+ "ADR" => ["ADR;type=HOME", "item#.ADR;type=HOME"],
"X-ABRELATEDNAMES" => ["item#.X-ABRELATEDNAMES"]
}.freeze #: Hash[String, Array[String]]
@@ -318,10 +320,16 @@ module ProTacts
# empty value is a row with no number in it at all.
PHONE = /\A[0-9 +().-]*\z/ #: Regexp
+ # A number with the extension a person wrote after it, "+1 (425)
+ # 707-1712 X71712" as one line. Digits have to come first: a value
+ # that is an extension and nothing else ("ext. 4") labels a row
+ # rather than filling it, and stays refused.
+ EXTENSION = /\A[0-9 +().-]*[0-9][0-9 +().-]*(?:x|ext)\.?\s*[0-9]+\z/i #: Regexp
+
# Properties read and thrown away, whatever form they take: this
# address book has no field for any of them, and a card's `source`
# still holds the line.
- DROPPED = %w[IMPP URL X-SOCIALPROFILE X-APPLE-SUBADMINISTRATIVEAREA X-AIM].freeze #: Array[String]
+ DROPPED = %w[IMPP ORG TITLE URL X-SOCIALPROFILE X-APPLE-SUBADMINISTRATIVEAREA X-AIM].freeze #: Array[String]
# Properties that say something about the line sharing their group
# rather than about the contact: a label, and the country code
@@ -329,20 +337,34 @@ module ProTacts
ANNOTATIONS = %w[X-ABLABEL X-ABADR].freeze #: Array[String]
# Whether a value is one its field can hold: a phone is a `+` and
- # digits, a birthday a whole date, an address the seven components
- # RFC 2426 section 3.2.1 gives it with no post office box, since no
- # field carries one. A name is any text.
+ # digits, with or without an extension, a birthday a whole date,
+ # an address the seven components RFC 2426 section 3.2.1 gives it
+ # with no post office box, since no field carries one. A name is
+ # any text, and an email is asked nothing here — see
+ # #dropped_value?.
#: (String name, VCard::Parser::Property property) -> bool
def self.value?(name, property)
case name
- when "TEL" then property.value.match?(PHONE)
+ when "TEL" then property.value.match?(PHONE) || property.value.match?(EXTENSION)
when "BDAY" then property.value.match?(Card::BIRTHDAY)
- when "EMAIL" then property.value.include?("@")
when "ADR" then property.components.size == 7 && property.components.fetch(0).empty?
else true
end
end
+ # A value its field has no use for, dropped rather than refused.
+ # An EMAIL without an `@` is not an address at all — Exchange
+ # writes a directory name into one
+ # (`/O=microsoft/OU=.../cn=algersha`) — so it is not a spelling to
+ # learn but a row this address book has no field for, and it goes
+ # the way the DROPPED properties go, the source keeping the line.
+ # The form is still checked around this, so a value like it in a
+ # spelling nobody has seen is reported like any other.
+ #: (String name, VCard::Parser::Property property) -> bool
+ def self.dropped_value?(name, property)
+ name == "EMAIL" && !property.value.include?("@")
+ end
+
# A line unfolded, without its terminator.
#: (VCard::Parser::Line line) -> String
def self.example(line)
@@ -355,7 +377,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?, :example, :form
+ private_class_method :entry, :grouped, :carried?, :name, :noted, :related_names, :only, :properties, :values, :address, :value?, :dropped_value?, :example, :form
end
end
end
diff --git a/test/pro_tacts/import/test_macos.rb b/test/pro_tacts/import/test_macos.rb
index 4a28044..7827dc8 100644
--- a/test/pro_tacts/import/test_macos.rb
+++ b/test/pro_tacts/import/test_macos.rb
@@ -60,7 +60,7 @@ class ImportMacosTest < Minitest::Test
def test_every_unknown_field_is_named_and_no_plan_is_written
in_tmpdir do |dir|
records = [
- record("A:ABPerson", lines: ["ORG:Analytical Engines;", "EMAIL:ada@example.com"], note: "Analyst."),
+ record("A:ABPerson", lines: ["ROLE:Analyst", "EMAIL:ada@example.com"], note: "Analyst."),
record("B:ABPerson", lines: ["EMAIL:mary@example.com"], image: "/9j/")
]
@@ -71,8 +71,8 @@ class ImportMacosTest < Minitest::Test
EMAIL: 2
A:ABPerson: "EMAIL:ada@example.com"
B:ABPerson: "EMAIL:mary@example.com"
- ORG: 1
- A:ABPerson: "ORG:Analytical Engines;"
+ ROLE: 1
+ A:ABPerson: "ROLE:Analyst"
MESSAGE
refute dir.exist?
end
@@ -147,6 +147,47 @@ class ImportMacosTest < Minitest::Test
end
end
+ # Contacts writes an address ungrouped when nothing labels it, and
+ # grouped when something does; both are the one field.
+ def test_an_ungrouped_address_is_an_address
+ in_tmpdir do |dir|
+ lines = ["ADR;type=HOME:;;12 Marylebone Rd;London;;NW1 5LS;England"]
+ plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
+
+ assert_equal [{"street" => "12 Marylebone Rd", "locality" => "London", "postal_code" => "NW1 5LS", "country" => "England"}],
+ plan.card(plan.contacts.first.id).addresses
+ end
+ 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.
+ def test_a_work_number_and_a_labelled_one_are_phones
+ in_tmpdir do |dir|
+ lines = [
+ "TEL;type=WORK;type=VOICE:+1 203-536-3941",
+ "item1.TEL;type=pref:(206) 651-4359",
+ "item1.X-ABLabel:school"
+ ]
+ 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
+ end
+ end
+
+ # Where someone works has no field in this address book; the source
+ # keeps the lines the card drops.
+ def test_a_job_title_and_an_organization_are_dropped
+ in_tmpdir do |dir|
+ lines = ["TITLE:Countess of Lovelace", "ORG:Analytical Engine Co.;"]
+ plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
+
+ card = plan.card(plan.contacts.first.id)
+ assert_equal [[], [], []], [card.phones, card.emails, card.addresses]
+ assert_includes card.source.fetch("vcard"), "TITLE:Countess of Lovelace"
+ assert_includes card.source.fetch("vcard"), "ORG:Analytical Engine Co.;"
+ end
+ end
+
def test_a_second_address_under_another_item_number_is_one_form
in_tmpdir do |dir|
lines = [
@@ -266,7 +307,6 @@ class ImportMacosTest < Minitest::Test
in_tmpdir do |dir|
records = [
record("A:ABPerson", lines: ["BDAY:--12-10"]),
- record("B:ABPerson", lines: ["EMAIL;type=INTERNET:ada"]),
record("C:ABPerson", lines: ["item1.ADR;type=HOME:P.O. Box 4;;;London;;;"]),
record("D:ABPerson", lines: ["item1.ADR;type=HOME:;;Ockham Park;Surrey"]),
record("E:ABPerson", lines: ["BDAY:1815-12-10", "BDAY:1815-12-11"])
@@ -283,12 +323,37 @@ class ImportMacosTest < Minitest::Test
E:ABPerson: "BDAY:1815-12-10 / BDAY:1815-12-11"
BDAY value: 1
A:ABPerson: "BDAY:--12-10"
- EMAIL value: 1
- B:ABPerson: "EMAIL;type=INTERNET:ada"
MESSAGE
end
end
+ # An address book has no field for a directory name, and Exchange
+ # writes one into an EMAIL. It leaves with the IMPPs rather than
+ # stopping the plan, and the source keeps the line.
+ def test_an_email_that_is_not_an_address_is_dropped
+ in_tmpdir do |dir|
+ lines = [
+ "item2.EMAIL;type=INTERNET:/O=microsoft/OU=northamerica/cn=Recipients/cn=algersha",
+ "EMAIL;type=INTERNET:ada@example.com"
+ ]
+ plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
+
+ card = plan.card(plan.contacts.first.id)
+ assert_equal ["ada@example.com"], card.emails
+ assert_includes card.source.fetch("vcard"), "cn=algersha"
+ end
+ end
+
+ # An extension is part of the number as a person typed it.
+ def test_a_number_carries_the_extension_written_after_it
+ in_tmpdir do |dir|
+ 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
+ end
+ end
+
def test_a_name_beyond_first_and_last_is_refused
in_tmpdir do |dir|
records = [
@@ -347,7 +412,6 @@ class ImportMacosTest < Minitest::Test
in_tmpdir do |dir|
records = [
record("A:ABPerson", lines: ["TEL;type=MAIN:+12532189075"]),
- record("B:ABPerson", lines: ["TEL;type=WORK;type=VOICE:+12532189075"]),
record("C:ABPerson", lines: ["item1.TEL;type=CELL;type=VOICE;type=pref:+12532189075"]),
record("D:ABPerson", lines: ["TEL;type=CELL;type=VOICE;type=pref:ext. 4"]),
record("E:ABPerson", name: ["N:Lovelace;Ada;;;", "FN;CHARSET=utf-8:Ada Lovelace"])
@@ -365,8 +429,6 @@ class ImportMacosTest < Minitest::Test
D:ABPerson: "TEL;type=CELL;type=VOICE;type=pref:ext. 4"
TEL;type=MAIN: 1
A:ABPerson: "TEL;type=MAIN:+12532189075"
- TEL;type=WORK;type=VOICE: 1
- B:ABPerson: "TEL;type=WORK;type=VOICE:+12532189075"
item#.TEL;type=CELL;type=VOICE: 1
C:ABPerson: "item1.TEL;type=CELL;type=VOICE;type=pref:+12532189075"
MESSAGE