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>
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 = [