1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
require "json"

require "pro_tacts/birthday"
require "pro_tacts/contact"
require "pro_tacts/import/vcf"
require "pro_tacts/vcard"

module ProTacts
  module Import
    # Monica 4's account export (Settings, Export, "Export to JSON"),
    # read as the .vcf the rest of the import already walks
    # (docs/plans/2026-09-25-importing-from-monica.md, which says why
    # the JSON, where each part of a contact goes, and the shapes of
    # Monica's export resources read here).
    module Monica
      # Monica's relationship type names, as its English interface
      # spells them (resources/lang/en/app.php). A type an account made
      # up itself is its own name, which is already words.
      RELATIONSHIPS = {
        "partner" => "significant other",
        "inlovewith" => "in love with",
        "lovedby" => "loved by",
        "ex" => "ex-partner",
        "stepparent" => "step-parent",
        "godfather" => "godparent",
        "godson" => "godchild",
        "bestfriend" => "best friend",
        "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.
      #: (String bytes) -> bool
      def self.export?(bytes)
        bytes.lstrip.start_with?("{")
      end

      # The export as a .vcf, a card per contact in the order the export
      # lists them, less the `is_partial` ones Monica only knows by name
      # (the plan's "Where each part goes").
      #: (String bytes) -> String
      def self.vcf(bytes)
        account = account_of(bytes)
        contacts = records(account, "contact")
        names = {} #: Hash[String, String]
        contacts.each { names[uuid_of(it)] = name_of(props(it)) }
        photos = {} #: Hash[String, String]
        records(account, "photo").each { photos[uuid_of(it)] = props(it)["dataUrl"].to_s }
        field_types = {} #: Hash[String, Hash[String, untyped]]
        list(account.dig("instance", "contact_field_types")).each { field_types[uuid_of(it)] = props(it) }
        activities = {} #: Hash[String, Hash[String, untyped]]
        records(account, "activity").each { activities[uuid_of(it)] = props(it) }
        relationships = records(account, "relationship").group_by { props(it)["contact_is"].to_s }

        cards = contacts.reject { props(it)["is_partial"] }.map { |contact|
          card(contact, names:, photos:, field_types:, activities:,
                        relationships: relationships.fetch(contact["uuid"].to_s, []))
        }
        raise Vcf::Invalid, "this Monica export holds no contacts" if cards.empty?

        cards.join
      end

      # The export's account, or a refusal naming what the file is not.
      #: (String bytes) -> Hash[String, untyped]
      def self.account_of(bytes)
        parsed = JSON.parse(bytes)
        account = parsed["account"] if parsed.is_a?(Hash)
        return account if account.is_a?(Hash)

        raise Vcf::Invalid, "this is JSON, but not a Monica export"
      rescue JSON::ParserError
        raise Vcf::Invalid, "this file is neither a set of vCards nor JSON"
      end

      # One collection's values out of a record's `data`, and none when
      # the record has none of that kind: Monica leaves an empty
      # collection out rather than writing it empty.
      #: (Hash[String, untyped] record, String type) -> Array[untyped]
      def self.records(record, type)
        collection = list(record["data"]).find { it.is_a?(Hash) && it["type"] == type }
        collection ? list(collection["values"]) : []
      end

      # A value the export says is a list, or none where it is not one.
      #: (untyped value) -> Array[untyped]
      def self.list(value)
        value.is_a?(Array) ? value : []
      end

      # A record's own id, which is how every other record names it.
      #: (untyped record) -> String
      def self.uuid_of(record)
        record.is_a?(Hash) ? record["uuid"].to_s : record.to_s
      end

      #: (untyped record) -> Hash[String, untyped]
      def self.props(record)
        properties = record.is_a?(Hash) ? record["properties"] : nil
        properties.is_a?(Hash) ? properties : {}
      end

      # A person as a note names them: the names Monica shows, in the
      # western order it shows them.
      #: (Hash[String, untyped] properties) -> String
      def self.name_of(properties)
        %w[first_name middle_name last_name].filter_map { properties[it].to_s.strip.then { it unless it.empty? } }.join(" ")
      end

      #: (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", "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))}"
        lines << text("NICKNAME", properties["nickname"])
        # Monica's company is free text, and one that came in by vCard
        # kept ORG's raw value, the trailing semicolon macOS writes
        # included — Monica's own migration nulls a company of ";"
        # alone (2021_10_14_212144_v_card_company). Escaped as text,
        # that semicolon would be part of the name.
        lines << text("ORG", properties["company"].to_s.sub(/[;\s]+\z/, ""))
        lines << text("TITLE", properties["job"])
        lines << birthday(properties["birthdate"])

        records(contact, "contact_field").each do |field|
          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))
        end
        lines << photo(properties.dig("avatar", "avatar_photo"), photos)

        tags = list(properties["tags"]).map { VCard.escape(it.to_s) }
        lines << "CATEGORIES:#{tags.join(",")}" unless tags.empty?

        lines << text("NOTE", note(contact, names:, relationships:))
        lines.concat(left_behind(contact, activities, names:, relationships:))
        lines << "UID:#{contact["uuid"]}"
        lines << "END:VCARD"
        lines.compact.map { "#{it}\r\n" }.join
      end

      # A text property's line, or none for a blank value.
      #: (String name, untyped value) -> String?
      def self.text(name, value)
        value = value.to_s.strip
        "#{name}:#{VCard.escape(value)}" unless value.empty?
      end

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

        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
      end

      # A contact field: a phone or an email by the type Monica filed it
      # under, a Signal handle by a type the account named Signal
      # (Monica ships no such type), and nothing for anything else —
      # Facebook, Twitter, another type the account made up — which no
      # screen here shows (docs/plans/2026-10-18-signal-handles.md,
      # "What comes in on import"). 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 handle nobody meant.
      #: (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)}"
        when "email" then "EMAIL;type=INTERNET:#{VCard.escape(value)}"
        else Contact.handle_line("signal", value) if type["name"].to_s.strip.casecmp?("signal")
        end
      end

      # An address, labelled with the name Monica gave it (a free-text
      # "Home" or "Summer house") the way Contacts labels a row: a
      # grouped X-ABLabel beside it, which Vcf.read keeps with its line.
      #: (Hash[String, untyped] address, Integer item) -> Array[String]
      def self.address(address, item)
        components = %w[street city province postal_code country].map { VCard.escape(address[it].to_s.strip) }
        return [] if components.all?(&:empty?)

        street, city, province, postal, country = components
        label = address["name"].to_s.strip
        return ["ADR:;;#{street};#{city};#{province};#{postal};#{country}"] if label.empty?

        ["item#{item}.ADR:;;#{street};#{city};#{province};#{postal};#{country}",
         "item#{item}.X-ABLabel:#{VCard.escape(label)}"]
      end

      # The avatar Monica stored, out of the account's photos, where it
      # 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)
        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")}"
      end

      # The one NOTE: what Monica holds as prose about the person, a
      # paragraph each. The contact's description and its notes first,
      # those being words someone wrote; then the facts Monica keeps as
      # records, spelled out — who they are related to, their pets,
      # what they eat, how you met.
      #: (Hash[String, untyped] contact, names: Hash[String, String], relationships: Array[untyped]) -> String
      def self.note(contact, names:, relationships:)
        properties = props(contact)
        paragraphs = [properties["description"].to_s.strip]
        paragraphs.concat(records(contact, "note").map { props(it)["body"].to_s.strip })

        paragraphs << relationships.filter_map { |relationship|
          related = props(relationship)
          name = names[related["of_contact"].to_s]
          next if name.nil? || name.empty?

          type = related["type"].to_s
          "#{RELATIONSHIPS.fetch(type, type).capitalize}: #{name}"
        }.join("\n")

        paragraphs << records(contact, "pet").filter_map { |pet|
          name, category = props(pet).values_at("name", "category").map { it.to_s.strip }
          next if name.empty? && category.empty?

          next "Pet: #{category}" if name.empty?

          category.empty? ? "Pet: #{name}" : "Pet: #{name} (#{category})"
        }.join("\n")

        food = properties["food_preferences"].to_s.strip
        paragraphs << "Food preferences: #{food}" unless food.empty?

        met = [date_of(properties["first_met_date"]), names[properties["first_met_through"].to_s]]
        paragraphs << "Met #{[met[0], met[1] && "through #{met[1]}"].compact.join(" ")}" if met.any?

        paragraphs.reject(&:empty?).join("\n\n")
      end

      # What Monica keeps about a person that is neither a field here
      # 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. 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],
          "call" => %w[called_at content],
          "conversation" => %w[happened_at],
          "life_event" => %w[happened_at name note],
          "gift" => %w[name comment],
          "debt" => %w[amount currency],
        }.each do |type, fields|
          records(contact, type).each do |record|
            lines << left_line(type, props(record), fields)
          end
        end
        records(contact, "activity").each do |uuid|
          lines << left_line("activity", activities[uuid.to_s] || {}, %w[happened_at summary])
        end
        lines.compact
      end

      # One left-behind record as a line: its fields that say something,
      # a date read as a date, in the order given.
      #: (String type, Hash[String, untyped] record, Array[String] fields) -> String?
      def self.left_line(type, record, fields)
        values = fields.filter_map { |field|
          value = record[field].to_s.strip
          value = value[0, 10] if value.match?(/\A\d{4}-\d{2}-\d{2}T/)
          value unless value.empty?
        }
        return nil if values.empty?

        "X-MONICA-#{type.upcase.tr("_", "-")}:#{VCard.escape(values.join(" — "))}"
      end

      # A SpecialDate as the day it names, or none. The year stays even
      # where Monica was not told it, the reader here being a person.
      #: (untyped date) -> String?
      def self.date_of(date)
        date.is_a?(Hash) ? date["date"].to_s[0, 10].then { it unless it.empty? } : nil
      end

      private_class_method :account_of, :records, :list, :uuid_of, :props, :name_of, :card, :text, :birthday,
                           :contact_field, :address, :photo, :note, :left_behind, :left_line, :date_of
    end
  end
end