import: tick a Monica export's contacts into a monica group
Each card the export composes now says in PRODID it came from Monica's door;
the walk ticks a monica group by that, not by the file name it wore.
CATEGORIES could have carried it, but the as-exported card would then show
a monica tag that Monica never wrote, a line its record shouldn't know.

Assisted-by: Claude Opus 5.5 via Claude Code
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
change mtsrkryzkytpzpprvosmlsklzmzwmpkq
commit bf9383d0d1f732a671aca48b245a369bdd6e149d
author Alpha Chen <alpha@kejadlen.dev>
date
parent ozmrqlvl
diff --git a/lib/pro_tacts/import/monica.rb b/lib/pro_tacts/import/monica.rb
index 0fbd108..c8955c4 100644
--- a/lib/pro_tacts/import/monica.rb
+++ b/lib/pro_tacts/import/monica.rb
@@ -46,6 +46,24 @@ module ProTacts
         "protege" => "protégé",
       }.freeze #: Hash[String, String]
 
+      # The program that wrote a composed card, which is this one
+      # reading Monica's export — the PRODID's own question, and how
+      # the walk tells a Monica card from a .vcf's. Vcf.read drops it
+      # as noise, so it costs the card nothing.
+      PRODID = "-//pro-tacts//Monica 4 export//EN" #: String
+
+      # The group a card from Monica is ticked into beside the import's
+      # own, so everything that came over from Monica can be found
+      # together after any number of imports.
+      GROUP = "monica" #: String
+
+      # The groups a card is ticked into for having come from Monica:
+      # GROUP for a card this module composed, none for any other.
+      #: (VCard card) -> Array[String]
+      def self.groups(card)
+        card.property("PRODID")&.text == PRODID ? [GROUP] : []
+      end
+
       # Whether an upload is a JSON document rather than a .vcf — asked
       # of the bytes rather than the file's name, which is the
       # browser's to get wrong. A vCard begins with BEGIN.
@@ -131,7 +149,7 @@ module ProTacts
       #: (Hash[String, untyped] contact, names: Hash[String, String], photos: Hash[String, String], field_types: Hash[String, Hash[String, untyped]], activities: Hash[String, Hash[String, untyped]], relationships: Array[untyped]) -> String
       def self.card(contact, names:, photos:, field_types:, activities:, relationships:)
         properties = props(contact)
-        lines = ["BEGIN:VCARD", "VERSION:3.0"] #: Array[String?]
+        lines = ["BEGIN:VCARD", "VERSION:3.0", "PRODID:#{PRODID}"] #: Array[String?]
         first, middle, last = %w[first_name middle_name last_name].map { VCard.escape(properties[it].to_s.strip) }
         lines << "N:#{last};#{first};#{middle};;"
         lines << "FN:#{VCard.escape(name_of(properties))}"
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index 131de86..11bb13c 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -262,7 +262,8 @@ module ProTacts
         # pressed (Admin::ImportSidebar).
         save: target ? "Update #{target.name || target.id}" : "Import",
         aside: Admin::ImportOriginal.new(card: original, dropped: merge ? dropped + merge.left : dropped),
-        fields: import_picker(group, joined, named, member: target, tags: Import::Vcf.categories(original)),
+        fields: import_picker(group, joined, named, member: target,
+                                                    tags: Import::Vcf.categories(original) + Import::Monica.groups(original)),
         lead: import_target(upload, index, matches, target),
         sidebar:,
       )
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index d274eef..621bca6 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -1144,10 +1144,25 @@ class AdminImportPagesTest < Minitest::Test
       assert_includes last_response.body, "Jane Booles"
       assert_includes last_response.body, "Likes jigsaws."
       assert_includes last_response.body, "TITLE:Welder"
+      # Everything from Monica is ticked into the one group, beside
+      # the import's own.
+      assert_includes last_response.body, %(<input type="checkbox" name="named[]" value="monica" checked>)
 
       save(id, 0, first: "Jane", last: "Booles", "note" => "Likes jigsaws.")
 
-      assert_equal ["Likes jigsaws."], store.contacts.fetch(0).notes.map(&:value)
+      jane = store.contacts.fetch(0)
+      assert_equal ["Likes jigsaws."], jane.notes.map(&:value)
+      assert_equal [jane.id], store.all_groups.find { it.name == "monica" }.members
+    end
+  end
+
+  # A .vcf is not Monica's, whatever its file is called.
+  def test_a_vcf_is_not_ticked_into_the_monica_group
+    with_contacts({}) do
+      upload(PLAIN, name: "monica.vcf")
+      follow_redirect!
+
+      refute_includes last_response.body, %(value="monica")
     end
   end
 
diff --git a/test/pro_tacts/import/test_monica.rb b/test/pro_tacts/import/test_monica.rb
index fae8a03..f9e64cd 100644
--- a/test/pro_tacts/import/test_monica.rb
+++ b/test/pro_tacts/import/test_monica.rb
@@ -173,6 +173,16 @@ class ImportMonicaTest < Minitest::Test
     assert_includes dropped, "X-MONICA-ACTIVITY:2026-06-01 — Picnic"
   end
 
+  # A composed card says who wrote it, which is how the walk knows to
+  # tick it into the monica group; a card from anywhere else is not.
+  def test_a_composed_card_is_marked_for_the_monica_group
+    card = card_for(export(jane), "Jane Booles")
+
+    assert_equal Monica::PRODID, card.property("PRODID").text
+    assert_equal ["monica"], Monica.groups(card)
+    assert_empty Monica.groups(ProTacts::VCard.new("BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Sam\r\nEND:VCARD\r\n"))
+  end
+
   def test_tags_arrive_as_the_card_s_categories
     card = card_for(export(jane), "Jane Booles")