import: plan from macOS iCloud contacts with an empty builder
The reader takes iCloud cards unmerged, under ids AppleScript can name,
so the notes the framework hides come back through AppleScript all the same.
The builder knows no property yet, so every field it meets is named and refused,
and a branch gets written only once a real card shows what it is used for.
Assisted-by: Claude Opus 5 via Claude Code
Issue: rtqvmzvp
diff --git a/AGENTS.md b/AGENTS.md
index eb4a59a..516eb68 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -24,6 +24,8 @@ rake db:dump # Write the cards, birthdays, and groups to data/dump
rake dev # Dev server, seeded from test/fixtures/cards into a
# throwaway tmpdir on every start, reloading on change
# (needs fd and entr)
+rake import:macos:plan # Plan importing this Mac's iCloud contacts
+ # (LIMIT=n; reads Contacts, so leave it to the user)
rake import:execute # Land an import plan on a host
# (PLAN=dir HOST=https://contacts)
rake profile:install # Download carddav.mobileconfig from the app and
diff --git a/Steepfile b/Steepfile
index 82cb478..69108b3 100644
--- a/Steepfile
+++ b/Steepfile
@@ -26,5 +26,5 @@ target :lib do
].map { "lib/pro_tacts/admin/#{it}.rb" }, inline: true)
signature "sig"
- library "date", "digest", "fileutils", "json", "logger", "net-http", "pathname", "securerandom", "strscan", "time", "uri", "yaml"
+ library "date", "digest", "fileutils", "json", "logger", "net-http", "open3", "pathname", "securerandom", "strscan", "time", "uri", "yaml"
end
diff --git a/docs/plans/2026-09-16-importing-from-macos.md b/docs/plans/2026-09-16-importing-from-macos.md
index 2e8cae3..332be67 100644
--- a/docs/plans/2026-09-16-importing-from-macos.md
+++ b/docs/plans/2026-09-16-importing-from-macos.md
@@ -87,7 +87,20 @@ usually a different one.
## Reading the Mac
`script/macos-contacts.swift` reads through Contacts.framework, and TCC
-grants access to whatever terminal runs it. It serializes each contact
+grants access to whatever terminal runs it.
+
+It reads the iCloud account alone, card by card. This Mac's Contacts
+holds four accounts: iCloud (443 cards), Monica (1,679), and two
+pro-tacts accounts, the deployment and the dev server. By default the
+framework merges cards linked across accounts into one contact under an
+identifier no card has; 344 of the 1,801 contacts it returned were
+merges. AppleScript knows no such identifier, so a merge's note cannot
+be read, and a delete could not name it. Reading the iCloud container
+with `unifyResults` off gives each card under the identifier AppleScript
+uses, and keeps Monica's cards for the Monica importer and pro-tacts
+from importing itself. The reader still fails on any card AppleScript
+does not list.
+ It serializes each contact
with `CNContactVCardSerialization`, which leaves out three things on this
Mac with no error for any of them (measured 2026-09-16 over 1,799
contacts, macOS 26):
@@ -112,7 +125,11 @@ with image data in base64, and the AppleScript note.
## The builder knows only what it was taught
The macOS builder turns each line into a card line through a `case` on
-the property name, whose `else` records the line as unknown. Each branch
+the property name, whose `else` records the line as unknown. The envelope
+is not carried but written around the lines, so the minted `UID` goes
+inside it; a `VERSION` other than 3.0 is unknown. The note and the image
+data, which the vCard leaves out, are unknown until a branch takes them
+too. Each branch
names the parameters it accepts, and a parameter outside that list is
unknown too. Once every contact in the batch is read, any unknown fails
the build with every unknown name, its count, and a few source ids, and
diff --git a/lib/pro_tacts/import/macos.rb b/lib/pro_tacts/import/macos.rb
new file mode 100644
index 0000000..e2cbab8
--- /dev/null
+++ b/lib/pro_tacts/import/macos.rb
@@ -0,0 +1,118 @@
+require "json"
+require "open3"
+require "pathname"
+
+require "pro_tacts/change_id"
+require "pro_tacts/import/plan"
+require "pro_tacts/vcard/parser"
+
+module ProTacts
+ module Import
+ # A plan from this Mac's iCloud contacts, read by
+ # script/macos-contacts.swift. The builder knows only the fields it has
+ # been taught, and refuses the rest
+ # (docs/plans/2026-09-16-importing-from-macos.md, "The builder knows
+ # only what it was taught").
+ module Macos
+ SCRIPT = Pathname.new(__FILE__).dirname.join("../../../script/macos-contacts.swift").expand_path #: Pathname
+
+ # Every field no branch handles, across every contact read, so one
+ # build names all of them.
+ class Unknown < StandardError
+ # @rbs @seen: Hash[String, Array[String]]
+
+ EXAMPLES = 3 #: Integer
+
+ #: () -> void
+ def initialize
+ @seen = Hash.new { |seen, field| seen[field] = [] }
+ super
+ end
+
+ #: (String field, String source_id) -> void
+ def add(field, source_id)
+ @seen[field] << source_id
+ end
+
+ #: () -> bool
+ def any? = !@seen.empty?
+
+ #: () -> String
+ def message
+ lines = @seen.sort.map do |field, source_ids|
+ " #{field}: #{source_ids.size}, e.g. #{source_ids.uniq.first(EXAMPLES).join(", ")}"
+ end
+ "no branch handles these fields, so no plan was written:\n#{lines.join("\n")}"
+ end
+ end
+
+ # The reader's output, one parsed object per contact.
+ #: (limit: Integer?) -> Array[Hash[String, untyped]]
+ def self.read(limit:)
+ command = ["swift", SCRIPT.to_s, "read"]
+ command << limit.to_s if limit
+ output, status = Open3.capture2(*command)
+ raise "#{SCRIPT.basename} exited #{status.exitstatus}" unless status.success?
+
+ output.each_line.map { JSON.parse(it) }
+ end
+
+ #: (Pathname dir, Array[Hash[String, untyped]] records, created_at: Time) -> Plan
+ def self.plan(dir, records, created_at:)
+ unknown = Unknown.new
+ entries = records.map { entry(it, unknown) }
+ raise unknown if unknown.any?
+
+ Plan.write(dir, source: "macos", created_at:, entries:)
+ end
+
+ #: (Hash[String, untyped] record, Unknown unknown) -> Plan::Entry
+ def self.entry(record, unknown)
+ id = ChangeId.mint(12)
+ source_id = record.fetch("identifier") #: String
+ vcard = record.fetch("vcard") #: String
+ note = record.fetch("note") #: String?
+ contact = record.fetch("contact") #: Hash[String, untyped]
+
+ body = VCard::Parser.lines(vcard).filter_map { line(it, source_id, unknown) }
+ # Beyond the vCard: what the serializer leaves out.
+ unknown.add("note", source_id) if note
+ unknown.add("imageData", source_id) if contact.fetch("imageData")
+
+ backup = {"original.vcf" => vcard, "contact.json" => JSON.pretty_generate(contact)}
+ backup["note.txt"] = note if note
+
+ Plan::Entry.new(
+ id:, source_id:,
+ card: "BEGIN:VCARD\r\nVERSION:3.0\r\n#{body.join}UID:#{id}\r\nEND:VCARD\r\n",
+ backup:
+ )
+ end
+
+ # A line of the source card as a line of the imported one, or nil for
+ # one it does not carry. The envelope is written around the lines
+ # rather than carried, so the card's UID can go inside it.
+ #: (VCard::Parser::Line line, String source_id, Unknown unknown) -> String?
+ def self.line(line, source_id, unknown)
+ property = line.property
+ if property.nil?
+ unknown.add("unreadable line", source_id) unless line.verbatim.strip.empty?
+ return
+ end
+
+ case property.name.upcase
+ when "BEGIN", "END"
+ nil
+ when "VERSION"
+ unknown.add("VERSION:#{property.value}", source_id) unless property.value == "3.0"
+ nil
+ else
+ unknown.add(property.name.upcase, source_id)
+ nil
+ end
+ end
+
+ private_class_method :entry, :line
+ end
+ end
+end
diff --git a/script/macos-contacts.swift b/script/macos-contacts.swift
new file mode 100644
index 0000000..0c009f9
--- /dev/null
+++ b/script/macos-contacts.swift
@@ -0,0 +1,158 @@
+// Reads this Mac's Contacts for an import plan
+// (docs/plans/2026-09-16-importing-from-macos.md, "Reading the Mac").
+//
+// swift script/macos-contacts.swift read [limit]
+//
+// prints one JSON object per person in the iCloud account, companies
+// skipped, in Contacts.app's own order: the identifier, the framework's
+// vCard, every fetched key, and the note, which only AppleScript will give
+// up. Cards are read unmerged, so a contact linked to a card in another
+// account (Monica, or pro-tacts itself) is this account's card alone.
+
+import Contacts
+import Foundation
+
+func fail(_ message: String) -> Never {
+ FileHandle.standardError.write(Data("macos-contacts: \(message)\n".utf8))
+ exit(1)
+}
+
+// Every key but the note, which the framework strips from an unentitled
+// fetch without an error.
+let keys: [String] = [
+ CNContactIdentifierKey, CNContactTypeKey,
+ CNContactNamePrefixKey, CNContactGivenNameKey, CNContactMiddleNameKey, CNContactFamilyNameKey,
+ CNContactPreviousFamilyNameKey, CNContactNameSuffixKey, CNContactNicknameKey,
+ CNContactPhoneticGivenNameKey, CNContactPhoneticMiddleNameKey, CNContactPhoneticFamilyNameKey,
+ CNContactOrganizationNameKey, CNContactDepartmentNameKey, CNContactJobTitleKey,
+ CNContactPhoneticOrganizationNameKey,
+ CNContactBirthdayKey, CNContactNonGregorianBirthdayKey, CNContactDatesKey,
+ CNContactImageDataKey, CNContactThumbnailImageDataKey, CNContactImageDataAvailableKey,
+ CNContactPhoneNumbersKey, CNContactEmailAddressesKey, CNContactPostalAddressesKey,
+ CNContactUrlAddressesKey, CNContactRelationsKey, CNContactSocialProfilesKey,
+ CNContactInstantMessageAddressesKey,
+]
+
+func json(_ value: Any?) -> Any {
+ switch value {
+ case nil: return NSNull()
+ case let string as String: return string
+ case let data as Data: return data.base64EncodedString()
+ case let number as NSNumber: return number
+ case let components as DateComponents:
+ var fields: [String: Any] = [:]
+ if let calendar = components.calendar { fields["calendar"] = calendar.identifier.debugDescription }
+ if let era = components.era { fields["era"] = era }
+ if let year = components.year { fields["year"] = year }
+ if let month = components.month { fields["month"] = month }
+ if let day = components.day { fields["day"] = day }
+ if let leap = components.isLeapMonth { fields["leap_month"] = leap }
+ return fields
+ // A CNLabeledValue's type parameter is an Objective-C lightweight
+ // generic, so its parts are read by key rather than through a cast.
+ case let labeled as [NSObject] where labeled.allSatisfy({ $0 is CNLabeledValue<NSString> }):
+ return labeled.map {
+ ["identifier": json($0.value(forKey: "identifier")), "label": json($0.value(forKey: "label")), "value": json($0.value(forKey: "value"))]
+ }
+ case let phone as CNPhoneNumber: return phone.stringValue
+ case let address as CNPostalAddress:
+ return [
+ "street": address.street, "sub_locality": address.subLocality, "city": address.city,
+ "sub_administrative_area": address.subAdministrativeArea, "state": address.state,
+ "postal_code": address.postalCode, "country": address.country, "iso_country_code": address.isoCountryCode,
+ ]
+ case let relation as CNContactRelation: return relation.name
+ case let profile as CNSocialProfile:
+ return [
+ "url": profile.urlString, "username": profile.username,
+ "user_identifier": profile.userIdentifier, "service": profile.service,
+ ]
+ case let im as CNInstantMessageAddress: return ["username": im.username, "service": im.service]
+ default: fail("no JSON spelling for a \(type(of: value!))")
+ }
+}
+
+// Every person's note by identifier, in one Apple event, and every
+// identifier AppleScript knows, so a contact it spells differently fails
+// rather than reading as one with no note.
+func notes() -> (ids: Set<String>, notes: [String: String]) {
+ let source = "tell application \"Contacts\" to get {id, note} of every person"
+ var error: NSDictionary?
+ guard let result = NSAppleScript(source: source)?.executeAndReturnError(&error) else {
+ fail("reading notes through AppleScript: \(error ?? [:])")
+ }
+ guard let ids = result.atIndex(1), let notes = result.atIndex(2), ids.numberOfItems == notes.numberOfItems else {
+ fail("AppleScript answered notes in an unexpected shape")
+ }
+ var known: Set<String> = []
+ var byId: [String: String] = [:]
+ if ids.numberOfItems > 0 {
+ for index in 1...ids.numberOfItems {
+ guard let id = ids.atIndex(index)?.stringValue else { fail("a person with no id") }
+ known.insert(id)
+ if let note = notes.atIndex(index)?.stringValue { byId[id] = note }
+ }
+ }
+ return (known, byId)
+}
+
+let arguments = CommandLine.arguments.dropFirst()
+guard arguments.first == "read" else { fail("usage: macos-contacts.swift read [limit]") }
+var limit: Int? = nil
+if arguments.count > 1 {
+ guard let n = Int(arguments[arguments.startIndex + 1]), n > 0 else { fail("a limit is a positive number") }
+ limit = n
+}
+
+let store = CNContactStore()
+let icloud: [CNContainer]
+do {
+ icloud = try store.containers(matching: nil).filter { $0.name == "iCloud" }
+} catch {
+ fail("listing accounts: \(error)")
+}
+guard icloud.count == 1 else { fail("found \(icloud.count) accounts named iCloud, and reads exactly one") }
+
+let request = CNContactFetchRequest(
+ keysToFetch: keys.map { $0 as NSString } + [CNContactVCardSerialization.descriptorForRequiredKeys()])
+request.predicate = CNContact.predicateForContactsInContainer(withIdentifier: icloud[0].identifier)
+// Merged contacts carry identifiers no card has, which AppleScript cannot
+// find and a delete could not name.
+request.unifyResults = false
+request.sortOrder = .userDefault
+
+var people: [CNContact] = []
+do {
+ try store.enumerateContacts(with: request) { contact, stop in
+ guard contact.contactType == .person else { return }
+ people.append(contact)
+ if let limit, people.count >= limit { stop.pointee = true }
+ }
+} catch {
+ fail("reading contacts: \(error)")
+}
+
+let (noted, noteById) = notes()
+for contact in people {
+ guard noted.contains(contact.identifier) else {
+ fail("AppleScript has no person \(contact.identifier), so its note cannot be read")
+ }
+ for key in keys where !contact.isKeyAvailable(key) {
+ fail("\(key) came back unavailable for \(contact.identifier)")
+ }
+ var fields: [String: Any] = [:]
+ for key in keys { fields[key] = json(contact.value(forKey: key)) }
+
+ let vcard: String
+ do {
+ vcard = String(decoding: try CNContactVCardSerialization.data(with: [contact]), as: UTF8.self)
+ } catch {
+ fail("serializing \(contact.identifier): \(error)")
+ }
+
+ let object: [String: Any] = [
+ "identifier": contact.identifier, "vcard": vcard, "contact": fields, "note": json(noteById[contact.identifier]),
+ ]
+ let line = try! JSONSerialization.data(withJSONObject: object, options: [.sortedKeys])
+ FileHandle.standardOutput.write(line + Data("\n".utf8))
+}
diff --git a/tasks/import.rake b/tasks/import.rake
index d8dd04b..1e6d58d 100644
--- a/tasks/import.rake
+++ b/tasks/import.rake
@@ -2,6 +2,24 @@
# (docs/plans/2026-09-16-importing-from-macos.md).
namespace :import do
+ namespace :macos do
+ desc "Plan importing this Mac's iCloud contacts into data/imports (LIMIT=n for the first n)"
+ task :plan do
+ require "pathname"
+ require "pro_tacts/import/macos"
+
+ created_at = Time.now.utc
+ dir = Pathname.new("data/imports/macos-#{created_at.strftime("%Y%m%dT%H%M%SZ")}")
+ limit = ENV.fetch("LIMIT", nil)&.then { Integer(it) }
+
+ records = ProTacts::Import::Macos.read(limit:)
+ plan = ProTacts::Import::Macos.plan(dir, records, created_at:)
+ puts "#{plan.contacts.size} contacts planned in #{dir}"
+ rescue ProTacts::Import::Macos::Unknown => error
+ abort error.message
+ end
+ end
+
desc "Land the plan in PLAN on the pro-tacts at HOST, a base URL such as https://contacts"
task :execute do
require "net/http"
diff --git a/test/pro_tacts/import/test_macos.rb b/test/pro_tacts/import/test_macos.rb
new file mode 100644
index 0000000..cec792a
--- /dev/null
+++ b/test/pro_tacts/import/test_macos.rb
@@ -0,0 +1,79 @@
+require_relative "../../test_helper"
+
+require "json"
+require "pathname"
+require "tmpdir"
+
+require "pro_tacts/import/macos"
+require "pro_tacts/import/plan"
+
+class ImportMacosTest < Minitest::Test
+ Macos = ProTacts::Import::Macos
+
+ CREATED_AT = Time.utc(2026, 9, 16, 18, 4, 12)
+
+ def record(identifier, lines: [], note: nil, image: nil)
+ {
+ "identifier" => identifier,
+ "vcard" => ["BEGIN:VCARD", "VERSION:3.0", *lines, "END:VCARD", ""].join("\r\n"),
+ "contact" => {"identifier" => identifier, "imageData" => image},
+ "note" => note
+ }
+ end
+
+ def in_tmpdir
+ Dir.mktmpdir { yield Pathname.new(it) / "plan" }
+ end
+
+ def test_an_empty_card_is_planned_under_a_minted_uid
+ in_tmpdir do |dir|
+ plan = Macos.plan(dir, [record("A:ABPerson")], created_at: CREATED_AT)
+
+ assert_equal 1, plan.contacts.size
+ contact = plan.contacts.first
+ assert_match(/\A[k-z]{12}\z/, contact.id)
+ assert_equal "A:ABPerson", contact.source_id
+ assert_equal "BEGIN:VCARD\r\nVERSION:3.0\r\nUID:#{contact.id}\r\nEND:VCARD\r\n", plan.card(contact.id)
+ end
+ end
+
+ def test_the_backup_holds_the_vcard_and_the_keys
+ in_tmpdir do |dir|
+ source = record("A:ABPerson")
+ plan = Macos.plan(dir, [source], created_at: CREATED_AT)
+
+ backup = dir / "backups" / plan.contacts.first.id
+ assert_equal source.fetch("vcard"), (backup / "original.vcf").read
+ assert_equal source.fetch("contact"), JSON.parse((backup / "contact.json").read)
+ refute (backup / "note.txt").exist?
+ end
+ end
+
+ def test_every_unknown_field_is_named_and_no_plan_is_written
+ in_tmpdir do |dir|
+ records = [
+ record("A:ABPerson", lines: ["N:Lovelace;Ada;;;", "FN:Ada Lovelace"], note: "Analyst."),
+ record("B:ABPerson", lines: ["FN:Mary Boole"], image: "/9j/")
+ ]
+
+ error = assert_raises(Macos::Unknown) { Macos.plan(dir, records, created_at: CREATED_AT) }
+
+ assert_includes error.message, "FN: 2, e.g. A:ABPerson, B:ABPerson"
+ assert_includes error.message, "N: 1, e.g. A:ABPerson"
+ assert_includes error.message, "note: 1, e.g. A:ABPerson"
+ assert_includes error.message, "imageData: 1, e.g. B:ABPerson"
+ refute dir.exist?
+ end
+ end
+
+ def test_a_card_that_is_not_version_3_is_refused
+ in_tmpdir do |dir|
+ source = record("A:ABPerson")
+ source["vcard"] = source.fetch("vcard").sub("VERSION:3.0", "VERSION:4.0")
+
+ error = assert_raises(Macos::Unknown) { Macos.plan(dir, [source], created_at: CREATED_AT) }
+
+ assert_includes error.message, "VERSION:4.0: 1"
+ end
+ end
+end