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>
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")