monica: leave behind what the export cannot complete
A birthday that was no date was rescued to nil, a field whose type the
export does not hold became a nameless social profile, and a
relationship to someone unnamed or an avatar naming a missing photo
was skipped. Each is an X-MONICA- line now, struck on the walk with
the rest of what is not coming in, rather than gone without a trace.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kqo9sKAi4DnKCQkrtYoAwM
change
commit 65a3e4daa38878a85319424680496d60e47939c2
author Claude <noreply@anthropic.com>
date
parent 5bc14579
diff --git a/docs/plans/2026-09-25-importing-from-monica.md b/docs/plans/2026-09-25-importing-from-monica.md
index a3ab1dd..7e5f124 100644
--- a/docs/plans/2026-09-25-importing-from-monica.md
+++ b/docs/plans/2026-09-25-importing-from-monica.md
@@ -75,6 +75,10 @@ activities are the account's, and name contacts by uuid.
   property that already says it) struck on the card as exported. They
   are things to do or things that happened rather than things about
   the person, and the one worth keeping is a copy into the note away.
+  So is any part the export names but cannot complete: a birthday
+  that is no date, a field of a type the export does not hold, a
+  relationship to someone it gives no name, and an avatar naming a
+  photo it does not carry. Each is struck in view rather than gone.
 
 A contact Monica only knows by name — `is_partial`, a relative added
 from someone else's page — is not a card of its own. It is a name in
diff --git a/lib/pro_tacts/import/monica.rb b/lib/pro_tacts/import/monica.rb
index a18fd10..4f30ea1 100644
--- a/lib/pro_tacts/import/monica.rb
+++ b/lib/pro_tacts/import/monica.rb
@@ -143,7 +143,7 @@ module ProTacts
         lines << birthday(properties["birthdate"])
 
         records(contact, "contact_field").each do |field|
-          lines << contact_field(props(field), field_types[props(field)["type"].to_s] || {})
+          lines << contact_field(props(field), field_types[props(field)["type"].to_s])
         end
         records(contact, "address").each_with_index do |address, index|
           lines.concat(address(props(address), index + 1))
@@ -154,7 +154,7 @@ module ProTacts
         lines << "CATEGORIES:#{tags.join(",")}" unless tags.empty?
 
         lines << text("NOTE", note(contact, names:, relationships:))
-        lines.concat(left_behind(contact, activities))
+        lines.concat(left_behind(contact, activities, names:, relationships:))
         lines << "UID:#{contact["uuid"]}"
         lines << "END:VCARD"
         lines.compact.map { "#{it}\r\n" }.join
@@ -169,30 +169,36 @@ module ProTacts
 
       # A SpecialDate (Monica's own date model) as BDAY, where a card can
       # spell it, and an age-based one as a line of its own left behind
-      # (the plan's "Where each part goes").
+      # (the plan's "Where each part goes"). A date that is no month and
+      # day Birthday admits is left behind as it came, the rest of the
+      # module's answer to a part with nowhere to go.
       #: (untyped date) -> String?
       def self.birthday(date)
         return nil unless date.is_a?(Hash)
 
-        match = date["date"].to_s.match(/\A(\d{4})-(\d{2})-(\d{2})/)
-        return nil if match.nil?
+        value = date["date"].to_s.strip
+        return nil if value.empty?
+
+        match = value.match(/\A(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])/)
+        return "X-MONICA-BIRTHDATE:#{VCard.escape(value)}" if match.nil?
 
         year, month, day = match.captures.map(&:to_i)
         return "X-MONICA-AGE-BASED-BIRTHDAY:about #{year}" if date["is_age_based"]
 
         Birthday.new(year: date["is_year_unknown"] ? nil : year, month:, day:).to_line
-      rescue ArgumentError
-        nil
       end
 
       # A contact field: a phone or an email by the type Monica filed it
       # under, and anything else — Facebook, Twitter, a type the account
       # made up — as a social profile named for that type, which no
-      # screen here shows.
-      #: (Hash[String, untyped] field, Hash[String, untyped] type) -> String?
+      # screen here shows. A field naming a type the export does not
+      # hold is left behind, its value with it: guessing a kind for it
+      # would file a phone as a profile nobody sees.
+      #: (Hash[String, untyped] field, Hash[String, untyped]? type) -> String?
       def self.contact_field(field, type)
         value = field["data"].to_s.strip
         return nil if value.empty?
+        return "X-MONICA-CONTACT-FIELD:#{VCard.escape(value)}" if type.nil?
 
         case type["type"]
         when "phone" then "TEL:#{VCard.escape(value)}"
@@ -218,12 +224,18 @@ module ProTacts
       end
 
       # The avatar Monica stored, out of the account's photos, where it
-      # is a data URL. A Gravatar or a link to someone else's server is
-      # not a picture this export holds.
+      # is a data URL. A Gravatar names no stored photo, so there is
+      # none to take. One that names a photo the export does not hold,
+      # or a link to someone else's server, is left behind saying which.
       #: (untyped uuid, Hash[String, String] photos) -> String?
       def self.photo(uuid, photos)
-        match = photos.fetch(uuid.to_s, "").match(%r{\Adata:image/(\w+);base64,(.+)\z}m)
-        return nil if match.nil?
+        return nil if uuid.to_s.empty?
+
+        stored = photos[uuid.to_s]
+        return "X-MONICA-PHOTO:not in the export" if stored.nil? || stored.empty?
+
+        match = stored.match(%r{\Adata:image/(\w+);base64,(.+)\z}m)
+        return "X-MONICA-PHOTO:#{VCard.escape(stored)}" if match.nil?
 
         type, payload = match.captures
         "PHOTO;ENCODING=b;TYPE=#{type.to_s.upcase}:#{payload.to_s.delete("\r\n")}"
@@ -271,14 +283,24 @@ module ProTacts
       # nor prose worth a note: things to do, things that happened,
       # money and gifts. Each a line on the card as exported, struck
       # there with the rest of what is not coming in, so the importer
-      # can copy the one worth keeping into the note by hand.
-      #: (Hash[String, untyped] contact, Hash[String, Hash[String, untyped]] activities) -> Array[String]
-      def self.left_behind(contact, activities)
+      # can copy the one worth keeping into the note by hand. A
+      # relationship to someone the export gives no name is here too,
+      # there being no one for the note's line to name.
+      #: (Hash[String, untyped] contact, Hash[String, Hash[String, untyped]] activities, names: Hash[String, String], relationships: Array[untyped]) -> Array[String]
+      def self.left_behind(contact, activities, names:, relationships:)
         properties = props(contact)
         lines = [] #: Array[String?]
         deceased = date_of(properties["deceased_date"])
         lines << "X-MONICA-DECEASED:#{deceased || "yes"}" if properties["is_dead"]
 
+        relationships.each do |relationship|
+          related = props(relationship)
+          next unless names[related["of_contact"].to_s].to_s.empty?
+
+          type = related["type"].to_s
+          lines << "X-MONICA-RELATIONSHIP:#{VCard.escape(RELATIONSHIPS.fetch(type, type).capitalize)}"
+        end
+
         {
           "reminder" => %w[initial_date title],
           "task" => %w[title description],
diff --git a/test/pro_tacts/import/test_monica.rb b/test/pro_tacts/import/test_monica.rb
index 475f199..6663eb2 100644
--- a/test/pro_tacts/import/test_monica.rb
+++ b/test/pro_tacts/import/test_monica.rb
@@ -214,6 +214,41 @@ class ImportMonicaTest < Minitest::Test
     assert_equal "about 1980", card.property("X-MONICA-AGE-BASED-BIRTHDAY").text
   end
 
+  # A date no month and day reads as: left behind as it came, the
+  # module's answer to any part with nowhere to go, rather than gone.
+  def test_a_birthday_that_will_not_read_is_left_behind
+    ["1980-00-12T00:00:00Z", "12/03/1980"].each do |date|
+      card = card_for(export(jane("birthdate" => {"date" => date})), "Jane Booles")
+
+      assert_nil card.property("BDAY")
+      assert_equal date, card.property("X-MONICA-BIRTHDATE").text
+    end
+  end
+
+  def test_a_field_whose_type_the_export_does_not_hold_is_left_behind
+    contact = jane
+    contact.fetch("data").fetch(0)["values"] = [{"uuid" => "f9", "properties" => {"data" => "+1 555 0199", "type" => "gone"}}]
+    card = card_for(export(contact), "Jane Booles")
+
+    assert_nil card.property("X-SOCIALPROFILE")
+    assert_equal "+1 555 0199", card.property("X-MONICA-CONTACT-FIELD").text
+  end
+
+  def test_a_relationship_to_someone_the_export_does_not_name_is_left_behind
+    card = card_for(export(jane, relationships: [relationship("jane-uuid", "partner", "nobody-uuid")]), "Jane Booles")
+
+    refute_includes card.property("NOTE").text, "Significant other"
+    assert_equal "Significant other", card.property("X-MONICA-RELATIONSHIP").text
+  end
+
+  def test_an_avatar_naming_a_photo_the_export_does_not_hold_is_left_behind
+    card = card_for(export(jane("avatar" => {"avatar_source" => "photo", "avatar_photo" => "gone-uuid"})),
+                    "Jane Booles")
+
+    assert_nil card.property("PHOTO")
+    assert_equal "not in the export", card.property("X-MONICA-PHOTO").text
+  end
+
   def test_json_that_is_not_a_monica_export_is_refused
     error = assert_raises(Vcf::Invalid) { Monica.vcf("{\"contacts\": []}") }