Import a Monica export through the .vcf walk
/import takes Monica 4's JSON export as well as a .vcf. The export is
composed into a card per contact at the upload, and the walk takes it
from there: notes, relationships, pets, food preferences and how you
met join the one NOTE; tags tick groups beside the card; reminders,
activities and the rest are struck on the card as exported.

Any .vcf gains the same: ORG and X-ABShowAs come in, SOURCE and REV
are noise beside PRODID, and CATEGORIES tick groups.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SBjEmuUAmezgn9EuJaXR9w
change ozmrqlvlkmooyonzyrsuwnprqvlzlxpo
commit 03e676301e03257983262b559a6678ee6323cfdc
author Claude <noreply@anthropic.com>
date
parent yluszmzw
diff --git a/docs/plans/2026-09-25-importing-from-monica.md b/docs/plans/2026-09-25-importing-from-monica.md
new file mode 100644
index 0000000..a3ab1dd
--- /dev/null
+++ b/docs/plans/2026-09-25-importing-from-monica.md
@@ -0,0 +1,121 @@
+# Importing from Monica
+
+2026-09-25. `/import` takes Monica 4's account export (Settings,
+Export, "Export to JSON") as well as a `.vcf`. The export is read as
+the `.vcf` it composes, a card per contact, and from the upload on it
+is the walk as it was (`2026-09-21-import-a-vcf.md`): the composed
+file is the import's original, pared by `Vcf.read`, and read beside
+the editor a contact at a time, matched and folded as any card is
+(`2026-09-23-merging-on-import.md`).
+
+Three smaller changes ride with it, because a Monica card, by file or
+by export, needs them: `ORG` and `X-ABShowAs` come in, `SOURCE` and
+`REV` are dropped without remark, and a card's `CATEGORIES` are ticked
+beside it as groups.
+
+## Why the JSON and not Monica's vCards
+
+Monica writes a vCard for one contact at a time; a whole book comes
+out of its CardDAV server, through a client, as a `.vcf` the walk
+already takes. What it does not carry is most of what Monica is for.
+Its exporters (`app/Services/VCard/ExportVCard.php` on the 4.x
+branch) write names, phones, emails, addresses, the birthday, a
+photo, the company, the job, social profiles and tags. Notes,
+relationships, pets, how you met, reminders and everything that
+happened are nowhere in the card.
+
+Monica 4's JSON export is the one place those come out whole. It is
+Monica's own beta (`version: 1.0-preview.1`), and Monica 5 has no
+equivalent, so this reads Monica 4 and nothing else; a book on Monica
+5 comes in by its CardDAV `.vcf` and loses what a card cannot carry.
+
+## A card, not a second walk
+
+The export could have had screens of its own. It gets none, because
+everything the walk does is already the right thing to do with a
+Monica contact: show what it was, open the editor on what is coming
+in, offer the contact it looks like, file it under its groups. So
+`Import::Monica.vcf` composes cards at the upload and the route hands
+them to `Vcf.cards` as if the file had been a `.vcf` all along.
+
+A composed card carries everything Monica knows, not only what this
+book reads. That keeps the walk's promise that nothing is lost without
+having been shown first: the left-hand card is the whole record, and
+what is not coming in is struck on it like any other line.
+
+## Where each part goes
+
+The shapes are Monica's export resources (`app/ExportResources`): a
+record's columns, a `properties` object, and a `data` array of
+collections `{count, type, values}`. Relationships, photos and
+activities are the account's, and name contacts by uuid.
+
+- **Into a field.** First, middle and last names; the nickname; the
+  company as `ORG`; each contact field whose type Monica filed as a
+  phone or an email; each address, labelled with the name Monica gave
+  it the way Contacts labels a row; the avatar Monica stored, from the
+  account's photos, where it is a data URL rather than a Gravatar.
+- **The birthday**, where a card can spell it: a whole date, or a
+  month and day where Monica was told the year is unknown. One Monica
+  worked out from an age holds a year it guessed and a day it made
+  up; no card spells that truthfully, and a staged card could not
+  hold a year alone anyway, so it stays behind as a line of its own
+  to read and enter on the contact's page once it is in.
+- **Into the one `NOTE`** (`2026-09-25-one-note-per-contact.md`), a
+  paragraph each: the description and every note, those being words
+  someone wrote; then the relationships (`Spouse: Sam Booles`, in
+  Monica's English names for its types), the pets, the food
+  preferences, and how you met. These are prose about the person, and
+  the note is the one place a contact holds prose.
+- **Into groups.** Tags are `CATEGORIES` on the card, and the walk
+  ticks a group of each name beside it (below).
+- **Left behind, in view.** The job as `TITLE`, social profiles,
+  reminders, tasks, calls, conversations, life events, gifts, debts,
+  activities and a death date, each an `X-MONICA-` line (or the vCard
+  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.
+
+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
+the relationships of the contacts it is related to.
+
+## Tags are groups
+
+Monica files people by tag; this book files them by group, and the
+two mean the same thing. So a card's `CATEGORIES`, from a Monica
+export or any `.vcf` that carries them, are ticked beside it: the
+book's own group where one has that name, and otherwise a name the
+Save makes, the way a name typed into the filter is
+(`Import::Write#group_id`). They are ticks rather than writes, so the
+walk's rule stands: nothing joins a group the importer did not look
+at, and any of them can be unticked.
+
+A tag spelling a sync group ticks nothing. Which phones a card goes
+out to is the one box beside a card a tag should not be able to tick.
+
+The `CATEGORIES` line itself is struck on the card as exported, being
+no line this book keeps, but is not counted as a loss: what it said
+arrives, as the ticked boxes beside it.
+
+## Small changes to what a card brings in
+
+- **`ORG` and `X-ABShowAs` are known.** A company card's editor reads
+  its name out of `ORG`, and the flag is how the book knows to show it
+  as one (`2026-09-24-company-cards.md`, which named this as the bulk
+  path). A fold fills `ORG` where the contact has none, as it does a
+  nickname.
+- **`SOURCE` and `REV` are noise**, beside `PRODID`. Monica writes
+  both on every card: where the card came from and when it last
+  changed there. Counted, they would mark every row of a Monica file
+  with a loss nobody is going to copy into a card.
+
+## Non-goals
+
+- **Monica 5, and Monica's API.** Monica 5's CardDAV `.vcf` imports
+  as any `.vcf` does. Its CardDAV server serves groups as cards
+  (`KIND:group`); a file that carries them is not read as groups yet,
+  and they arrive as rows of their own.
+- **A second import of the same export.** As with a `.vcf`, an export
+  carries no id this server minted; the match beside each card is
+  what stands between a second upload and a second set of contacts.
diff --git a/lib/pro_tacts/admin/import_upload.rb b/lib/pro_tacts/admin/import_upload.rb
index 5691380..0dac586 100644
--- a/lib/pro_tacts/admin/import_upload.rb
+++ b/lib/pro_tacts/admin/import_upload.rb
@@ -11,7 +11,9 @@ module ProTacts
     # on a Mac and carrying a plan to a host over HTTP; now it is the
     # file that Mac already knows how to export.
     #
-    # The form takes one .vcf and nothing else. Looking over what is
+    # The form takes one file: a .vcf, or the JSON Monica 4 exports an
+    # account as (Import::Monica), which is read as the .vcf it composes.
+    # Looking over what is
     # in it needs the file open, so that is the next screen's
     # (ImportReview, over the walk's list of its contacts and the pair
     # of cards a row there opens).
@@ -38,8 +40,8 @@ module ProTacts
           render RecordCard.new(heading: "Import contacts", submit: "Import", form: FORM) do
             form(action: "/import", method: "post", enctype: "multipart/form-data", id: FORM) do
               label(class: "field") do
-                plain "vcard file"
-                input(type: "file", name: "vcf", accept: ".vcf,text/vcard")
+                plain "vcard file or Monica export"
+                input(type: "file", name: "vcf", accept: ".vcf,text/vcard,.json,application/json")
               end
             end
           end
diff --git a/lib/pro_tacts/import/merge.rb b/lib/pro_tacts/import/merge.rb
index 76dc0b1..7ba1351 100644
--- a/lib/pro_tacts/import/merge.rb
+++ b/lib/pro_tacts/import/merge.rb
@@ -14,9 +14,9 @@ module ProTacts
     # the arriving card adds only what the contact does not hold yet. A
     # phone, an email or an address comes in beside the ones the
     # contact has, unless it is one of them. A name, a nickname, a
-    # note, a photo or a birthday — what a contact holds one of — comes
-    # in only where the contact has none, and where the two differ the
-    # book's own stands. What that leaves behind is #left, struck on
+    # note, a photo, an organization or a birthday — what a contact
+    # holds one of — comes in only where the contact has none, and
+    # where the two differ the book's own stands. What that leaves behind is #left, struck on
     # the card as exported the way every other line not coming in is
     # (Admin::ImportOriginal), so nothing is lost without having been
     # shown first.
@@ -40,7 +40,7 @@ module ProTacts
 
       # What a contact holds one of: an arriving one fills a gap, and
       # is left behind where the contact has its own.
-      ONE = %w[N FN NICKNAME NOTE PHOTO].freeze #: Array[String]
+      ONE = %w[N FN NICKNAME NOTE PHOTO ORG X-ABSHOWAS].freeze #: Array[String]
 
       # The envelope, which the contact has its own of — its UID above
       # all, the arriving one naming a record in some other book
diff --git a/lib/pro_tacts/import/monica.rb b/lib/pro_tacts/import/monica.rb
new file mode 100644
index 0000000..0fbd108
--- /dev/null
+++ b/lib/pro_tacts/import/monica.rb
@@ -0,0 +1,327 @@
+require "json"
+
+require "pro_tacts/birthday"
+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).
+    #
+    # Monica's own vCards leave out most of what Monica is for — notes,
+    # relationships, pets — and its JSON export is the one place those
+    # come out whole. So this composes a card per contact out of it, and
+    # everything after the upload is the walk as it was: the composed
+    # file is staged as the import's original, pared by Vcf.read, and
+    # read beside the editor a contact at a time.
+    #
+    # A composed card carries everything, not only what this book
+    # reads. What Monica knows and no screen here shows goes in as an
+    # X-MONICA- line, struck on the card as exported like any other
+    # line not coming in: the left-hand card is the whole record, so
+    # nothing is lost without having been shown first. What reads as
+    # prose about the person — the notes, the relationships, the pets —
+    # is joined into the one NOTE instead, that being the one place in
+    # a contact that holds prose.
+    #
+    # The shapes read here are Monica's own export resources
+    # (app/ExportResources on its 4.x branch): each record is its
+    # columns, a `properties` object, and a `data` array of collections
+    # `{count, type, values}` named by the resource they hold.
+    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]
+
+      # 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. A contact Monica only knows by name — `is_partial`,
+      # the relative added from someone else's page — is not a card of
+      # its own; it is a name in the relationships of the contacts it
+      # is related to.
+      #: (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"] #: 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"])
+        lines << text("ORG", properties["company"])
+        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))
+        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: a whole date, or a month and day when Monica was told
+      # the year is unknown. One Monica worked out from an age holds a
+      # year it guessed and a day it made up, which no card can spell
+      # truthfully, so it stays behind as a line of its own for the
+      # importer to read and enter on the contact's own page.
+      #: (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?
+
+        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?
+      def self.contact_field(field, type)
+        value = field["data"].to_s.strip
+        return nil if value.empty?
+
+        case type["type"]
+        when "phone" then "TEL:#{VCard.escape(value)}"
+        when "email" then "EMAIL;type=INTERNET:#{VCard.escape(value)}"
+        else "X-SOCIALPROFILE;type=#{type["name"].to_s.gsub(/[^\w-]/, "")}:#{VCard.escape(value)}"
+        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 or a link to someone else's server is
+      # not a picture this export holds.
+      #: (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?
+
+        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.
+      #: (Hash[String, untyped] contact, Hash[String, Hash[String, untyped]] activities) -> Array[String]
+      def self.left_behind(contact, activities)
+        properties = props(contact)
+        lines = [] #: Array[String?]
+        deceased = date_of(properties["deceased_date"])
+        lines << "X-MONICA-DECEASED:#{deceased || "yes"}" if properties["is_dead"]
+
+        {
+          "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
diff --git a/lib/pro_tacts/import/vcf.rb b/lib/pro_tacts/import/vcf.rb
index ec5248f..e0f668d 100644
--- a/lib/pro_tacts/import/vcf.rb
+++ b/lib/pro_tacts/import/vcf.rb
@@ -57,7 +57,9 @@ module ProTacts
 
       # Unknown, and not worth saying so. PRODID names the program
       # that wrote the file rather than anything about the person, and
-      # every export carries one: counted as a loss it would put a
+      # every export carries one; SOURCE and REV, which Monica writes
+      # on every card, are where the card came from and when it last
+      # changed there — the export's facts again. Counted as a loss it would put a
       # line in every file's summary and a mark on every contact's
       # row, for a fact nobody is going to copy into a card. What goes
       # quiet is the asking, not the dropping — the line still does
@@ -65,7 +67,14 @@ module ProTacts
       # (Admin::ImportOriginal), because that card is the file's own
       # bytes and a line shown plain there would be a line claiming to
       # arrive.
-      NOISE = %w[PRODID].freeze #: Array[String]
+      NOISE = %w[PRODID SOURCE REV].freeze #: Array[String]
+
+      # Tags (RFC 2426 section 3.6.1), which come in as groups rather
+      # than as a line: the walk ticks a group of each name beside the
+      # card (Web#import_picker), so what the line said arrives, and
+      # the line itself, struck on the card as exported, is not a loss
+      # to count.
+      CATEGORIES = "CATEGORIES" #: String
 
       # What an import makes of one card: the card as it will come in,
       # and the lines it leaves behind. Both, because the review
@@ -143,7 +152,7 @@ module ProTacts
             next if property.nil?
 
             name = property.name.upcase
-            next if known?(name) || noise?(name)
+            next if known?(name) || noise?(name) || name == CATEGORIES
 
             (seen[name] ||= []) << summary(line)
           end
@@ -176,10 +185,20 @@ module ProTacts
       def self.losses(dropped)
         dropped.reject { |line|
           property = line.property
-          !property.nil? && noise?(property.name)
+          !property.nil? && (noise?(property.name) || property.name.casecmp?(CATEGORIES))
         }
       end
 
+      # The tags a card carries, as the names of the groups they come
+      # in as: every CATEGORIES line's comma-separated values, unescaped
+      # (RFC 2426 section 2.4.2), blank ones and repeats left out.
+      #: (VCard card) -> Array[String]
+      def self.categories(card)
+        card.properties.select { it.name.casecmp?(CATEGORIES) }.flat_map { |property|
+          property.value.split(/(?<!\\),/).map { VCard.unescape(it).strip }
+        }.reject(&:empty?).uniq
+      end
+
       # One card as this book will hold it, beside what that costs.
       # The kept lines are the bytes that arrived, in the order they
       # arrived, so a card comes in as the card it was; the dropped
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index 15bdbc3..131de86 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -10,6 +10,7 @@ require "pro_tacts/admin/import_upload"
 require "pro_tacts/birthday"
 require "pro_tacts/import/match"
 require "pro_tacts/import/merge"
+require "pro_tacts/import/monica"
 require "pro_tacts/import/write"
 require "pro_tacts/import/staged"
 require "pro_tacts/import/vcf"
@@ -88,15 +89,16 @@ module ProTacts
 
     private
 
-    # The upload's own POST: the file read and judged whole before
-    # anything is staged, the old `execute`'s rule that a source which
-    # will not read brings in nothing. What it stages is both readings —
+    # The upload's own POST: the file — a .vcf, or a Monica export
+    # read as one — read and judged whole before anything is staged,
+    # the old `execute`'s rule that a source which will not read
+    # brings in nothing. What it stages is both readings —
     # the file as it arrived, and the cards pared to what this book
     # shows — and then it is the walk's first card.
     #: (untyped r) -> untyped
     def stage_upload(r)
       upload = file_in(r.params["vcf"])
-      return import_screen(notice: "Choose a .vcf file to import.") if upload.nil?
+      return import_screen(notice: "Choose a .vcf or Monica export to import.") if upload.nil?
 
       name = File.basename(upload[:filename].to_s)
       # Relabelled and judged in the same breath, Web#write_card's rule
@@ -107,6 +109,11 @@ module ProTacts
       return import_screen(notice: "#{name} is not UTF-8 text.") unless bytes.valid_encoding?
 
       begin
+        # A Monica export is read as the cards it composes, and from
+        # here on is a .vcf like any other: the composed file is what
+        # the walk stages as the import's original and strikes lines
+        # out of (Import::Monica).
+        bytes = Import::Monica.vcf(bytes) if Import::Monica.export?(bytes)
         cards = Import::Vcf.cards(bytes)
       rescue Import::Vcf::Invalid => error
         return import_screen(notice: error.message)
@@ -255,7 +262,7 @@ 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),
+        fields: import_picker(group, joined, named, member: target, tags: Import::Vcf.categories(original)),
         lead: import_target(upload, index, matches, target),
         sidebar:,
       )
@@ -332,18 +339,28 @@ module ProTacts
     # save moves only what was toggled. The group for the import is
     # ticked beside them all the same, so the group the walk ends on
     # lists every contact it touched.
-    #: (String group, Array[String]? joined, Array[String]? named, ?member: Contact?) -> Admin::ImportGroups
-    def import_picker(group, joined, named, member: nil)
+    #
+    # The card's own tags (CATEGORIES, which Monica writes a contact's
+    # tags as) are ticked too: a group of that name where the book has
+    # one, and a name for the Save to make where it does not
+    # (Import::Write#group_id), so an import from a book that sorted
+    # its people by tag arrives sorted the same way. A tag spelling a
+    # sync group is left alone — which phones a card goes out to is
+    # the one box here a tag should not be able to tick.
+    #: (String group, Array[String]? joined, Array[String]? named, ?member: Contact?, ?tags: Array[String]) -> Admin::ImportGroups
+    def import_picker(group, joined, named, member: nil, tags: [])
       choices = store.group_choices
       lot = group.empty? ? nil : choices.find { it.name == group }
       everyone = choices.find { it.name == Group::EVERYONE }
       none = [] #: Array[String]
       was = member ? store.groups_of(member.id).map(&:id) : none
       ticked = member ? was : [everyone&.id].compact
+      tags = tags.reject { Group.sync_name?(it) }
+      tagged = choices.select { tags.include?(it.name) }
       Admin::ImportGroups.new(
         groups: choices,
-        joined: joined || [*ticked, lot&.id].compact.uniq,
-        named: named || [(group unless lot || group.empty?)].compact,
+        joined: joined || [*ticked, lot&.id, *tagged.map(&:id)].compact.uniq,
+        named: named || [(group unless lot || group.empty?), *(tags - tagged.map(&:name))].compact.uniq,
         was:,
       )
     end
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index 3a2f60f..d274eef 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -1,5 +1,6 @@
 require_relative "../../test_helper"
 
+require "json"
 require "pathname"
 require "rack/test"
 require "tmpdir"
@@ -113,7 +114,7 @@ class AdminImportPagesTest < Minitest::Test
 
     assert_equal 200, last_response.status
     assert_includes last_response.body, %(<form action="/import" method="post" enctype="multipart/form-data")
-    assert_includes last_response.body, %(<input type="file" name="vcf" accept=".vcf,text/vcard">)
+    assert_includes last_response.body, %(<input type="file" name="vcf" accept=".vcf,text/vcard,.json,application/json">)
   end
 
   # Straight into the walk: a list built from a file nobody has looked
@@ -1099,7 +1100,7 @@ class AdminImportPagesTest < Minitest::Test
     with_contacts({}) do |store|
       post "/import"
 
-      assert_includes last_response.body, "Choose a .vcf file to import."
+      assert_includes last_response.body, "Choose a .vcf or Monica export to import."
       assert_empty store.changes
     end
   end
@@ -1125,4 +1126,60 @@ class AdminImportPagesTest < Minitest::Test
       assert_empty store.changes
     end
   end
+
+  # A Monica export is read as the cards it composes, and is a walk like
+  # any other from there (Import::Monica).
+  def test_a_monica_export_opens_the_walk_on_its_contacts
+    export = JSON.generate({
+      "account" => {"data" => [{"count" => 1, "type" => "contact", "values" => [
+        {"uuid" => "u", "properties" => {"first_name" => "Jane", "last_name" => "Booles", "job" => "Welder"},
+         "data" => [{"count" => 1, "type" => "note", "values" => [{"properties" => {"body" => "Likes jigsaws."}}]}]},
+      ]}]},
+    })
+    with_contacts({}) do |store|
+      id = upload(export, name: "monica.json")
+      follow_redirect!
+
+      assert_equal 200, last_response.status
+      assert_includes last_response.body, "Jane Booles"
+      assert_includes last_response.body, "Likes jigsaws."
+      assert_includes last_response.body, "TITLE:Welder"
+
+      save(id, 0, first: "Jane", last: "Booles", "note" => "Likes jigsaws.")
+
+      assert_equal ["Likes jigsaws."], store.contacts.fetch(0).notes.map(&:value)
+    end
+  end
+
+  # A card's tags are ticked beside it as groups: the book's own where
+  # it has one of that name, and a name to make where it does not.
+  def test_a_cards_tags_are_ticked_as_its_groups
+    tagged = PLAIN.sub("UID:", "CATEGORIES:school,book club\r\nUID:")
+    with_contacts({}) do |store|
+      school = store.create_group(name: "school")
+      id = upload(tagged)
+      follow_redirect!
+
+      assert_includes last_response.body, %(<input type="checkbox" name="groups[]" value="#{school}" checked>)
+      assert_includes last_response.body, %(<input type="checkbox" name="named[]" value="book club" checked>)
+
+      save(id, 0, first: "Sam", last: "Booles")
+      sam = store.contacts.fetch(0).id
+
+      assert_equal [sam], store.group(school).members
+      assert_equal [sam], store.all_groups.find { it.name == "book club" }.members
+    end
+  end
+
+  # Which phones a card goes out to is not a tag's to decide.
+  def test_a_tag_spelling_a_sync_group_ticks_nothing
+    tagged = PLAIN.sub("UID:", "CATEGORIES:sync:*\r\nUID:")
+    with_contacts({}) do |_store|
+      upload(tagged)
+      follow_redirect!
+
+      refute_includes last_response.body, %(name="named[]" value="sync:*")
+    end
+  end
+
 end
diff --git a/test/pro_tacts/import/test_monica.rb b/test/pro_tacts/import/test_monica.rb
new file mode 100644
index 0000000..fae8a03
--- /dev/null
+++ b/test/pro_tacts/import/test_monica.rb
@@ -0,0 +1,213 @@
+require_relative "../../test_helper"
+
+require "json"
+
+require "pro_tacts/import/monica"
+require "pro_tacts/import/vcf"
+
+# Reading Monica 4's JSON export as the .vcf the walk already imports
+# (docs/plans/2026-09-25-importing-from-monica.md). The export here is
+# spelled the way Monica's export resources write one
+# (app/ExportResources on its 4.x branch): a record's columns, its
+# `properties`, and its `data` as `{count, type, values}` collections.
+class ImportMonicaTest < Minitest::Test
+  Monica = ProTacts::Import::Monica
+  Vcf = ProTacts::Import::Vcf
+
+  PHONE = "type-phone"
+  EMAIL = "type-email"
+  TWITTER = "type-twitter"
+
+  # A red pixel, as the data URL Monica's Photo resource writes.
+  PIXEL = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFBQIAX8jx0gAAAABJRU5ErkJggg=="
+
+  def collection(type, values)
+    {"count" => values.length, "type" => type, "values" => values}
+  end
+
+  def jane(**properties)
+    {
+      "uuid" => "jane-uuid",
+      "properties" => {
+        "first_name" => "Jane", "last_name" => "Booles", "nickname" => "JB",
+        "company" => "Acme", "job" => "Welder",
+        "description" => "Met at the co-op.",
+        "food_preferences" => "No cilantro",
+        "tags" => ["school", "book club"],
+        "avatar" => {"avatar_source" => "photo", "avatar_photo" => "photo-uuid"},
+        "birthdate" => {"is_age_based" => false, "is_year_unknown" => false,
+                        "date" => "1980-05-02T00:00:00.000000Z"},
+        "first_met_through" => "sam-uuid",
+        **properties,
+      },
+      "data" => [
+        collection("contact_field", [
+          {"uuid" => "f1", "properties" => {"data" => "+1 555 0100", "type" => PHONE}},
+          {"uuid" => "f2", "properties" => {"data" => "jane@example.com", "type" => EMAIL}},
+          {"uuid" => "f3", "properties" => {"data" => "janeb", "type" => TWITTER}},
+        ]),
+        collection("note", [
+          {"uuid" => "n1", "properties" => {"body" => "Likes jigsaws."}},
+          {"uuid" => "n2", "properties" => {"body" => "Owes us a casserole dish."}},
+        ]),
+        collection("address", [
+          {"uuid" => "a1", "properties" => {"name" => "Home", "street" => "1 Main St", "city" => "Springfield",
+                                            "province" => "IL", "postal_code" => "62701", "country" => "US"}},
+        ]),
+        collection("pet", [{"uuid" => "p1", "properties" => {"name" => "Rex", "category" => "dog"}}]),
+        collection("reminder", [{"uuid" => "r1", "properties" => {"title" => "Call about the dish",
+                                                                  "initial_date" => "2026-10-01T00:00:00Z"}}]),
+        collection("activity", ["act-uuid"]),
+      ],
+    }
+  end
+
+  def sam
+    {"uuid" => "sam-uuid", "properties" => {"first_name" => "Sam", "last_name" => "Booles"}}
+  end
+
+  # Known by name only, from Jane's page: a relative, not a contact.
+  def aunt
+    {"uuid" => "aunt-uuid", "properties" => {"first_name" => "Edna", "is_partial" => true}}
+  end
+
+  def export(*contacts, relationships: [])
+    JSON.generate({
+      "version" => "1.0-preview.1",
+      "account" => {
+        "uuid" => "account",
+        "data" => [
+          collection("contact", contacts),
+          collection("relationship", relationships),
+          collection("photo", [{"uuid" => "photo-uuid",
+                                "properties" => {"dataUrl" => "data:image/png;base64,#{PIXEL}"}}]),
+          collection("activity", [{"uuid" => "act-uuid",
+                                   "properties" => {"summary" => "Picnic", "happened_at" => "2026-06-01T00:00:00Z"}}]),
+        ],
+        "instance" => {
+          "contact_field_types" => [
+            {"uuid" => PHONE, "properties" => {"name" => "Phone", "type" => "phone"}},
+            {"uuid" => EMAIL, "properties" => {"name" => "Email", "type" => "email"}},
+            {"uuid" => TWITTER, "properties" => {"name" => "Twitter"}},
+          ],
+        },
+      },
+    })
+  end
+
+  def relationship(from, type, to)
+    {"uuid" => "#{from}-#{to}", "properties" => {"type" => type, "contact_is" => from, "of_contact" => to}}
+  end
+
+  def card_for(bytes, name)
+    Vcf.cards(Monica.vcf(bytes)).find { it.property("FN")&.text == name }
+  end
+
+  def test_an_export_is_told_from_a_vcf_by_its_first_character
+    assert Monica.export?("\n  {\"account\": {}}")
+    refute Monica.export?("BEGIN:VCARD\r\n")
+  end
+
+  def test_each_contact_becomes_a_card_the_walk_reads
+    cards = Vcf.cards(Monica.vcf(export(jane, sam)))
+
+    assert_equal ["Jane Booles", "Sam Booles"], cards.map { it.property("FN").text }
+    assert(cards.all?(&:card?))
+  end
+
+  def test_the_fields_this_book_shows_come_in
+    card = Vcf.read(card_for(export(jane, sam), "Jane Booles")).card
+
+    assert_equal "Booles;Jane;;;", card.property("N").value
+    assert_equal "JB", card.property("NICKNAME").text
+    assert_equal "Acme", card.property("ORG").text
+    assert_equal "1980-05-02", card.property("BDAY").value
+    assert_equal "+1 555 0100", card.property("TEL").text
+    assert_equal "jane@example.com", card.property("EMAIL").text
+    assert_equal ";;1 Main St;Springfield;IL;62701;US", card.property("ADR").value
+    assert_equal "Home", card.property("X-ABLabel").text
+    assert_equal PIXEL, card.property("PHOTO").value
+  end
+
+  # One NOTE, the invariant a contact keeps
+  # (docs/plans/2026-09-25-one-note-per-contact.md): the prose first,
+  # then what Monica keeps as records, spelled out.
+  def test_the_prose_joins_into_the_one_note
+    rels = [relationship("jane-uuid", "spouse", "sam-uuid"), relationship("jane-uuid", "bestfriend", "aunt-uuid")]
+    card = card_for(export(jane, sam, aunt, relationships: rels), "Jane Booles")
+    notes = card.properties.select { it.name == "NOTE" }
+
+    assert_equal 1, notes.length
+    assert_equal <<~NOTE.chomp, notes.fetch(0).text
+      Met at the co-op.
+
+      Likes jigsaws.
+
+      Owes us a casserole dish.
+
+      Spouse: Sam Booles
+      Best friend: Edna
+
+      Pet: Rex (dog)
+
+      Food preferences: No cilantro
+
+      Met through Sam Booles
+    NOTE
+  end
+
+  def test_a_relative_known_only_by_name_is_not_a_card
+    names = Vcf.cards(Monica.vcf(export(jane, aunt))).map { it.property("FN").text }
+
+    assert_equal ["Jane Booles"], names
+  end
+
+  # What no screen here shows is on the card as exported, to be struck
+  # there and copied by hand where it is worth it.
+  def test_what_this_book_does_not_show_is_left_behind_in_view
+    dropped = Vcf.read(card_for(export(jane), "Jane Booles")).dropped.map { Vcf.summary(it) }
+
+    assert_includes dropped, "TITLE:Welder"
+    assert_includes dropped, "X-SOCIALPROFILE;type=Twitter:janeb"
+    assert_includes dropped, "X-MONICA-REMINDER:2026-10-01 — Call about the dish"
+    assert_includes dropped, "X-MONICA-ACTIVITY:2026-06-01 — Picnic"
+  end
+
+  def test_tags_arrive_as_the_card_s_categories
+    card = card_for(export(jane), "Jane Booles")
+
+    assert_equal ["school", "book club"], Vcf.categories(card)
+  end
+
+  def test_a_birthday_without_its_year_is_one_a_card_can_spell
+    card = card_for(export(jane("birthdate" => {"is_year_unknown" => true, "date" => "2020-05-02T00:00:00Z"})),
+                    "Jane Booles")
+
+    assert_equal "1604-05-02", card.property("BDAY").value
+  end
+
+  # Worked out from an age: a year Monica guessed and a day it made up.
+  def test_a_birthday_monica_guessed_from_an_age_is_left_behind
+    card = card_for(export(jane("birthdate" => {"is_age_based" => true, "date" => "1980-01-01T00:00:00Z"})),
+                    "Jane Booles")
+
+    assert_nil card.property("BDAY")
+    assert_equal "about 1980", card.property("X-MONICA-AGE-BASED-BIRTHDAY").text
+  end
+
+  def test_json_that_is_not_a_monica_export_is_refused
+    error = assert_raises(Vcf::Invalid) { Monica.vcf("{\"contacts\": []}") }
+
+    assert_includes error.message, "not a Monica export"
+  end
+
+  def test_a_file_that_will_not_parse_is_refused
+    assert_raises(Vcf::Invalid) { Monica.vcf("{not json") }
+  end
+
+  def test_an_export_of_nobody_is_refused
+    error = assert_raises(Vcf::Invalid) { Monica.vcf(export(aunt)) }
+
+    assert_includes error.message, "no contacts"
+  end
+end
diff --git a/test/pro_tacts/import/test_vcf.rb b/test/pro_tacts/import/test_vcf.rb
index 1175783..714958c 100644
--- a/test/pro_tacts/import/test_vcf.rb
+++ b/test/pro_tacts/import/test_vcf.rb
@@ -216,4 +216,58 @@ class ImportVcfTest < Minitest::Test
     assert_includes reading.card.to_s, "not a content line\r\n"
     assert_empty reading.dropped.select { it.property.nil? }
   end
+
+  # A company card comes in whole (docs/plans/2026-09-24-company-cards.md):
+  # its editor reads the name out of ORG, and the flag says how to show it.
+  def test_a_company_comes_in_with_its_organization_and_its_flag
+    card = Vcf.cards(<<~CARD.gsub("\n", "\r\n")).fetch(0)
+      BEGIN:VCARD
+      VERSION:3.0
+      N:;;;;
+      FN:Acme
+      ORG:Acme;
+      X-ABShowAs:COMPANY
+      END:VCARD
+    CARD
+    reading = Vcf.read(card)
+
+    assert_includes reading.card.to_s, "ORG:Acme;\r\n"
+    assert_includes reading.card.to_s, "X-ABShowAs:COMPANY\r\n"
+    assert_empty reading.dropped
+  end
+
+  # Where a card came from and when it last changed there, which Monica
+  # writes on every card: the export's facts, dropped without remark.
+  def test_source_and_rev_are_noise
+    card = Vcf.cards(<<~CARD.gsub("\n", "\r\n")).fetch(0)
+      BEGIN:VCARD
+      VERSION:4.0
+      FN:Jane Booles
+      SOURCE:https://monica.example.com/people/h:abc
+      REV:2026-09-01T00:00:00Z
+      END:VCARD
+    CARD
+    reading = Vcf.read(card)
+
+    assert_equal %w[SOURCE REV], reading.dropped.map { it.property&.name }
+    assert_empty Vcf.losses(reading.dropped)
+  end
+
+  # Tags come in as groups, so the line that carried them is neither a
+  # property to survey nor a loss to count.
+  def test_categories_are_read_as_group_names_and_counted_as_no_loss
+    card = Vcf.cards(<<~CARD.gsub("\n", "\r\n")).fetch(0)
+      BEGIN:VCARD
+      VERSION:3.0
+      FN:Jane Booles
+      CATEGORIES:school,book club,school
+      CATEGORIES:Smith\\, Jones
+      END:VCARD
+    CARD
+
+    assert_equal ["school", "book club", "Smith, Jones"], Vcf.categories(card)
+    assert_empty Vcf.losses(Vcf.read(card).dropped)
+    assert_empty Vcf.unknown([card])
+  end
+
 end