import: ask the card browser whether a card is still there
A card in no `sync:` group is in nobody's book, so the DAV route called it gone,
and remove kept a contact whose card the web view had been showing all along.
GET /contacts/<id> answers over every card the host holds, which is what remove meant to ask,
and a kept contact prints the name its card carries, an id being no help to the person given the task.
Assisted-by: Claude Opus 5 via Claude Code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Issue: rtqvmzvp
diff --git a/docs/plans/2026-09-16-importing-from-macos.md b/docs/plans/2026-09-16-importing-from-macos.md
index 8bb5083..af1d59b 100644
--- a/docs/plans/2026-09-16-importing-from-macos.md
+++ b/docs/plans/2026-09-16-importing-from-macos.md
@@ -198,7 +198,10 @@ editor writes it.
the oldest landed plan imported, and `PLAN=dir` names another. The host
is the one that plan recorded rather than a `HOST`, the contacts to take
off this Mac being the ones already carried somewhere. For each one it
-first GETs the card from that host, then reads the contact again through
+first asks that host's card browser, `GET /contacts/<id>`, whether it
+still has the card — the DAV collection serves the asking user's book
+alone, and a card in no `sync:` group is on the host and in no book —
+then reads the contact again through
`macos-contacts.swift show` and compares it to its backup, note
included, and keeps it, printing why, if either check
fails. A contact edited on the Mac since the plan is kept, and so
diff --git a/lib/pro_tacts/import/remove.rb b/lib/pro_tacts/import/remove.rb
index 5d35a5a..00b294f 100644
--- a/lib/pro_tacts/import/remove.rb
+++ b/lib/pro_tacts/import/remove.rb
@@ -15,7 +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 with why. Signed in
+ # 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
# sig/pro_tacts/import.rbs, being a Data class.
# @rbs skip
Result = Data.define(:removed, :kept)
@@ -44,7 +46,7 @@ module ProTacts
kept = [] #: Array[[String, String]]
going = still_there.select { |contact|
why = keep(contact, records.fetch(contact.source_id))
- kept << [contact.id, why] if why
+ kept << [name(contact.id), why] if why
why.nil?
}
# One delete for the batch, since each one starts the script
@@ -67,7 +69,7 @@ module ProTacts
backup = @plan.backup(contact.id)
note = (backup / "note.txt").then { it.file? ? it.read : nil }
if !on_host?(contact.id)
- "its card is not on #{@plan.host}"
+ "its card is no longer on #{@plan.host}"
elsif record.fetch("vcard") != (backup / "original.vcf").read
"it has changed on this Mac since the plan"
elsif record.fetch("note") != note
@@ -75,13 +77,26 @@ 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
+ # here is whether the host still has it at all
+ # (docs/plans/2026-09-12-per-user-books.md).
#: (String id) -> bool
def on_host?(id)
- response = @client.call("GET", "/dav/addressbook/#{id}.vcf")
+ response = @client.call("GET", "/contacts/#{id}")
case response.status
when 200 then true
when 404 then false
- else raise Failed, "GET of #{id} answered #{response.status}"
+ else raise Failed, "GET of /contacts/#{id} answered #{response.status}: #{response.body}"
end
end
end
diff --git a/tasks/import.rake b/tasks/import.rake
index c1751a6..ad7ec92 100644
--- a/tasks/import.rake
+++ b/tasks/import.rake
@@ -67,7 +67,7 @@ namespace :import do
plan, client: ProTacts::Import::HttpClient.new(http), mac: ProTacts::Import::Macos::Mac
)
end
- result.kept.each { |id, why| puts "kept #{id}: #{why}" }
+ result.kept.each { |name, why| puts "kept #{name}: #{why}" }
puts "#{result.removed} contacts removed from this Mac"
end
end
diff --git a/test/pro_tacts/import/test_remove.rb b/test/pro_tacts/import/test_remove.rb
index 774abb1..db481b3 100644
--- a/test/pro_tacts/import/test_remove.rb
+++ b/test/pro_tacts/import/test_remove.rb
@@ -47,7 +47,7 @@ class ImportRemoveTest < Minitest::Test
end
# A plan already landed on the host, since that is what remove works on.
- def with_landed_plan(notes: {})
+ def with_landed_plan(notes: {}, edit: nil)
Dir.mktmpdir do |tmp|
dir = Pathname.new(tmp) / "plan"
entries = IDS.map { |id|
@@ -57,6 +57,7 @@ class ImportRemoveTest < Minitest::Test
Plan::Entry.new(id:, source_id: source_id(id), card:, backup:)
}
Plan.write(dir, source: "macos", created_at: Time.utc(2026, 9, 16, 18, 4, 12), entries:)
+ IDS.each { (dir / "cards/#{it}.yml").write(edit.call((dir / "cards/#{it}.yml").read)) } if edit
with_contacts({}) do |store|
client = ImportExecuteTest::RackClient.new
plan = Plan.read(dir)
@@ -87,7 +88,7 @@ class ImportRemoveTest < Minitest::Test
result = Remove.call(plan, client:, mac:)
- assert_equal [[IDS.first, "it has changed on this Mac since the plan"]], result.kept
+ assert_equal [["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 +102,7 @@ class ImportRemoveTest < Minitest::Test
result = Remove.call(plan, client:, mac:)
- assert_equal [[IDS.first, "its note has changed on this Mac since the plan"]], result.kept
+ assert_equal [["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,11 +114,25 @@ class ImportRemoveTest < Minitest::Test
result = Remove.call(plan, client:, mac:)
- assert_equal [[IDS.first, "its card is not on #{HOST}"]], result.kept
+ assert_equal [["Contact #{IDS.first}", "its card is no longer on #{HOST}"]], result.kept
assert_equal [source_id(IDS.last)], mac.deleted
end
end
+ # The card browser shows every card; /dav/addressbook serves the asking
+ # user's book, which a card in no `sync:` group is outside of.
+ def test_a_contact_whose_card_is_in_no_book_still_leaves_the_mac
+ with_landed_plan(edit: ->(yaml) { yaml.sub("- sync:*\n", "") }) do |plan, store, client|
+ refute_includes store.book("test@example.com"), IDS.first
+ mac = Mac.new(IDS.to_h { [source_id(it), record(it)] })
+
+ result = Remove.call(plan, client:, mac:)
+
+ assert_empty result.kept
+ assert_equal IDS.map { source_id(it) }, mac.deleted
+ end
+ end
+
def test_a_contact_already_off_the_mac_counts_as_removed
with_landed_plan do |plan, _store, client|
mac = Mac.new({source_id(IDS.last) => record(IDS.last)})