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
change nmkmssttmlmvkzqyxntxmzuorqzmvmxv
commit f31ffc221e5149d442fa116bcf8acc74d8ef634c
author Alpha Chen <alpha@kejadlen.dev>
date
parent lprltsmq
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)})