import: carry the rest of a contact, and its source with it
A card holds the note, the nickname, the picture and the rest, and the source it was read from below,
so the fields and the original sit in one file, and backups/ is a directory the plan no longer knows.
A spouse arrives under the note, since a relation has no field of its own to fill,
and a group is judged whole: dropping item1.URL drops the country code and the label still.

Assisted-by: Claude Opus 5 via Claude Code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Issue: rtqvmzvp
change xwwytlmontuqomnpzutzyyxlxsymttsl
commit 64e6bc4239cd65d4d3209ad27a7f5651406efad6
author Alpha Chen <alpha@kejadlen.dev>
date
parent nmkmsstt
diff --git a/docs/plans/2026-09-16-importing-from-macos.md b/docs/plans/2026-09-16-importing-from-macos.md
index af1d59b..d0893cb 100644
--- a/docs/plans/2026-09-16-importing-from-macos.md
+++ b/docs/plans/2026-09-16-importing-from-macos.md
@@ -14,11 +14,9 @@ holds every contact in full:
 | Path | Holds |
 |---|---|
 | `plan.yml` | The source, when it was built, the group, and each contact's minted id beside its source id; then the host and each contact's status |
-| `cards/<id>.yml` | The contact's fields and the groups it belongs to, for a person to edit before `execute` |
-| `backups/<id>/` | The original, in whatever files the source reads it as |
+| `cards/<id>.yml` | The contact's fields, the groups it belongs to, and the source it was read from, for a person to edit before `execute` |
 
-The builder writes the cards and backups once, and no code rewrites
-them. A person can: a card file is the plan's one open question, read as
+The builder writes the cards once, and no code rewrites them. A person can: a card file is the plan's one open question, read as
 it stands when `execute` carries it.
 
 `plan.yml` also records how far `execute` and `remove` have carried the
@@ -88,25 +86,59 @@ first keeps a rerun from collecting refusals, each of which warns Sentry
 
 ## A card is a form filled in
 
-A card file holds what the web editor would: a first and last name and
-a list of phone numbers, plus the names of the groups it belongs to. The plan
-writes `sync:*` and its own `import-<timestamp>` group into every card,
-ahead of any the builder chose.
+A card file holds what the web editor would: a first and last name, a
+nickname, a birthday, phone numbers, email addresses, addresses, a note,
+and whether to carry the picture, plus the names of the groups it
+belongs to. Under `source` it also holds what the reader gave — the
+identifier, the vCard, the note, and every fetched key — so the fields
+and the original are read together, and `remove` compares the Mac's
+contact against the same file. The plan writes `sync:*` and its own
+`import-<timestamp>` group into every card, ahead of any the builder
+chose.
 
 ```yaml
 first: Ada
 last: Lovelace
+birthday: '1815-12-10'
 phones:
 - "+12532189075"
+emails:
+- ada@example.com
+addresses:
+- street: 12 Marylebone Rd
+  locality: London
+  postal_code: NW1 5LS
+note: An analyst.
+photo: true
 groups:
 - sync:*
 - import-20260916T180412Z
+source:
+  identifier: A:ABPerson
+  vcard: "BEGIN:VCARD\r\n…"
+  note: An analyst.
+  contact:
+    imageData: /9j/…
 ```
 
 `Import::Card` turns the fields into a contact through the web's own
-code: `CardForm.new_card`, then `CardForm.contact_card` with each phone
-as an add row. A phone's types do not survive, because the form has no
-field for them, so a number lands as a bare `TEL`.
+code: `CardForm.new_card`, then `CardForm.contact_card` with each phone,
+email, and address as an add row. Types do not survive, because the form
+has no field for them, so a number lands as a bare `TEL` and a home
+address as a bare `ADR`. The birthday rides beside the card rather than
+in it, which is where the store keeps one
+(`2026-09-11-every-birthday-in-the-model.md`); `Contact` composes the
+`BDAY` line back in, and the PUT hands it to the store, which takes it
+out again.
+
+The picture is the one field with no value of its own: `photo` says
+whether to carry what `source` holds, and the line is written from
+`imageData` with the TYPE its magic bytes name, the way Contact#photo
+reads one back. A `photo: true` the source cannot supply is refused.
+
+An address holds the editor's own parts — extended, street, locality,
+region, postal code, country — and no post office box, which no screen
+shows either.
 
 A file that will not read as a card is refused, naming the file: a
 missing or unknown key, a name with neither half, or a value YAML reads
@@ -159,8 +191,8 @@ The reader asks for an explicit list of keys and fails if any comes back
 unavailable, so the next silent strip stops the read rather than a
 contact losing a field.
 
-A contact's backup is the serialized vCard, every fetched key as JSON
-with image data in base64, and the AppleScript note.
+What a card keeps of its source is the serialized vCard, every fetched
+key with image data in base64, and the AppleScript note.
 
 ## The builder knows only what it was taught
 
@@ -183,8 +215,28 @@ The `case` starts empty. A property earns a branch when a build refuses
 it, and the branch is written with that property in view, so nothing is
 imported on the strength of a guess about what it holds. Parameter
 values are part of the form, so `TYPE=IPHONE` is refused until a branch
-names it. A property whose value has a shape can check it too: `TEL`
-takes only a `+` and digits.
+names it. Two spellings are read as one: `item1.` and `item2.`, since
+the number counts a card's groups rather than saying anything about the
+line, and a trailing `type=pref`, which ranks a line rather than naming
+a kind — `Contact#types_of` drops it for that reason, and a form is
+listed without it.
+
+Some properties are read and dropped rather than refused, this address
+book having no field for any of them: `IMPP`, `URL`, `X-SOCIALPROFILE`,
+`X-APPLE-SUBADMINISTRATIVEAREA`, and `X-AIM`. `X-ABLabel` is dropped for
+a related reason — it labels whichever line shares its `item` group, and
+every line that takes one is itself dropped or reads the label. A card's
+`source` still holds every one of their lines.
+
+`X-ABRELATEDNAMES` has no field either and is not dropped: each related
+name is written under the note as "<label>: <name>", the label being the
+`X-ABLabel` of its group, unwrapped from Apple's `_$!<Spouse>!$_`. A
+spouse's name is worth more in the note than nowhere.
+
+A property whose value has a shape is checked against it: `TEL` takes a
+`+` and digits, `BDAY` a whole date, `EMAIL` an address with an `@`, and
+`ADR` the seven components RFC 2426 section 3.2.1 gives it, with the post
+office box empty.
 
 A field holds less than a line can, so a line that would lose something
 on the way into one is unknown too. The name comes from the one `N`,
@@ -202,7 +254,7 @@ first asks that host's card browser, `GET /contacts/<id>`, whether it
 still has the card — the DAV collection serves the asking user's book
 alone, and a card in no `sync:` group is on the host and in no book —
 then reads the contact again through
-`macos-contacts.swift show` and compares it to its backup, note
+`macos-contacts.swift show` and compares it to the card's `source`, note
 included, and keeps it, printing why, if either check
 fails. A contact edited on the Mac since the plan is kept, and so
 is one whose card is no longer on the host. A contact already gone from
diff --git a/lib/pro_tacts/import/card.rb b/lib/pro_tacts/import/card.rb
index 0006079..d8aeddb 100644
--- a/lib/pro_tacts/import/card.rb
+++ b/lib/pro_tacts/import/card.rb
@@ -1,27 +1,48 @@
+require "base64"
 require "yaml"
 
 require "pro_tacts/admin/card_form"
+require "pro_tacts/birthday"
 require "pro_tacts/contact"
 
 module ProTacts
   module Import
     # One contact in a plan, as the file a person edits before execute:
-    # the fields the web editor has, and the groups to join
+    # the fields the web editor has, and the groups it belongs to
     # (docs/plans/2026-09-16-importing-from-macos.md, "A card is a form
     # filled in"). Signed in sig/pro_tacts/import.rbs, being a Data class.
     # @rbs skip
-    Card = Data.define(:first, :last, :phones, :groups)
+    Card = Data.define(:first, :last, :nickname, :birthday, :phones, :emails, :addresses, :note, :photo, :groups, :source)
 
     # Reopened for the reason VCard::Parser::Property is.
     class Card
       # A card file that will not read as one.
       class Invalid < StandardError; end
 
-      KEYS = %w[first last phones groups].freeze #: Array[String]
+      KEYS = %w[first last nickname birthday phones emails addresses note photo groups source].freeze #: Array[String]
+
+      # What the source gave, kept in the card rather than off to the
+      # side: the fields above are read out of it, and `remove` compares
+      # the Mac's contact against it before deleting anything, so the two
+      # are read together and edited in one place.
+      SOURCE_KEYS = %w[identifier vcard note contact].freeze #: Array[String]
+
+      # An address's parts, as the editor's own add row names them
+      # (Admin::CardForm's ADDRESS_COMPONENTS, which is private to it).
+      # No post office box: no screen shows one either.
+      ADDRESS_KEYS = %w[extended street locality region postal_code country].freeze #: Array[String]
+
+      BIRTHDAY = /\A(\d{4})-(\d{2})-(\d{2})\z/ #: Regexp
+
+      # A picture's format as `PHOTO`'s TYPE names it, read off the
+      # decoded bytes the way Contact#photo reads one back: the magic
+      # bytes cannot mislabel what they are.
+      PHOTO_TYPES = {"image/jpeg" => "JPEG", "image/png" => "PNG", "image/gif" => "GIF"}.freeze #: Hash[String, String]
 
       # Every problem is refused rather than skipped: a key this does not
       # know, or a value YAML read as something other than text (an
-      # unquoted `no` is false), is an edit that would otherwise vanish.
+      # unquoted `no` is false, and an unquoted 1815-12-10 a Date), is an
+      # edit that would otherwise vanish.
       #: (Pathname path) -> Card
       def self.read(path)
         document = YAML.safe_load_file(path)
@@ -33,13 +54,25 @@ module ProTacts
         unknown = document.keys - KEYS
         invalid.("has #{unknown.join(", ")}, which no field takes") unless unknown.empty?
 
-        first, last, phones, groups = document.values_at(*KEYS)
+        first, last, nickname, birthday, phones, emails, addresses, note, photo, groups, source = document.values_at(*KEYS)
         invalid.("first and last must be text; quote them") unless first.is_a?(String) && last.is_a?(String)
         invalid.("needs a first or last name") if first.strip.empty? && last.strip.empty?
+        invalid.("nickname must be text, or blank") unless nickname.nil? || nickname.is_a?(String)
+        invalid.("birthday must be a quoted YYYY-MM-DD, or blank") unless birthday.nil? || birthday.is_a?(String) && birthday.match?(BIRTHDAY)
         invalid.("phones must be a list of quoted numbers") unless texts?(phones)
+        invalid.("emails must be a list of addresses") unless texts?(emails)
+        addresses.is_a?(Array) && addresses.all? { address?(it) } or
+          invalid.("addresses must be a list, each with any of #{ADDRESS_KEYS.join(", ")}")
         invalid.("groups must be a list of names") unless texts?(groups) && groups.none? { it.strip.empty? }
+        invalid.("note must be text, or blank") unless note.nil? || note.is_a?(String)
+        invalid.("photo must be true or false") unless [true, false].include?(photo)
+        invalid.("source must hold #{SOURCE_KEYS.join(", ")}") unless source?(source)
+        card = new(first:, last:, nickname:, birthday:, phones:, emails:, addresses:, note:, photo:, groups:, source:)
+        invalid.("photo is true and the source holds no picture this server knows") if photo && card.picture.nil?
 
-        new(first:, last:, phones:, groups:)
+        card
+      rescue Psych::DisallowedClass => error
+        raise Invalid, "#{path}: #{error.message.sub(/\ATried to load unspecified class: /, "reads as a ")}; quote it"
       end
 
       #: (untyped value) -> bool
@@ -47,20 +80,71 @@ module ProTacts
         value.is_a?(Array) && value.all?(String)
       end
 
-      private_class_method :texts?
+      #: (untyped value) -> bool
+      def self.address?(value)
+        value.is_a?(Hash) && (value.keys - ADDRESS_KEYS).empty? &&
+          value.values.all?(String) && value.values.any? { !it.strip.empty? }
+      end
+
+      #: (untyped value) -> bool
+      def self.source?(value)
+        value.is_a?(Hash) && value.keys.sort == SOURCE_KEYS.sort &&
+          value.fetch("identifier").is_a?(String) && value.fetch("vcard").is_a?(String) &&
+          [NilClass, String].include?(value.fetch("note").class) && value.fetch("contact").is_a?(Hash)
+      end
+
+      private_class_method :texts?, :address?, :source?
 
       # The contact the web create and then the editor's add rows would
-      # make of these fields.
+      # make of these fields. The birthday rides beside the card as the
+      # store keeps it, and Contact composes the line back in
+      # (docs/plans/2026-09-11-every-birthday-in-the-model.md).
       #: (String id) -> Contact
       def contact(id)
         created = Contact.new(id:, stored: Admin::CardForm.new_card(id, first, last), birthday: nil, inherited: [])
-        stored = Admin::CardForm.contact_card(created, first, last, {"new_phone" => phones})
-        Contact.new(id:, stored:, birthday: nil, inherited: [])
+        stored = Admin::CardForm.contact_card(created, first, last, {
+          "nickname" => nickname.to_s, "note" => note.to_s,
+          "new_phone" => phones, "new_email" => emails, "new_address" => addresses
+        })
+        # The picture has no field on any form, the editor showing one
+        # rather than taking one, so its line is written here.
+        stored = stored.insert([picture].compact)
+        Contact.new(id:, stored:, birthday: born_on, inherited: [])
+      end
+
+      # The source's picture as a PHOTO line, or nil for a card that
+      # carries none and for data that decodes to no format the server
+      # knows — which Card.read refuses rather than dropping.
+      #: () -> String?
+      def picture
+        return unless photo
+
+        data = source.fetch("contact")["imageData"] #: untyped
+        return unless data.is_a?(String)
+
+        bytes = begin
+          Base64.strict_decode64(data)
+        rescue ArgumentError
+          return
+        end
+        signature = Contact::PHOTO_SIGNATURES.find { bytes.start_with?(it.first) }
+        return if signature.nil?
+
+        "PHOTO;ENCODING=b;TYPE=#{PHOTO_TYPES.fetch(signature.last)}:#{data}\r\n"
+      end
+
+      #: () -> Birthday?
+      def born_on
+        captures = birthday&.match(BIRTHDAY)&.captures
+        return if captures.nil?
+
+        year, month, day = captures
+        Birthday.new(year: Integer(year), month: Integer(month), day: Integer(day))
       end
 
       #: () -> Hash[String, untyped]
       def document
-        {"first" => first, "last" => last, "phones" => phones, "groups" => groups}
+        KEYS.zip([first, last, nickname, birthday, phones, emails, addresses, note, photo, groups, source]).to_h
       end
     end
   end
diff --git a/lib/pro_tacts/import/macos.rb b/lib/pro_tacts/import/macos.rb
index b211b05..a9dcee1 100644
--- a/lib/pro_tacts/import/macos.rb
+++ b/lib/pro_tacts/import/macos.rb
@@ -111,25 +111,104 @@ module ProTacts
         note = record.fetch("note") #: String?
         contact = record.fetch("contact") #: Hash[String, untyped]
 
-        carried = VCard::Parser.lines(vcard).select { carried?(it, source_id, unknown) }
+        lines = VCard::Parser.lines(vcard)
+        grouped = grouped(lines)
+        carried = lines.select { carried?(it, grouped, source_id, unknown) }
         first, last = name(carried, source_id, unknown)
-        phones = carried.filter_map { it.property&.then { |property| property.text if property.name.casecmp?("TEL") } }
-        # Beyond the vCard: what the serializer leaves out.
-        unknown.add("note", source_id, note) if note
-        image = contact.fetch("imageData") #: String?
-        unknown.add("imageData", source_id, image) if image
+        nickname = only(carried, "NICKNAME", source_id, unknown)
+        birthday = only(carried, "BDAY", source_id, unknown)
+        # A number the card left empty is a row Contacts kept the label
+        # of and nothing else, and an empty add row inserts nothing
+        # (Admin::CardForm).
+        phones = values(carried, "TEL").reject(&:empty?)
+        emails = values(carried, "EMAIL")
+        addresses = properties(carried, "ADR").map { address(it) }
+        source = {"identifier" => source_id, "vcard" => vcard, "note" => note, "contact" => contact}
+        # Beyond the vCard, and carried from the source the card holds:
+        # the note AppleScript gave up, and the picture the serializer
+        # leaves out.
+        photo = !contact.fetch("imageData").nil?
+        card = Card.new(
+          first:, last:, nickname:, birthday:, phones:, emails:, addresses:,
+          note: noted(note, related_names(lines, carried)), photo:, groups: [], source:
+        )
+        Plan::Entry.new(id:, source_id:, card:)
+      end
+
+      # The note, with the people this contact is related to written
+      # under it: this address book has no field for a relation, and a
+      # spouse's name is worth more in the note than nowhere.
+      #: (String? note, Array[String] related) -> String?
+      def self.noted(note, related)
+        return note if related.empty?
 
-        backup = {"original.vcf" => vcard, "contact.json" => JSON.pretty_generate(contact)}
-        backup["note.txt"] = note if note
+        [note, related.join("\n")].compact.reject(&:empty?).join("\n\n")
+      end
+
+      # Each related name as "<label>: <name>", the label being the
+      # X-ABLabel sharing its group — Apple's own labels arrive wrapped
+      # (`_$!<Spouse>!$_`) and a person's own arrives as they typed it.
+      # The labels are read off every line rather than the carried ones,
+      # a label being dropped with the group it annotates.
+      #: (Array[VCard::Parser::Line] lines, Array[VCard::Parser::Line] carried) -> Array[String]
+      def self.related_names(lines, carried)
+        labels = properties(lines, "X-ABLabel").to_h {
+          [it.group, VCard.unescape(it.value).sub(/\A_\$!<(.*)>!\$_\z/, "\\1")] #: [String?, String]
+        }
+        properties(carried, "X-ABRELATEDNAMES").map {
+          label = labels[it.group]
+          label ? "#{label}: #{it.text}" : it.text
+        }
+      end
 
-        Plan::Entry.new(id:, source_id:, card: Card.new(first:, last:, phones:, groups: []), backup:)
+      # The properties of the carried lines naming one property, in the
+      # order the card wrote them.
+      #: (Array[VCard::Parser::Line] lines, String name) -> Array[VCard::Parser::Property]
+      def self.properties(lines, name)
+        lines.filter_map { it.property }.select { it.name.casecmp?(name) }
+      end
+
+      #: (Array[VCard::Parser::Line] lines, String name) -> Array[String]
+      def self.values(lines, name) = properties(lines, name).map(&:text)
+
+      # An address as the editor's add row holds one, the blank
+      # components left out rather than written as empty fields.
+      #: (VCard::Parser::Property property) -> Hash[String, String]
+      def self.address(property)
+        # The post office box leads the value and has no field; a card
+        # carrying one does not get this far (see ADR's value check).
+        _po_box, *parts = property.components
+        Card::ADDRESS_KEYS.zip(parts).to_h { |key, value|
+          [key, value.to_s] #: [String, String]
+        }.reject { |_key, value| value.empty? }
+      end
+
+      # The one line naming a property a card holds once, or nil for a
+      # card with none; more than one is refused, the field having room
+      # for a single value.
+      #: (Array[VCard::Parser::Line] lines, String name, String source_id, Unknown unknown) -> String?
+      def self.only(lines, name, source_id, unknown)
+        found = lines.select { it.names?(name) }
+        return found.first&.property&.text if found.size <= 1
+
+        unknown.add("#{name} lines: #{found.size}", source_id, found.map { example(it) }.join(" / "))
+        nil
+      end
+
+      # The `item` groups this card explains: a group with a line in it
+      # that says what the group is for, rather than annotating it.
+      #: (Array[VCard::Parser::Line] lines) -> Array[String]
+      def self.grouped(lines)
+        lines.filter_map { it.property }
+          .reject { ANNOTATIONS.include?(it.name.upcase) }
+          .filter_map { it.group }
       end
 
       # Whether a line of the source card is carried into the card's
       # fields; one that is not is recorded unknown or, for the envelope
-      # and PRODID, dropped.
-      #: (VCard::Parser::Line line, String source_id, Unknown unknown) -> bool
-      def self.carried?(line, source_id, unknown)
+      # and the dropped properties, left behind.
+      #: (VCard::Parser::Line line, Array[String] grouped, String source_id, Unknown unknown) -> bool
+      def self.carried?(line, grouped, source_id, unknown)
         property = line.property
         example = example(line)
         if property.nil?
@@ -144,17 +223,30 @@ module ProTacts
         when "VERSION"
           unknown.add("VERSION:#{property.value}", source_id, example) unless property.value == "3.0"
           false
+        when *DROPPED
+          # Fields this address book does not have. The source keeps
+          # them, the card carrying what it was read from.
+          false
+        when *ANNOTATIONS
+          # A line that annotates the line sharing its group — a label,
+          # or an address's country code — goes wherever that line goes,
+          # which is why dropping `item1.URL` drops `item1.X-ABADR` with
+          # it. One annotating nothing is a line with no explanation, so
+          # it is unknown like any other.
+          group = property.group
+          unknown.add(name, source_id, example) unless group && grouped.include?(group)
+          false
         when "PRODID"
           # Contacts writes its own on every save; the source's stays in the backup.
           form = form(property, example)
           unknown.add(form, source_id, example) unless form == "PRODID"
           unknown.add("PRODID value", source_id, example) unless property.value.start_with?("-//Apple Inc.//")
           false
-        when "FN", "N", "TEL"
+        when *FORMS.keys
           refused = [] #: Array[String]
           form = form(property, example)
           refused << form unless FORMS.fetch(name).include?(form)
-          refused << "TEL value" if name == "TEL" && !property.value.match?(PHONE)
+          refused << "#{name} value" unless value?(name, property)
           refused.each { unknown.add(it, source_id, example) }
           refused.empty?
         else
@@ -189,14 +281,56 @@ module ProTacts
       end
 
       # The spellings each carried property is taken in, group and
-      # parameters included, exactly as the source wrote them.
+      # parameters included, as the source wrote them: `item1.` and
+      # `item2.` are one spelling, since the number counts a card's
+      # groups rather than saying anything about the line, and a
+      # trailing `type=pref` is left off, ranking a line rather than
+      # naming a kind (Contact#types_of drops it for the same reason).
       FORMS = {
         "FN" => ["FN"],
         "N" => ["N"],
-        "TEL" => ["TEL;type=CELL;type=VOICE;type=pref"]
+        "NICKNAME" => ["NICKNAME"],
+        "BDAY" => ["BDAY"],
+        "TEL" => [
+          "TEL",
+          "TEL;type=CELL;type=VOICE",
+          "TEL;type=HOME;type=VOICE",
+          "TEL;type=IPHONE;type=CELL;type=VOICE"
+        ],
+        "EMAIL" => ["EMAIL;type=INTERNET", "EMAIL;type=INTERNET;type=HOME", "EMAIL;type=INTERNET;type=WORK"],
+        "ADR" => ["item#.ADR;type=HOME"],
+        "X-ABRELATEDNAMES" => ["item#.X-ABRELATEDNAMES"]
       }.freeze #: Hash[String, Array[String]]
 
-      PHONE = /\A\+[0-9]+\z/ #: Regexp
+      # A number as Contacts hands one over, which is as a person typed
+      # it: "+12532189075" and "(206) 651-4359" are both numbers, and an
+      # empty value is a row with no number in it at all.
+      PHONE = /\A[0-9 +().-]*\z/ #: Regexp
+
+      # Properties read and thrown away, whatever form they take: this
+      # address book has no field for any of them, and a card's `source`
+      # still holds the line.
+      DROPPED = %w[IMPP URL X-SOCIALPROFILE X-APPLE-SUBADMINISTRATIVEAREA X-AIM].freeze #: Array[String]
+
+      # Properties that say something about the line sharing their group
+      # rather than about the contact: a label, and the country code
+      # Contacts writes beside an address.
+      ANNOTATIONS = %w[X-ABLABEL X-ABADR].freeze #: Array[String]
+
+      # Whether a value is one its field can hold: a phone is a `+` and
+      # digits, a birthday a whole date, an address the seven components
+      # RFC 2426 section 3.2.1 gives it with no post office box, since no
+      # field carries one. A name is any text.
+      #: (String name, VCard::Parser::Property property) -> bool
+      def self.value?(name, property)
+        case name
+        when "TEL" then property.value.match?(PHONE)
+        when "BDAY" then property.value.match?(Card::BIRTHDAY)
+        when "EMAIL" then property.value.include?("@")
+        when "ADR" then property.components.size == 7 && property.components.fetch(0).empty?
+        else true
+        end
+      end
 
       # A line unfolded, without its terminator.
       #: (VCard::Parser::Line line) -> String
@@ -207,10 +341,10 @@ module ProTacts
       # A line as the source spelled it, less its value.
       #: (VCard::Parser::Property property, String example) -> String
       def self.form(property, example)
-        example.delete_suffix(":#{property.value}")
+        example.delete_suffix(":#{property.value}").sub(/\Aitem\d+\./, "item#.").delete_suffix(";type=pref")
       end
 
-      private_class_method :entry, :carried?, :name, :example, :form
+      private_class_method :entry, :grouped, :carried?, :name, :noted, :related_names, :only, :properties, :values, :address, :value?, :example, :form
     end
   end
 end
diff --git a/lib/pro_tacts/import/plan.rb b/lib/pro_tacts/import/plan.rb
index a82cf76..417eb7c 100644
--- a/lib/pro_tacts/import/plan.rb
+++ b/lib/pro_tacts/import/plan.rb
@@ -15,10 +15,11 @@ module ProTacts
       # @rbs @manifest: Hash[String, untyped]
 
       # What a builder hands over for one contact: its card, which the
-      # plan's own groups are added to, and the original's files, by
-      # name. Signed in sig/pro_tacts/import.rbs, being a Data class.
+      # plan's own groups are added to. The original rides inside the
+      # card (Card::SOURCE_KEYS). Signed in sig/pro_tacts/import.rbs,
+      # being a Data class.
       # @rbs skip
-      Entry = Data.define(:id, :source_id, :card, :backup)
+      Entry = Data.define(:id, :source_id, :card)
 
       # A contact as the plan lists it, with the last step it reached:
       # nil, then landed, imported, and removed, in that order.
@@ -38,14 +39,11 @@ module ProTacts
         Dir.mkdir(dir)
 
         group = "import-#{created_at.utc.strftime("%Y%m%dT%H%M%SZ")}"
+        (dir / "cards").mkpath
         entries.each do |entry|
-          (dir / "cards").mkpath
           card = entry.card
-          card = Card.new(first: card.first, last: card.last, phones: card.phones, groups: [EVERYONE, group, *card.groups])
+          card = card.with(groups: [EVERYONE, group, *card.groups])
           (dir / "cards/#{entry.id}.yml").write(YAML.dump(card.document))
-          backup = dir / "backups" / entry.id
-          backup.mkpath
-          entry.backup.each { |name, bytes| (backup / name).binwrite(bytes) }
         end
 
         plan = new(dir, {
@@ -118,10 +116,6 @@ module ProTacts
       #: (String? status) -> Array[Contact]
       def with_status(status) = contacts.select { it.status == status }
 
-      # Where a source's own files for one contact were written.
-      #: (String id) -> Pathname
-      def backup(id) = dir / "backups" / id
-
       # The card as its file now reads, edits included.
       #: (String id) -> Card
       def card(id)
diff --git a/lib/pro_tacts/import/remove.rb b/lib/pro_tacts/import/remove.rb
index 00b294f..c7f1e78 100644
--- a/lib/pro_tacts/import/remove.rb
+++ b/lib/pro_tacts/import/remove.rb
@@ -66,13 +66,12 @@ module ProTacts
       # the safe direction to be wrong in.
       #: (Plan::Contact contact, Hash[String, untyped] record) -> String?
       def keep(contact, record)
-        backup = @plan.backup(contact.id)
-        note = (backup / "note.txt").then { it.file? ? it.read : nil }
+        source = @plan.card(contact.id).source
         if !on_host?(contact.id)
           "its card is no longer on #{@plan.host}"
-        elsif record.fetch("vcard") != (backup / "original.vcf").read
+        elsif record.fetch("vcard") != source.fetch("vcard")
           "it has changed on this Mac since the plan"
-        elsif record.fetch("note") != note
+        elsif record.fetch("note") != source.fetch("note")
           "its note has changed on this Mac since the plan"
         end
       end
diff --git a/sig/pro_tacts/import.rbs b/sig/pro_tacts/import.rbs
index 67f934f..d4cc882 100644
--- a/sig/pro_tacts/import.rbs
+++ b/sig/pro_tacts/import.rbs
@@ -8,10 +8,20 @@ module ProTacts
     class Card
       attr_reader first: String
       attr_reader last: String
+      attr_reader nickname: String?
+      attr_reader birthday: String?
       attr_reader phones: Array[String]
+      attr_reader emails: Array[String]
+      attr_reader addresses: Array[Hash[String, String]]
+      attr_reader note: String?
+      attr_reader photo: bool
       attr_reader groups: Array[String]
+      attr_reader source: Hash[String, untyped]
 
-      def self.new: (first: String, last: String, phones: Array[String], groups: Array[String]) -> instance
+      def self.new: (first: String, last: String, nickname: String?, birthday: String?, phones: Array[String], emails: Array[String], addresses: Array[Hash[String, String]], note: String?, photo: bool, groups: Array[String], source: Hash[String, untyped]) -> instance
+
+      # Data#with, for the one member a plan fills in itself.
+      def with: (groups: Array[String]) -> Card
     end
 
     class Plan
@@ -19,9 +29,8 @@ module ProTacts
         attr_reader id: String
         attr_reader source_id: String
         attr_reader card: Card
-        attr_reader backup: Hash[String, String]
 
-        def self.new: (id: String, source_id: String, card: Card, backup: Hash[String, String]) -> instance
+        def self.new: (id: String, source_id: String, card: Card) -> instance
       end
 
       class Contact
diff --git a/test/pro_tacts/import/test_card.rb b/test/pro_tacts/import/test_card.rb
index 0a201f7..74e1ad8 100644
--- a/test/pro_tacts/import/test_card.rb
+++ b/test/pro_tacts/import/test_card.rb
@@ -11,13 +11,29 @@ class ImportCardTest < Minitest::Test
   ADA = <<~YAML
     first: Ada
     last: Lovelace
+    nickname: The Countess
+    birthday: '1815-12-10'
     phones:
     - "+12532189075"
+    emails:
+    - ada@example.com
+    addresses:
+    - street: 12 Marylebone
+      locality: London
+    note: An analyst.
+    photo: false
     groups:
     - sync:*
     - import-20260916T180412Z
+    source:
+      identifier: A:ABPerson
+      vcard: "BEGIN:VCARD\\r\\nEND:VCARD\\r\\n"
+      note:
+      contact: {}
   YAML
 
+  SOURCE = {"identifier" => "A:ABPerson", "vcard" => "BEGIN:VCARD\r\nEND:VCARD\r\n", "note" => nil, "contact" => {}}.freeze
+
   def read(yaml)
     Dir.mktmpdir do |tmp|
       path = Pathname.new(tmp) / "kmnuqmzxylru.yml"
@@ -32,18 +48,31 @@ class ImportCardTest < Minitest::Test
   end
 
   def test_a_card_reads_its_fields
-    assert_equal Card.new(first: "Ada", last: "Lovelace", phones: ["+12532189075"], groups: ["sync:*", "import-20260916T180412Z"]), read(ADA)
+    assert_equal Card.new(first: "Ada", last: "Lovelace", nickname: "The Countess", birthday: "1815-12-10", phones: ["+12532189075"], emails: ["ada@example.com"], addresses: [{"street" => "12 Marylebone", "locality" => "London"}], note: "An analyst.", photo: false, groups: ["sync:*", "import-20260916T180412Z"], source: SOURCE), read(ADA)
   end
 
   def test_a_card_is_the_contact_the_web_create_and_add_rows_make
     contact = read(ADA).contact("kmnuqmzxylru")
 
     assert_equal "kmnuqmzxylru", contact.id
-    assert_equal "BEGIN:VCARD\r\nVERSION:3.0\r\nN:Lovelace;Ada;;;\r\nFN:Ada Lovelace\r\nUID:kmnuqmzxylru\r\nTEL:+12532189075\r\nEND:VCARD\r\n", contact.vcard.to_s
+    assert_equal <<~CARD.gsub("\n", "\r\n"), contact.vcard.to_s
+      BEGIN:VCARD
+      VERSION:3.0
+      N:Lovelace;Ada;;;
+      FN:Ada Lovelace
+      UID:kmnuqmzxylru
+      NICKNAME:The Countess
+      NOTE:An analyst.
+      TEL:+12532189075
+      EMAIL:ada@example.com
+      ADR:;;12 Marylebone;London;;;
+      BDAY:1815-12-10
+      END:VCARD
+    CARD
   end
 
   def test_a_name_is_escaped_into_the_card
-    card = Card.new(first: "Ada; Countess", last: "", phones: [], groups: [])
+    card = Card.new(first: "Ada; Countess", last: "", nickname: nil, birthday: nil, phones: [], emails: [], addresses: [], note: nil, photo: false, groups: [], source: SOURCE)
 
     assert_equal ["Ada; Countess"], [card.contact("kmnuqmzxylru").name]
   end
@@ -59,7 +88,7 @@ class ImportCardTest < Minitest::Test
   end
 
   def test_a_field_no_card_takes_is_refused
-    assert_invalid "#{ADA}emails: []\n", "has emails, which no field takes"
+    assert_invalid "#{ADA}url: https://example.com\n", "has url, which no field takes"
   end
 
   def test_an_unquoted_number_is_refused
@@ -74,6 +103,39 @@ class ImportCardTest < Minitest::Test
     assert_invalid ADA.sub("first: Ada", "first: ''").sub("last: Lovelace", "last: ' '"), "needs a first or last name"
   end
 
+  def test_an_unquoted_date_is_refused
+    assert_invalid ADA.sub("birthday: '1815-12-10'", "birthday: 1815-12-10"), "reads as a Date; quote it"
+  end
+
+  def test_a_birthday_that_is_not_a_whole_date_is_refused
+    assert_invalid ADA.sub("birthday: '1815-12-10'", "birthday: '--12-10'"), "birthday must be a quoted YYYY-MM-DD"
+  end
+
+  def test_a_card_with_no_birthday_reads_and_writes_the_blank
+    card = read(ADA.sub("birthday: '1815-12-10'", "birthday:"))
+
+    assert_nil card.birthday
+    refute_includes card.contact("kmnuqmzxylru").vcard.to_s, "BDAY"
+  end
+
+  def test_an_address_part_no_field_takes_is_refused
+    assert_invalid ADA.sub("  locality: London", "  po_box: '4'"), "addresses must be a list, each with any of"
+  end
+
+  def test_a_picture_the_source_holds_is_a_photo_line
+    jpeg = ["\xFF\xD8\xFFhello".b].pack("m0")
+    source = SOURCE.merge("contact" => {"imageData" => jpeg})
+    card = read(ADA.sub("photo: false", "photo: true").sub("  contact: {}", "  contact:\n    imageData: #{jpeg}"))
+
+    assert_equal source.fetch("contact"), card.source.fetch("contact")
+    assert_includes card.contact("kmnuqmzxylru").vcard.to_s, "PHOTO;ENCODING=b;TYPE=JPEG:#{jpeg}\r\n"
+    assert_equal "image/jpeg", card.contact("kmnuqmzxylru").photo.mime_type
+  end
+
+  def test_a_photo_the_source_cannot_supply_is_refused
+    assert_invalid ADA.sub("photo: false", "photo: true"), "photo is true and the source holds no picture"
+  end
+
   def test_a_blank_group_is_refused
     assert_invalid ADA.sub("- sync:*", "- ''"), "groups must be a list of names"
   end
diff --git a/test/pro_tacts/import/test_execute.rb b/test/pro_tacts/import/test_execute.rb
index 73fad6b..3d6baf3 100644
--- a/test/pro_tacts/import/test_execute.rb
+++ b/test/pro_tacts/import/test_execute.rb
@@ -50,8 +50,9 @@ class ImportExecuteTest < Minitest::Test
     Dir.mktmpdir do |tmp|
       dir = Pathname.new(tmp) / "plan"
       entries = IDS.map { |id|
-        card = ProTacts::Import::Card.new(first: "Contact", last: id, phones: [], groups:)
-        Plan::Entry.new(id:, source_id: "#{id}:ABPerson", card:, backup: {})
+        source = {"identifier" => "#{id}:ABPerson", "vcard" => "BEGIN:VCARD\r\nEND:VCARD\r\n", "note" => nil, "contact" => {}}
+        card = ProTacts::Import::Card.new(first: "Contact", last: id, nickname: nil, birthday: nil, phones: [], emails: [], addresses: [], note: nil, photo: false, groups:, source:)
+        Plan::Entry.new(id:, source_id: "#{id}:ABPerson", card:)
       }
       Plan.write(dir, source: "macos", created_at: Time.utc(2026, 9, 16, 18, 4, 12), entries:)
       with_contacts({}) { |store| yield Plan.read(dir), store, RackClient.new }
diff --git a/test/pro_tacts/import/test_macos.rb b/test/pro_tacts/import/test_macos.rb
index 31a441f..6c0968d 100644
--- a/test/pro_tacts/import/test_macos.rb
+++ b/test/pro_tacts/import/test_macos.rb
@@ -37,19 +37,23 @@ class ImportMacosTest < Minitest::Test
       contact = plan.contacts.first
       assert_match(/\A[k-z]{12}\z/, contact.id)
       assert_equal "A:ABPerson", contact.source_id
-      assert_equal Card.new(first: "Ada", last: "Lovelace", phones: [], groups: GROUPS), plan.card(contact.id)
+      card = plan.card(contact.id)
+      assert_equal ["Ada", "Lovelace", nil, nil, [], [], [], GROUPS],
+        [card.first, card.last, card.nickname, card.birthday, card.phones, card.emails, card.addresses, card.groups]
     end
   end
 
-  def test_the_backup_holds_the_vcard_and_the_keys
+  def test_the_card_holds_the_source_it_was_read_from
     in_tmpdir do |dir|
-      source = record("A:ABPerson")
-      plan = Macos.plan(dir, [source], created_at: CREATED_AT)
-
-      backup = dir / "backups" / plan.contacts.first.id
-      assert_equal source.fetch("vcard"), (backup / "original.vcf").read
-      assert_equal source.fetch("contact"), JSON.parse((backup / "contact.json").read)
-      refute (backup / "note.txt").exist?
+      read = record("A:ABPerson")
+      read["contact"] = read.fetch("contact").merge("givenName" => "Ada")
+      plan = Macos.plan(dir, [read], created_at: CREATED_AT)
+
+      source = plan.card(plan.contacts.first.id).source
+      assert_equal read.fetch("vcard"), source.fetch("vcard")
+      assert_equal "A:ABPerson", source.fetch("identifier")
+      assert_nil source.fetch("note")
+      assert_equal read.fetch("contact"), source.fetch("contact")
     end
   end
 
@@ -69,10 +73,6 @@ class ImportMacosTest < Minitest::Test
             B:ABPerson: "EMAIL:mary@example.com"
           ORG: 1
             A:ABPerson: "ORG:Analytical Engines;"
-          imageData: 1
-            B:ABPerson: "/9j/"
-          note: 1
-            A:ABPerson: "Analyst."
       MESSAGE
       refute dir.exist?
     end
@@ -92,8 +92,6 @@ class ImportMacosTest < Minitest::Test
             E:ABPerson: "NOTE:one\\\\ntwo"
           PHOTO: 4
             A:ABPerson: "PHOTO;ENCODING=b:#{"A" * 83}… (137 characters)"
-          note: 1
-            E:ABPerson: "one\\ntwo"
       MESSAGE
     end
   end
@@ -115,6 +113,166 @@ class ImportMacosTest < Minitest::Test
     end
   end
 
+  def test_an_address_a_birthday_and_the_emails_are_fields
+    in_tmpdir do |dir|
+      lines = [
+        "item1.ADR;type=HOME;type=pref:;;12 Marylebone Rd;London;;NW1 5LS;England",
+        "BDAY:1815-12-10",
+        "EMAIL;type=INTERNET;type=HOME:ada@example.com",
+        "EMAIL;type=INTERNET;type=WORK;type=pref:ada@analytical.example",
+        "EMAIL;type=INTERNET:ada@lovelace.example"
+      ]
+      plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
+
+      card = plan.card(plan.contacts.first.id)
+      assert_equal "1815-12-10", card.birthday
+      assert_equal ["ada@example.com", "ada@analytical.example", "ada@lovelace.example"], card.emails
+      assert_equal [{"street" => "12 Marylebone Rd", "locality" => "London", "postal_code" => "NW1 5LS", "country" => "England"}], card.addresses
+    end
+  end
+
+  def test_a_second_address_under_another_item_number_is_one_form
+    in_tmpdir do |dir|
+      lines = [
+        "item1.ADR;type=HOME;type=pref:;;12 Marylebone Rd;London;;;",
+        "item2.ADR;type=HOME:;;Ockham Park;Surrey;;;"
+      ]
+      plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
+
+      assert_equal ["12 Marylebone Rd", "Ockham Park"], plan.card(plan.contacts.first.id).addresses.map { it.fetch("street") }
+    end
+  end
+
+  def test_every_kind_of_number_is_a_phone_and_an_empty_row_is_not
+    in_tmpdir do |dir|
+      lines = [
+        "TEL;type=pref:+1 203-536-3941",
+        "TEL;type=HOME;type=VOICE;type=pref:(206) 651-4359",
+        "TEL;type=IPHONE;type=CELL;type=VOICE:",
+        "TEL;type=CELL;type=VOICE:+12532189075"
+      ]
+      plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
+
+      assert_equal ["+1 203-536-3941", "(206) 651-4359", "+12532189075"], plan.card(plan.contacts.first.id).phones
+    end
+  end
+
+  def test_a_nickname_is_a_field_and_an_instant_message_address_is_dropped
+    in_tmpdir do |dir|
+      lines = ["NICKNAME:The Countess", "IMPP;X-SERVICE-TYPE=Skype;type=HOME;type=pref:skype:ada"]
+      plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
+
+      assert_equal "The Countess", plan.card(plan.contacts.first.id).nickname
+    end
+  end
+
+  def test_the_note_and_the_picture_are_fields
+    in_tmpdir do |dir|
+      jpeg = ["\xFF\xD8\xFFhello".b].pack("m0")
+      plan = Macos.plan(dir, [record("A:ABPerson", note: "Analyst.", image: jpeg)], created_at: CREATED_AT)
+
+      card = plan.card(plan.contacts.first.id)
+      assert_equal "Analyst.", card.note
+      assert card.photo
+      assert_equal "image/jpeg", card.contact(plan.contacts.first.id).photo.mime_type
+    end
+  end
+
+  def test_the_fields_this_address_book_does_not_have_are_dropped
+    in_tmpdir do |dir|
+      lines = [
+        "IMPP;X-SERVICE-TYPE=Skype;type=HOME;type=pref:skype:ada",
+        "X-SOCIALPROFILE;type=twitter:https://twitter.com/ada",
+        "item1.X-APPLE-SUBADMINISTRATIVEAREA:Middlesex",
+        "X-AIM;type=HOME;type=pref:ada",
+        "item2.URL;type=pref:https://example.com",
+        "item2.X-ABLabel:_$!<HomePage>!$_",
+        "item2.X-ABADR:us"
+      ]
+      plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
+
+      assert_equal "Ada Lovelace", plan.card(plan.contacts.first.id).contact("kmnuqmzxylru").name
+    end
+  end
+
+  def test_a_related_name_is_written_under_the_note
+    in_tmpdir do |dir|
+      lines = [
+        "item2.X-ABRELATEDNAMES;type=pref:Sylvia Lovelace",
+        "item2.X-ABLabel:_$!<Spouse>!$_",
+        "item3.X-ABRELATEDNAMES:Byron",
+        "item3.X-ABLabel:the poet",
+        "item4.URL;type=pref:https://example.com",
+        "item4.X-ABLabel:_$!<HomePage>!$_"
+      ]
+      plan = Macos.plan(dir, [record("A:ABPerson", lines:, note: "Analyst.")], created_at: CREATED_AT)
+
+      assert_equal "Analyst.\n\nSpouse: Sylvia Lovelace\nthe poet: Byron", plan.card(plan.contacts.first.id).note
+    end
+  end
+
+  def test_a_related_name_is_the_whole_note_of_a_contact_with_none
+    in_tmpdir do |dir|
+      lines = ["item2.X-ABRELATEDNAMES;type=pref:Sylvia", "item2.X-ABLabel:_$!<Spouse>!$_"]
+      plan = Macos.plan(dir, [record("A:ABPerson", lines:)], created_at: CREATED_AT)
+
+      assert_equal "Spouse: Sylvia", plan.card(plan.contacts.first.id).note
+    end
+  end
+
+  def test_an_annotation_with_no_line_to_annotate_is_refused
+    in_tmpdir do |dir|
+      records = [record("A:ABPerson", lines: ["item5.X-ABADR:us", "X-ABLabel:_$!<Spouse>!$_"])]
+
+      error = assert_raises(Macos::Unknown) { Macos.plan(dir, records, created_at: CREATED_AT) }
+
+      assert_equal <<~MESSAGE.chomp, error.message
+        no branch handles these fields, so no plan was written:
+          X-ABADR: 1
+            A:ABPerson: "item5.X-ABADR:us"
+          X-ABLABEL: 1
+            A:ABPerson: "X-ABLabel:_$!<Spouse>!$_"
+      MESSAGE
+    end
+  end
+
+  def test_a_second_nickname_is_refused
+    in_tmpdir do |dir|
+      records = [record("A:ABPerson", lines: ["NICKNAME:The Countess", "NICKNAME:Ada"])]
+
+      error = assert_raises(Macos::Unknown) { Macos.plan(dir, records, created_at: CREATED_AT) }
+
+      assert_includes error.message, "NICKNAME lines: 2: 1"
+    end
+  end
+
+  def test_a_value_no_field_can_hold_is_refused
+    in_tmpdir do |dir|
+      records = [
+        record("A:ABPerson", lines: ["BDAY:--12-10"]),
+        record("B:ABPerson", lines: ["EMAIL;type=INTERNET:ada"]),
+        record("C:ABPerson", lines: ["item1.ADR;type=HOME:P.O. Box 4;;;London;;;"]),
+        record("D:ABPerson", lines: ["item1.ADR;type=HOME:;;Ockham Park;Surrey"]),
+        record("E:ABPerson", lines: ["BDAY:1815-12-10", "BDAY:1815-12-11"])
+      ]
+
+      error = assert_raises(Macos::Unknown) { Macos.plan(dir, records, created_at: CREATED_AT) }
+
+      assert_equal <<~MESSAGE.chomp, error.message
+        no branch handles these fields, so no plan was written:
+          ADR value: 2
+            C:ABPerson: "item1.ADR;type=HOME:P.O. Box 4;;;London;;;"
+            D:ABPerson: "item1.ADR;type=HOME:;;Ockham Park;Surrey"
+          BDAY lines: 2: 1
+            E:ABPerson: "BDAY:1815-12-10 / BDAY:1815-12-11"
+          BDAY value: 1
+            A:ABPerson: "BDAY:--12-10"
+          EMAIL value: 1
+            B:ABPerson: "EMAIL;type=INTERNET:ada"
+      MESSAGE
+    end
+  end
+
   def test_a_name_beyond_first_and_last_is_refused
     in_tmpdir do |dir|
       records = [
@@ -145,7 +303,8 @@ class ImportMacosTest < Minitest::Test
     in_tmpdir do |dir|
       plan = Macos.plan(dir, [record("A:ABPerson", lines: ["PRODID:-//Apple Inc.//macOS 26.0//EN"])], created_at: CREATED_AT)
 
-      assert_equal Card.new(first: "Ada", last: "Lovelace", phones: [], groups: GROUPS), plan.card(plan.contacts.first.id)
+      card = plan.card(plan.contacts.first.id)
+      assert_equal [[], [], []], [card.phones, card.emails, card.addresses]
     end
   end
 
@@ -171,10 +330,10 @@ class ImportMacosTest < Minitest::Test
   def test_a_line_in_any_other_form_is_refused_under_that_form
     in_tmpdir do |dir|
       records = [
-        record("A:ABPerson", lines: ["TEL;type=HOME;type=VOICE:+12532189075"]),
-        record("B:ABPerson", lines: ["TEL;type=CELL;type=VOICE:+12532189075"]),
+        record("A:ABPerson", lines: ["TEL;type=MAIN:+12532189075"]),
+        record("B:ABPerson", lines: ["TEL;type=WORK;type=VOICE:+12532189075"]),
         record("C:ABPerson", lines: ["item1.TEL;type=CELL;type=VOICE;type=pref:+12532189075"]),
-        record("D:ABPerson", lines: ["TEL;type=CELL;type=VOICE;type=pref:(253) 218-9075"]),
+        record("D:ABPerson", lines: ["TEL;type=CELL;type=VOICE;type=pref:ext. 4"]),
         record("E:ABPerson", name: ["N:Lovelace;Ada;;;", "FN;CHARSET=utf-8:Ada Lovelace"])
       ]
 
@@ -187,12 +346,12 @@ class ImportMacosTest < Minitest::Test
           FN;CHARSET=utf-8: 1
             E:ABPerson: "FN;CHARSET=utf-8:Ada Lovelace"
           TEL value: 1
-            D:ABPerson: "TEL;type=CELL;type=VOICE;type=pref:(253) 218-9075"
-          TEL;type=CELL;type=VOICE: 1
-            B:ABPerson: "TEL;type=CELL;type=VOICE:+12532189075"
-          TEL;type=HOME;type=VOICE: 1
-            A:ABPerson: "TEL;type=HOME;type=VOICE:+12532189075"
-          item1.TEL;type=CELL;type=VOICE;type=pref: 1
+            D:ABPerson: "TEL;type=CELL;type=VOICE;type=pref:ext. 4"
+          TEL;type=MAIN: 1
+            A:ABPerson: "TEL;type=MAIN:+12532189075"
+          TEL;type=WORK;type=VOICE: 1
+            B:ABPerson: "TEL;type=WORK;type=VOICE:+12532189075"
+          item#.TEL;type=CELL;type=VOICE: 1
             C:ABPerson: "item1.TEL;type=CELL;type=VOICE;type=pref:+12532189075"
       MESSAGE
     end
diff --git a/test/pro_tacts/import/test_plan.rb b/test/pro_tacts/import/test_plan.rb
index 7fbc2dc..c813da6 100644
--- a/test/pro_tacts/import/test_plan.rb
+++ b/test/pro_tacts/import/test_plan.rb
@@ -12,10 +12,11 @@ class ImportPlanTest < Minitest::Test
 
   CREATED_AT = Time.utc(2026, 9, 16, 18, 4, 12)
 
-  ADA = ProTacts::Import::Card.new(first: "Ada", last: "Lovelace", phones: ["+12532189075"], groups: [])
+  SOURCE = {"identifier" => "A:ABPerson", "vcard" => "BEGIN:VCARD\r\nEND:VCARD\r\n", "note" => nil, "contact" => {}}
+  ADA = ProTacts::Import::Card.new(first: "Ada", last: "Lovelace", nickname: nil, birthday: nil, phones: ["+12532189075"], emails: [], addresses: [], note: nil, photo: false, groups: [], source: SOURCE)
 
-  def entry(id, source_id: "#{id}:ABPerson", card: ADA, backup: {"original.vcf" => "BEGIN:VCARD\r\n"})
-    Plan::Entry.new(id:, source_id:, card:, backup:)
+  def entry(id, source_id: "#{id}:ABPerson", card: ADA)
+    Plan::Entry.new(id:, source_id:, card:)
   end
 
   def in_tmpdir
@@ -45,19 +46,30 @@ class ImportPlanTest < Minitest::Test
 
   def test_a_card_joins_everyone_and_the_plans_group_after_its_own
     in_tmpdir do |dir|
-      card = ProTacts::Import::Card.new(first: "Ada", last: "Lovelace", phones: ["+12532189075"], groups: ["family"])
+      card = ADA.with(groups: ["family"])
       Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru", card:)])
 
       assert_equal <<~YAML, (dir / "cards/kmnuqmzxylru.yml").read
         ---
         first: Ada
         last: Lovelace
+        nickname:
+        birthday:
         phones:
         - "+12532189075"
+        emails: []
+        addresses: []
+        note:
+        photo: false
         groups:
         - sync:*
         - import-20260916T180412Z
         - family
+        source:
+          identifier: A:ABPerson
+          vcard: "BEGIN:VCARD\\r\\nEND:VCARD\\r\\n"
+          note:
+          contact: {}
       YAML
     end
   end
@@ -72,16 +84,6 @@ class ImportPlanTest < Minitest::Test
     end
   end
 
-  def test_the_backup_is_written_as_given
-    in_tmpdir do |dir|
-      photo = "\xFF\xD8\xFF".b
-      Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru", backup: {"original.vcf" => "BEGIN:VCARD\r\n", "photo.jpg" => photo})])
-
-      assert_equal "BEGIN:VCARD\r\n", (dir / "backups/kmnuqmzxylru/original.vcf").read
-      assert_equal photo, (dir / "backups/kmnuqmzxylru/photo.jpg").binread
-    end
-  end
-
   def test_a_plan_is_never_written_over_another
     in_tmpdir do |dir|
       Plan.write(dir, source: "macos", created_at: CREATED_AT, entries: [entry("kmnuqmzxylru")])
diff --git a/test/pro_tacts/import/test_remove.rb b/test/pro_tacts/import/test_remove.rb
index db481b3..89a898d 100644
--- a/test/pro_tacts/import/test_remove.rb
+++ b/test/pro_tacts/import/test_remove.rb
@@ -51,10 +51,9 @@ class ImportRemoveTest < Minitest::Test
     Dir.mktmpdir do |tmp|
       dir = Pathname.new(tmp) / "plan"
       entries = IDS.map { |id|
-        backup = {"original.vcf" => vcard(id)}
-        backup["note.txt"] = notes.fetch(id) if notes.key?(id)
-        card = Card.new(first: "Contact", last: id, phones: [], groups: [])
-        Plan::Entry.new(id:, source_id: source_id(id), card:, backup:)
+        source = {"identifier" => source_id(id), "vcard" => vcard(id), "note" => notes[id], "contact" => {}}
+        card = Card.new(first: "Contact", last: id, nickname: nil, birthday: nil, phones: [], emails: [], addresses: [], note: nil, photo: false, groups: [], source:)
+        Plan::Entry.new(id:, source_id: source_id(id), card:)
       }
       Plan.write(dir, source: "macos", created_at: Time.utc(2026, 9, 16, 18, 4, 12), entries:)
       IDS.each { (dir / "cards/#{it}.yml").write(edit.call((dir / "cards/#{it}.yml").read)) } if edit
@@ -67,7 +66,7 @@ class ImportRemoveTest < Minitest::Test
     end
   end
 
-  def test_a_contact_that_matches_its_backup_leaves_the_mac
+  def test_a_contact_that_matches_its_source_leaves_the_mac
     with_landed_plan do |plan, _store, client|
       mac = Mac.new(IDS.to_h { [source_id(it), record(it)] })