import: the thumbnail is the picture when there is no image
Two iCloud cards keep a thumbnail where the image should be;
one imported with no picture that the Mac could plainly see.

Assisted-by: Claude Opus 5 via Claude Code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
change nskztlovvoxprsskqxnuplsunzqqxnwl
commit 7a140ec1fd87f1c1b0c610dc96a33040d1c9e4b5
author Alpha Chen <alpha@kejadlen.dev>
date
parent zutrtxtn
diff --git a/docs/plans/2026-09-16-importing-from-macos.md b/docs/plans/2026-09-16-importing-from-macos.md
index b20d753..666c715 100644
--- a/docs/plans/2026-09-16-importing-from-macos.md
+++ b/docs/plans/2026-09-16-importing-from-macos.md
@@ -133,7 +133,8 @@ out again.
 
 The picture is the one field with no value of its own: `photo` says
 whether to carry what `source` holds, and the line is written from
-`imageData` with the TYPE its magic bytes name, the way Contact#photo
+`imageData`, or from `thumbnailImageData` when the framework gave no
+full image, with the TYPE its magic bytes name, the way Contact#photo
 reads one back. A `photo: true` the source cannot supply is refused.
 
 An address holds the editor's own parts — extended, street, locality,
@@ -191,9 +192,9 @@ contacts, macOS 26):
   has a unified identifier no container claims and a distinct
   `thumbnailImageData` of 723 to 333,967 bytes, and thumbnails without
   images are Monica's: 1,673 of its 1,680 cards, against 8 in the other
-  three accounts. Two of those 8 are iCloud's, and one has already
-  imported as `photo: false` over a 68,996-byte JPEG thumbnail, so
-  whether the builder falls back to `thumbnailImageData` is open.
+  three accounts. Two of those 8 are iCloud's, and one imported as
+  `photo: false` over a 68,996-byte JPEG thumbnail, so the builder
+  falls back to `thumbnailImageData` when `imageData` is nil.
 - `UID`. No card has one, so the builder adds the minted id.
 
 The reader asks for an explicit list of keys and fails if any comes back
diff --git a/lib/pro_tacts/import/card.rb b/lib/pro_tacts/import/card.rb
index 135ed09..45409ce 100644
--- a/lib/pro_tacts/import/card.rb
+++ b/lib/pro_tacts/import/card.rb
@@ -151,12 +151,23 @@ module ProTacts
 
       # 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.
+      # knows — which Card.read refuses rather than dropping. The
+      # thumbnail stands in when the framework gave no full image, which
+      # is the only picture some contacts have
+      # (docs/plans/2026-09-16-importing-from-macos.md, "Reading the
+      # Mac").
       #: () -> String?
       def picture
         return unless photo
 
-        data = source.fetch("contact")["imageData"] #: untyped
+        contact = source.fetch("contact") #: untyped
+        photo_line(contact["imageData"]) || photo_line(contact["thumbnailImageData"])
+      end
+
+      # One picture as a PHOTO line, or nil for data that is not text,
+      # not base64, or no format the server knows.
+      #: (untyped data) -> String?
+      def photo_line(data)
         return unless data.is_a?(String)
 
         bytes = begin
diff --git a/lib/pro_tacts/import/macos.rb b/lib/pro_tacts/import/macos.rb
index 04a4f03..8139ae6 100644
--- a/lib/pro_tacts/import/macos.rb
+++ b/lib/pro_tacts/import/macos.rb
@@ -140,8 +140,9 @@ module ProTacts
         source = {"identifier" => source_id, "vcard" => vcard, "note" => note, "contact" => contact}
         # Beyond the vCard, and carried from the source the card holds:
         # the note AppleScript gave up, and the picture the serializer
-        # leaves out.
-        photo = !contact.fetch("imageData").nil?
+        # leaves out. A contact whose picture came back only as a
+        # thumbnail still has one.
+        photo = !contact.fetch("imageData").nil? || !contact.fetch("thumbnailImageData").nil?
         card = Card.new(
           first:, last:, nickname:, birthday:, phones:, emails:, addresses:,
           note: noted(note, maiden_name, related_names(lines, carried)), photo:, groups: [], source:
diff --git a/test/pro_tacts/import/test_card.rb b/test/pro_tacts/import/test_card.rb
index 69e703f..70adc43 100644
--- a/test/pro_tacts/import/test_card.rb
+++ b/test/pro_tacts/import/test_card.rb
@@ -163,6 +163,22 @@ class ImportCardTest < Minitest::Test
     assert_equal "image/jpeg", card.contact("kmnuqmzxylru").photo.mime_type
   end
 
+  def test_a_thumbnail_is_the_picture_when_the_source_holds_no_image
+    jpeg = ["\xFF\xD8\xFFthumbnail".b].pack("m0")
+    card = read(ADA.sub("photo: false", "photo: true").sub("  contact: {}", "  contact:\n    imageData:\n    thumbnailImageData: #{jpeg}"))
+
+    assert_includes card.contact("kmnuqmzxylru").vcard.to_s, "PHOTO;ENCODING=b;TYPE=JPEG:#{jpeg}\r\n"
+  end
+
+  def test_the_full_image_wins_over_the_thumbnail
+    image = ["\xFF\xD8\xFFimage".b].pack("m0")
+    thumbnail = ["\xFF\xD8\xFFthumbnail".b].pack("m0")
+    card = read(ADA.sub("photo: false", "photo: true")
+      .sub("  contact: {}", "  contact:\n    imageData: #{image}\n    thumbnailImageData: #{thumbnail}"))
+
+    assert_includes card.contact("kmnuqmzxylru").vcard.to_s, "PHOTO;ENCODING=b;TYPE=JPEG:#{image}\r\n"
+  end
+
   def test_a_photo_the_source_cannot_supply_is_refused
     assert_invalid ADA.sub("photo: false", "photo: true"), "photo is true and the source holds no picture"
   end
diff --git a/test/pro_tacts/import/test_macos.rb b/test/pro_tacts/import/test_macos.rb
index b878408..1b41440 100644
--- a/test/pro_tacts/import/test_macos.rb
+++ b/test/pro_tacts/import/test_macos.rb
@@ -16,11 +16,11 @@ class ImportMacosTest < Minitest::Test
   NAME = ["N:Lovelace;Ada;;;", "FN:Ada Lovelace"].freeze
   GROUPS = ["sync:*", "import-20260916T180412Z"].freeze
 
-  def record(identifier, name: NAME, lines: [], note: nil, image: nil)
+  def record(identifier, name: NAME, lines: [], note: nil, image: nil, thumbnail: nil)
     {
       "identifier" => identifier,
       "vcard" => ["BEGIN:VCARD", "VERSION:3.0", *name, *lines, "END:VCARD", ""].join("\r\n"),
-      "contact" => {"identifier" => identifier, "imageData" => image},
+      "contact" => {"identifier" => identifier, "imageData" => image, "thumbnailImageData" => thumbnail},
       "note" => note
     }
   end
@@ -296,6 +296,17 @@ class ImportMacosTest < Minitest::Test
     end
   end
 
+  def test_a_contact_whose_picture_is_only_a_thumbnail_carries_it
+    in_tmpdir do |dir|
+      jpeg = ["\xFF\xD8\xFFthumbnail".b].pack("m0")
+      plan = Macos.plan(dir, [record("A:ABPerson", thumbnail: jpeg)], created_at: CREATED_AT)
+
+      card = plan.card(plan.contacts.first.id)
+      assert card.photo
+      assert_equal "image/jpeg", card.contact(plan.contacts.first.id).photo.mime_type
+    end
+  end
+
   def test_the_fields_this_address_book_does_not_have_are_dropped
     in_tmpdir do |dir|
       lines = [