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>
change qlvrkvnwozvurlppuxyqmryoyqpnxoly
commit 9917047d9213f57a132eceea55c0d437438d0a89
author Alpha Chen <alpha@kejadlen.dev>
date
parent vwnxuknw
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