import: delete the Mac's contacts one at a time
One save request held five, and one refusal sank the batch;
now each goes on its own, and a refusal keeps just that.
The plan writes down a name beside each id it mints,
so a contact that is kept comes back with better hints.
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 d0893cb..c92516e 100644
--- a/docs/plans/2026-09-16-importing-from-macos.md
+++ b/docs/plans/2026-09-16-importing-from-macos.md
@@ -259,10 +259,21 @@ included, and keeps it, printing why, if either check
fails. A contact edited on the Mac since the plan is kept, and so
is one whose card is no longer on the host. A contact already gone from
the Mac counts as removed, so a rerun is safe for the same reason
-`execute`'s is. The deletes go in one `CNSaveRequest`, since each one
-starts the script again, and the statuses are recorded after it: a run
-that dies between the two finds those contacts gone next time and
-records them then.
+`execute`'s is. The deletes go in one run of the script, since each one
+starts it again, but in one `CNSaveRequest` each: the save is all or
+nothing, and the store refuses some contacts it will hand over quite
+happily, so a batch would lose every other delete to that one. The
+script prints per identifier whether the delete took, a refused one is
+kept and printed like any other kept contact, and the statuses are
+recorded after: a run that dies between the two finds those contacts
+gone next time and records them then.
+
+A refusal is `NSCocoaErrorDomain` 134092 out of `store.execute`, with
+`CoreData: error: Unhandled error occurred during faulting` on stderr,
+and no key set avoids it — identifier alone fails the same way the full
+fetch does. What makes a contact refusable is not known; the one that
+turned this up was the only one of its plan carrying both a photo and
+`X-ABRELATEDNAMES`. Deleting it in Contacts.app is the way out.
The comparison is exact because CNContact exposes no modification date.
A contact Contacts.app touched on its own will be kept, which is the
diff --git a/lib/pro_tacts/import/macos.rb b/lib/pro_tacts/import/macos.rb
index a9dcee1..ffc4df6 100644
--- a/lib/pro_tacts/import/macos.rb
+++ b/lib/pro_tacts/import/macos.rb
@@ -80,9 +80,15 @@ module ProTacts
}
end
- #: (Array[String] source_ids) -> void
+ # The contacts this Mac would not delete, each to what it said;
+ # empty when every one of them left.
+ #: (Array[String] source_ids) -> Hash[String, String]
def self.delete(source_ids)
- Macos.run("delete", *source_ids) unless source_ids.empty?
+ return {} if source_ids.empty?
+
+ Macos.run("delete", *source_ids).reject { it.fetch("deleted") }.to_h {
+ [it.fetch("identifier"), it.fetch("error")] #: [String, String]
+ }
end
end
diff --git a/lib/pro_tacts/import/plan.rb b/lib/pro_tacts/import/plan.rb
index 417eb7c..ec2d295 100644
--- a/lib/pro_tacts/import/plan.rb
+++ b/lib/pro_tacts/import/plan.rb
@@ -22,11 +22,14 @@ module ProTacts
Entry = Data.define(:id, :source_id, :card)
# A contact as the plan lists it, with the last step it reached:
- # nil, then landed, imported, and removed, in that order.
+ # nil, then landed, imported, and removed, in that order. The name is
+ # the card's when the plan was built, written down so a person
+ # reading plan.yml can tell which line is whose without opening a
+ # card; nothing reads it back to decide anything.
# @rbs skip
- Contact = Data.define(:id, :source_id, :status)
+ Contact = Data.define(:id, :source_id, :name, :status)
- FORMAT = 1 #: Integer
+ FORMAT = 2 #: Integer
# Store::EVERYONE, spelled out so a plan loads no database.
EVERYONE = "sync:*" #: String
@@ -52,7 +55,11 @@ module ProTacts
"created_at" => created_at.utc.iso8601,
"group" => group,
"host" => nil,
- "contacts" => entries.map { {"id" => it.id, "source_id" => it.source_id, "status" => nil} }
+ "contacts" => entries.map {
+ # A card may hold only one of the two names.
+ person = [it.card.first, it.card.last].reject(&:empty?).join(" ")
+ {"id" => it.id, "source_id" => it.source_id, "name" => person, "status" => nil}
+ }
})
plan.save
plan
@@ -102,7 +109,9 @@ module ProTacts
#: () -> Array[Contact]
def contacts
- @manifest.fetch("contacts").map { Contact.new(id: it.fetch("id"), source_id: it.fetch("source_id"), status: it.fetch("status")) }
+ @manifest.fetch("contacts").map {
+ Contact.new(id: it.fetch("id"), source_id: it.fetch("source_id"), name: it.fetch("name"), status: it.fetch("status"))
+ }
end
#: (String id, String status) -> void
diff --git a/lib/pro_tacts/import/remove.rb b/lib/pro_tacts/import/remove.rb
index c7f1e78..f8df7e7 100644
--- a/lib/pro_tacts/import/remove.rb
+++ b/lib/pro_tacts/import/remove.rb
@@ -15,9 +15,9 @@ module ProTacts
class Failed < StandardError; end
# What a run did: how many contacts it took off the Mac, and the
- # ones it left there, each named as its card names it and said why.
- # A name rather than an id, since the person reading is the one who
- # will go and look at the contact. Signed in
+ # ones it left there, each as its plan id, the name the plan recorded
+ # and why it stayed. Both, since the person reading has a contact to
+ # find in Contacts and a line to find in plan.yml. Signed in
# sig/pro_tacts/import.rbs, being a Data class.
# @rbs skip
Result = Data.define(:removed, :kept)
@@ -43,19 +43,24 @@ module ProTacts
# what a rerun of an interrupted run sees.
gone.each { @plan.record(it.id, "removed") }
- kept = [] #: Array[[String, String]]
+ kept = [] #: Array[[String, String, String]]
going = still_there.select { |contact|
why = keep(contact, records.fetch(contact.source_id))
- kept << [name(contact.id), why] if why
+ kept << [contact.id, contact.name, why] if why
why.nil?
}
- # One delete for the batch, since each one starts the script
- # again; a run that dies before the plan records them finds them
- # gone next time.
- @mac.delete(going.map(&:source_id))
- going.each { @plan.record(it.id, "removed") }
+ # One call for the batch, since each one starts the script again,
+ # and one save request per contact inside it, so a contact the
+ # store refuses is the only one that stays. A run that dies before
+ # the plan records the rest finds them gone next time.
+ refused = @mac.delete(going.map(&:source_id))
+ removed, stuck = going.partition { !refused.key?(it.source_id) }
+ removed.each { @plan.record(it.id, "removed") }
+ stuck.each {
+ kept << [it.id, it.name, "this Mac would not delete #{it.source_id}: #{refused.fetch(it.source_id)}"]
+ }
- Result.new(removed: gone.size + going.size, kept:)
+ Result.new(removed: gone.size + removed.size, kept:)
end
private
@@ -76,14 +81,6 @@ module ProTacts
end
end
- # The contact as its card file names it, which is what a person
- # reading this run looks for in Contacts.
- #: (String id) -> String
- def name(id)
- card = @plan.card(id)
- [card.first, card.last].reject(&:empty?).join(" ")
- end
-
# Asked of the card browser rather than of `/dav/addressbook`,
# which serves the asking user's book alone: a card in no `sync:`
# group is on the host and outside every book, and the question
diff --git a/script/macos-contacts.swift b/script/macos-contacts.swift
index af2b3d3..f6ab711 100644
--- a/script/macos-contacts.swift
+++ b/script/macos-contacts.swift
@@ -13,8 +13,10 @@
// account (Monica, or pro-tacts itself) is this account's card alone.
//
// show prints those same objects for the identifiers it is given, leaving
-// out the ones this Mac no longer has; delete deletes them, and an
-// identifier it no longer has is already deleted.
+// out the ones this Mac no longer has; delete deletes them one at a time
+// and prints, per identifier it still has, whether the delete took and the
+// error if it did not. An identifier this Mac no longer has is already
+// deleted, and prints nothing.
import Contacts
import Foundation
@@ -152,20 +154,25 @@ do {
}
if command == "delete" {
- // One save request for the batch: a delete of a contact this Mac no
- // longer has is a delete that already happened, so the identifiers that
- // matched nothing are left alone.
- let request = CNSaveRequest()
+ // One save request per contact, because CNSaveRequest is all or nothing
+ // and the store refuses some contacts it will hand over quite happily:
+ // batched, one of those takes every other delete down with it. An
+ // identifier that matched nothing prints no line, a delete of a contact
+ // this Mac no longer has being a delete that already happened.
for contact in people {
guard let mutable = contact.mutableCopy() as? CNMutableContact else { fail("\(contact.identifier) will not copy") }
+ let request = CNSaveRequest()
request.delete(mutable)
- }
- if !people.isEmpty {
+ var result: [String: Any] = ["identifier": contact.identifier]
do {
try store.execute(request)
+ result["deleted"] = true
} catch {
- fail("deleting \(people.count) contacts: \(error)")
+ result["deleted"] = false
+ result["error"] = "\(error)"
}
+ let line = try! JSONSerialization.data(withJSONObject: result, options: [.sortedKeys])
+ FileHandle.standardOutput.write(line + Data("\n".utf8))
}
exit(0)
}
diff --git a/sig/pro_tacts/import.rbs b/sig/pro_tacts/import.rbs
index d4cc882..190e3df 100644
--- a/sig/pro_tacts/import.rbs
+++ b/sig/pro_tacts/import.rbs
@@ -36,18 +36,19 @@ module ProTacts
class Contact
attr_reader id: String
attr_reader source_id: String
+ attr_reader name: String
attr_reader status: String?
- def self.new: (id: String, source_id: String, status: String?) -> instance
+ def self.new: (id: String, source_id: String, name: String, status: String?) -> instance
end
end
class Remove
class Result
attr_reader removed: Integer
- attr_reader kept: Array[[String, String]]
+ attr_reader kept: Array[[String, String, String]]
- def self.new: (removed: Integer, kept: Array[[String, String]]) -> instance
+ def self.new: (removed: Integer, kept: Array[[String, String, String]]) -> instance
end
end
@@ -71,7 +72,7 @@ module ProTacts
interface _Mac
def show: (Array[String] source_ids) -> Hash[String, untyped]
- def delete: (Array[String] source_ids) -> void
+ def delete: (Array[String] source_ids) -> Hash[String, String]
end
end
end
diff --git a/tasks/import.rake b/tasks/import.rake
index ad7ec92..f094b66 100644
--- a/tasks/import.rake
+++ b/tasks/import.rake
@@ -40,8 +40,7 @@ namespace :import do
plan = ProTacts::Import::Macos.plan(dir, records, created_at:)
plan.contacts.each do |contact|
card = plan.card(contact.id)
- name = [card.first, card.last].reject(&:empty?).join(" ")
- puts "#{contact.id} #{name}#{card.phones.map { " #{it}" }.join}"
+ puts "#{contact.id} #{contact.name}#{card.phones.map { " #{it}" }.join}"
end
puts "#{plan.contacts.size} contacts planned in #{dir}"
rescue ProTacts::Import::Macos::Unknown => error
@@ -67,7 +66,7 @@ namespace :import do
plan, client: ProTacts::Import::HttpClient.new(http), mac: ProTacts::Import::Macos::Mac
)
end
- result.kept.each { |name, why| puts "kept #{name}: #{why}" }
+ result.kept.each { |id, name, why| puts "kept #{id} #{name}: #{why}" }
puts "#{result.removed} contacts removed from this Mac"
end
end
diff --git a/test/pro_tacts/import/test_plan.rb b/test/pro_tacts/import/test_plan.rb
index c813da6..5ba6a5e 100644
--- a/test/pro_tacts/import/test_plan.rb
+++ b/test/pro_tacts/import/test_plan.rb
@@ -31,11 +31,21 @@ class ImportPlanTest < Minitest::Test
assert_equal "macos", plan.source
assert_equal "2026-09-16T18:04:12Z", plan.created_at
- assert_equal [Plan::Contact.new(id: "kmnuqmzxylru", source_id: "kmnuqmzxylru:ABPerson", status: nil), Plan::Contact.new(id: "vmnlryyvktux", source_id: "vmnlryyvktux:ABPerson", status: nil)], plan.contacts
+ assert_equal [Plan::Contact.new(id: "kmnuqmzxylru", source_id: "kmnuqmzxylru:ABPerson", name: "Ada Lovelace", status: nil), Plan::Contact.new(id: "vmnlryyvktux", source_id: "vmnlryyvktux:ABPerson", name: "Ada Lovelace", status: nil)], plan.contacts
assert_nil plan.host
end
end
+ def test_a_contact_with_one_name_is_listed_under_it
+ in_tmpdir do |dir|
+ card = ProTacts::Import::Card.new(first: "Mitch", last: "", nickname: nil, birthday: nil, phones: [], emails: [], addresses: [], note: nil, photo: false, groups: [], source: SOURCE)
+
+ Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru", card:)])
+
+ assert_equal ["Mitch"], Plan.read(dir).contacts.map(&:name)
+ end
+ end
+
def test_the_group_is_named_for_when_the_plan_was_built
in_tmpdir do |dir|
Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [])
diff --git a/test/pro_tacts/import/test_remove.rb b/test/pro_tacts/import/test_remove.rb
index 89a898d..5560f0c 100644
--- a/test/pro_tacts/import/test_remove.rb
+++ b/test/pro_tacts/import/test_remove.rb
@@ -21,20 +21,26 @@ class ImportRemoveTest < Minitest::Test
IDS = %w[kmnuqmzxylru vmnlryyvktux].freeze
# This Mac, as the contacts it still has: the reader's objects by source
- # id, and a delete that takes them out of this hash.
+ # id, and a delete that takes them out of this hash. A source id in
+ # `refuses` is one the store will not delete, which is a real thing a Mac
+ # does (docs/plans/2026-09-16-importing-from-macos.md, "Removing the
+ # originals").
class Mac
attr_reader :deleted
- def initialize(records)
+ def initialize(records, refuses: {})
@records = records
+ @refuses = refuses
@deleted = []
end
def show(source_ids) = @records.slice(*source_ids)
def delete(source_ids)
- @deleted.concat(source_ids)
- @records = @records.except(*source_ids)
+ going = source_ids - @refuses.keys
+ @deleted.concat(going)
+ @records = @records.except(*going)
+ @refuses.slice(*source_ids)
end
end
@@ -87,7 +93,7 @@ class ImportRemoveTest < Minitest::Test
result = Remove.call(plan, client:, mac:)
- assert_equal [["Contact #{IDS.first}", "it has changed on this Mac since the plan"]], result.kept
+ assert_equal [[IDS.first, "Contact #{IDS.first}", "it has changed on this Mac since the plan"]], result.kept
assert_equal [source_id(IDS.last)], mac.deleted
assert_equal ["imported", "removed"], Plan.read(plan.dir).contacts.map(&:status)
end
@@ -101,7 +107,7 @@ class ImportRemoveTest < Minitest::Test
result = Remove.call(plan, client:, mac:)
- assert_equal [["Contact #{IDS.first}", "its note has changed on this Mac since the plan"]], result.kept
+ assert_equal [[IDS.first, "Contact #{IDS.first}", "its note has changed on this Mac since the plan"]], result.kept
assert_equal [source_id(IDS.last)], mac.deleted
end
end
@@ -113,7 +119,7 @@ class ImportRemoveTest < Minitest::Test
result = Remove.call(plan, client:, mac:)
- assert_equal [["Contact #{IDS.first}", "its card is no longer on #{HOST}"]], result.kept
+ assert_equal [[IDS.first, "Contact #{IDS.first}", "its card is no longer on #{HOST}"]], result.kept
assert_equal [source_id(IDS.last)], mac.deleted
end
end
@@ -144,6 +150,22 @@ class ImportRemoveTest < Minitest::Test
end
end
+ # The store refuses some contacts it will hand over quite happily, so a
+ # refusal keeps its own contact and no other: the rest still leave, and
+ # the plan records them.
+ def test_a_contact_the_mac_will_not_delete_is_kept_alone
+ with_landed_plan do |plan, _store, client|
+ mac = Mac.new(IDS.to_h { [source_id(it), record(it)] }, refuses: {source_id(IDS.first) => "faulting, 134092"})
+
+ result = Remove.call(plan, client:, mac:)
+
+ assert_equal 1, result.removed
+ assert_equal [[IDS.first, "Contact #{IDS.first}", "this Mac would not delete #{source_id(IDS.first)}: faulting, 134092"]], result.kept
+ assert_equal [source_id(IDS.last)], mac.deleted
+ assert_equal ["imported", "removed"], Plan.read(plan.dir).contacts.map(&:status)
+ end
+ end
+
def test_a_rerun_removes_nothing_again
with_landed_plan do |plan, _store, client|
mac = Mac.new(IDS.to_h { [source_id(it), record(it)] })