import: a row can update a contact the book already has
A card that looks like someone already in the book offers, above its
editor, to update them instead of arriving new: Levenshtein over the
name and the email's own half, and the phone's national number, the
best field gating the offer and the sum ranking it. Choosing one
renders that contact's editor with the card folded in, the lines the
fold leaves behind struck on the card as exported, and its Save is the
contact's own edit (docs/plans/2026-09-23-merging-on-import.md).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vb8rg9WqWhQesDht1LL6t
diff --git a/AGENTS.md b/AGENTS.md
index 0b92878..577c78f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -45,7 +45,10 @@ screen here shows struck, and this book's groups as a filtered list of
boxes to tick — the group made for the import and everyone's book among
them, ticked. That contact's own Save is what writes it through the
store, so every row still to do wears a rail and stopping halfway keeps
-everything already in (docs/plans/2026-09-21-import-a-vcf.md).
+everything already in (docs/plans/2026-09-21-import-a-vcf.md). A row
+whose card looks like someone the book already has offers, above its
+editor, to update them instead, the card folded into theirs
+(docs/plans/2026-09-23-merging-on-import.md).
The `import:*` tasks, the Swift reader they drove Contacts.app with,
and the plan directories they wrote are gone.
@@ -69,7 +72,8 @@ lib/pro_tacts/
├── web/setup.rb # The device setup screen and its profile
├── web/import.rb # The import screens: a .vcf, uploaded and saved
├── web/api.rb # JSON for scripts: the group list
-├── import/ # Reading an uploaded .vcf, and writing its cards
+├── import/ # Reading an uploaded .vcf, matching its cards to
+│ # contacts the book has, and writing them
├── admin/ # The Phlex views those screens render, plus
│ # card_form.rb, which reads a form back into a card
├── store.rb # Sequel over SQLite: cards, change log, derived index
diff --git a/Steepfile b/Steepfile
index 17620e3..e7a0a43 100644
--- a/Steepfile
+++ b/Steepfile
@@ -23,7 +23,7 @@ target :lib do
ignore(*%w[
avatar contact_card contact_dialog contacts_edit contacts_index contacts_show dashboard device_setup format
group_dialog group_filter group_label groups_edit groups_index groups_show icons import_groups import_original import_saved import_sidebar
- import_upload layout list_item phlex record_card upcoming_birthdays
+ import_target import_upload layout list_item phlex record_card upcoming_birthdays
].map { "lib/pro_tacts/admin/#{it}.rb" }, inline: true)
signature "sig"
diff --git a/docs/plans/2026-09-23-merging-on-import.md b/docs/plans/2026-09-23-merging-on-import.md
new file mode 100644
index 0000000..0989ae4
--- /dev/null
+++ b/docs/plans/2026-09-23-merging-on-import.md
@@ -0,0 +1,153 @@
+# Merging on import
+
+2026-09-23. A row of the import walk whose card looks like someone the
+book already has offers, at the top of its editor, to update that
+contact instead of adding a new one. Choosing it re-renders the editor
+over the contact with the card folded in, and that screen's Save is
+the contact's own edit. Nothing else about the walk
+(`2026-09-21-import-a-vcf.md`) changes.
+
+## Why
+
+An import into a book that is not empty meets people it already has.
+The walk as it stood could only add them, so the second copy of
+someone was the importer's to notice, to reconcile by hand on the
+contact's own page, and to delete — after the walk, off a list of
+what came in. The row being read is the moment the importer is
+looking at exactly this person, so that is where the question goes.
+
+## Finding them
+
+`Import::Match` scores every contact in the book against the arriving
+card over the three things a person is recognised by:
+
+- **The name**, by Levenshtein distance over its words sorted, without
+ case or punctuation. "Booles, Jane" and "Jane Booles" are one name
+ written two ways, and an edit distance over the spellings would call
+ them strangers.
+- **Each email address**, by Levenshtein distance over the part before
+ its @, without case. The domain is shared by everyone at it: over
+ whole addresses, "ada@example.com" is four edits in sixteen from
+ "mary@example.com", which offered the fixture book's Ada for a card
+ that was Mary's. The same handle at two providers is still one
+ person.
+- **Each phone number**, as its last ten digits, and only when the two
+ agree. A country code or a trunk prefix on one side and not the other
+ is still one number, but a number one digit off is another line
+ rather than a typo of this one: the fixture household's numbers are
+ consecutive, and an edit distance offered every Boole for every
+ other.
+
+A name or an email is scored as the share of the longer string an edit
+leaves alone, and a phone as 1 or 0. A contact is offered when the best
+of any one field clears the threshold — not a sum or a mean. The cases
+worth catching are one field agreeing and the others absent or
+different: the same email under a nickname, the same number under a
+married name. A sum would bury exactly those. The offers are then
+ranked by the sum, so of two contacts sharing an address the one that
+shares the name too comes first.
+
+The threshold is 0.8, which lets through one edit in five characters —
+"Jon Smith" against "John Smith" is 0.9 — and at most three are
+offered, best first. Both are guesses about a family's book, and an
+offer that turns out wrong costs one look and nothing else: nothing
+is written until the importer chooses it and saves.
+
+The card scored is the one staged for the row, pared to what this book
+reads. The read is every contact in the book, once per screen — the
+same bet against growth the contact list makes (`docs/DESIGN.md`, "The
+core idea").
+
+## The toggle
+
+Gloss's segmented tabs, above the editor's first field: "new contact",
+then "update" and a name for each match. It renders only where there
+is a match, a toggle with one side not being a choice.
+
+The sides are links rather than buttons, each a GET of the row's own
+screen with `?into=` naming the contact (or nothing, for new). Each
+side is its own rendering of the editor — over the arriving card, or
+over a contact with the card folded in — with its own fields and its
+own etag, so switching is a different screen rather than a state of
+the same one, and a screen is what a GET is for. The form carries the
+choice back as a hidden `into`.
+
+New is the default. A card that updates a contact changes something
+already in the book, and that is a thing for the importer to say
+rather than for a score to decide.
+
+## The fold
+
+`Import::Merge` takes the contact's own card as the base, every byte
+of it kept, and adds from the arriving card only what the contact does
+not have:
+
+- A phone, an email or an address the contact lacks comes in beside
+ the ones it has; one it already has (compared the way the match
+ compares, and an address by its components) stays single. An
+ address one of its groups lends counts as one it has.
+- A name, a nickname, a note, a photo — what a contact holds one of —
+ fills a gap and never overwrites. Where the two differ, the book's
+ own stands.
+- A birthday goes into the model where the contact has none, the split
+ `Store#put` makes on the way in, and is left behind where it has one.
+- The envelope is the contact's own, its UID above all.
+
+What the fold leaves behind is struck on the card as exported beside
+the editor, with the lines the import itself drops: "not imported" is
+what it is, and a line shown plain there is one claiming to arrive.
+Duplicates are not struck — what they say is on the contact already.
+
+Apple numbers a card's `item1.` groups per card, so an arriving line's
+group can name one the contact's card already uses. It is renumbered
+past all of them, and the label hung off it moves with it — the rule
+`Vcf.read` already keeps, that a label goes wherever the line it names
+goes.
+
+Everything the fold decided is in front of the importer as an editor,
+not applied behind them: the name that stood is in the name boxes,
+the phone that came in is a row, and either can be changed before the
+Save. That is the walk's whole posture — look at a contact and fix
+it — and it is why the default for every clash is the book's own
+value rather than a rule clever enough to need explaining.
+
+## The Save
+
+`Import::Write.update`, which is `Store#save_edit` — the contact
+editor's own write, so the change log records an edit of that contact
+under its own id — then `Store#regroup`.
+
+The groups beside it start from that contact's own, ticked, plus the
+group for the import, so the group the walk ends on lists everything
+it touched. Everyone's book is not added: whether the contact syncs was
+settled before this import, and it is ticked or not as it already is.
+The boxes the page loaded with go back as `was[]`, the groups dialog's
+own diff (`Web#apply_groups`), so a membership changed elsewhere in the
+meantime is left as it is.
+
+The fold is derived again at the Save rather than trusted from the
+page, so the etag the form carried refuses a save over a contact that
+changed since, as every editor here does. A contact deleted since is
+refused outright: writing the card as a new contact instead would be a
+different Save from the one pressed.
+
+The row is staged with the updated card after the write lands, not
+before as a new contact's is. Until the write, the row is still the
+card it arrived as, and a refused or failed Save should come back to
+that. After it, the row is saved and names the contact it went into,
+like any other.
+
+## Non-goals
+
+- Marking the rows that have a match in the walk's list. Scoring every
+ row against every contact on every screen is the cost of the whole
+ file times the whole book, per page load, and the row's own screen
+ says it when the row is opened.
+- Choosing a match automatically, even an exact one.
+- Folding field by field — a pick per property. The editor is already
+ the place to settle each field, with both cards in view.
+- Merging two contacts already in the book. That is a question about
+ the book, not the import, and belongs on a contact's own page if
+ anywhere.
+- Matching on addresses or notes. They identify a household or say
+ something about a person rather than naming one.
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 04e4210..f738243 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -83,14 +83,17 @@ module ProTacts
# @rbs @back: ([String, String])?
# @rbs @aside: Phlex::HTML?
# @rbs @fields: Phlex::HTML?
+ # @rbs @lead: Phlex::HTML?
- # The last six are the import's: the same editor, over a card
+ # The last seven are the import's: the same editor, over a card
# that is not stored yet, saving into the import rather than
# into a contact, rendered beside the card as it was exported
# (Admin::ImportOriginal) and carrying what the walk decides
# about a contact that a card cannot hold — which groups it
# joins (Admin::ImportGroups), inside this form so that one Save
- # writes the lot. `sidebar` is the walk's own list of contacts,
+ # writes the lot, and `lead` what it writes into — a new contact
+ # or one the book already has (Admin::ImportTarget), above the
+ # fields it decides. `sidebar` is the walk's own list of contacts,
# standing beside the pair (Admin::ImportSidebar), and `save`
# the submit's words, which on an import say where the card is
# going because it is not there yet. One editor rather than a
@@ -98,14 +101,15 @@ module ProTacts
# about is a field an import writes and an edit cannot undo.
# Defaulted to the contact's own, so the ordinary edit says
# nothing about any of it.
- #: (contact: Contact, ?notice: String?, ?action: String?, ?back: [String, String]?, ?aside: Phlex::HTML?, ?fields: Phlex::HTML?, ?sidebar: Phlex::HTML?, ?save: String?) -> void
+ #: (contact: Contact, ?notice: String?, ?action: String?, ?back: [String, String]?, ?aside: Phlex::HTML?, ?fields: Phlex::HTML?, ?lead: Phlex::HTML?, ?sidebar: Phlex::HTML?, ?save: String?) -> void
def initialize(contact:, notice: nil, action: nil, back: nil, aside: nil, fields: nil,
- sidebar: nil, save: nil)
+ lead: nil, sidebar: nil, save: nil)
@contact = contact
@action = action || "/contacts/#{contact.id}"
@back = back
@aside = aside
@fields = fields
+ @lead = lead
@sidebar = sidebar
@save = save || "Save"
# The rows are the contact's own, never what a group lends it
@@ -151,6 +155,9 @@ module ProTacts
) do
form(action: @action, method: "post", class: "field-stack", id: FORM) do
input(type: "hidden", name: "etag", value: @contact.etag)
+ # First, and only where something passed it: what the
+ # save writes into decides every field below it.
+ render @lead if @lead
# The caption is an element rather than bare text
# because the row is a grid (admin.css): a text node
# would still land in the type column as an
diff --git a/lib/pro_tacts/admin/import_groups.rb b/lib/pro_tacts/admin/import_groups.rb
index 92d7640..c2af4cd 100644
--- a/lib/pro_tacts/admin/import_groups.rb
+++ b/lib/pro_tacts/admin/import_groups.rb
@@ -42,6 +42,7 @@ module ProTacts
# @rbs @joined: Array[String]
# @rbs @named: Array[String]
# @rbs @capped: Array[String]
+ # @rbs @was: Array[String]
# Alphabetical, the groups dialog's own order: this is a list to
# find a name in, and nothing here stays put across a rename.
@@ -60,17 +61,26 @@ module ProTacts
# back to untick. The names take rows from the limit without
# being in the list the cap reads, so they are counted into it
# rather than listed in it.
- #: (groups: Array[Store::GroupChoice], joined: Array[String], named: Array[String]) -> void
- def initialize(groups:, joined:, named:)
+ #
+ # `was` is the groups a contact the book already has is in, for a
+ # card being folded into it (Import::Merge): sent back beside the
+ # boxes, the groups dialog's own `was[]`, so the save moves only
+ # what was toggled (Web#apply_groups). A new contact is in none.
+ #: (groups: Array[Store::GroupChoice], joined: Array[String], named: Array[String], ?was: Array[String]) -> void
+ def initialize(groups:, joined:, named:, was: [])
@groups = groups.sort_by { it.label.downcase }
@joined = joined
@named = named
+ @was = was
@capped = GroupFilter.capped(@groups.map(&:id), joined:, shown: named.length)
end
def view_template
div(class: "field-stack", x_data: GroupFilter::STATE) do
span(class: "type-label") { "groups" }
+ @was.each do |id|
+ input(type: "hidden", name: "was[]", value: id)
+ end
# Enter guarded rather than the filter moved: the dialog
# keeps its filter outside the form it filters, and here
# there is no outside — the boxes are the editor's own
diff --git a/lib/pro_tacts/admin/import_target.rb b/lib/pro_tacts/admin/import_target.rb
new file mode 100644
index 0000000..f2e1253
--- /dev/null
+++ b/lib/pro_tacts/admin/import_target.rb
@@ -0,0 +1,55 @@
+require "pro_tacts/admin/phlex"
+
+module ProTacts
+ module Admin
+ # What a row's Save will write, at the top of the walk's editor: the
+ # card as a new contact, or folded into one the book already has
+ # that it looks like (Import::Match,
+ # docs/plans/2026-09-23-merging-on-import.md). Rendered only where
+ # there is a contact to offer — a toggle with one side is not a
+ # choice.
+ #
+ # Gloss's segmented tabs, a filter over one record rather than
+ # navigation between pages, and links rather than buttons: each
+ # side is its own rendering of the editor, over the arriving card
+ # or over the contact it would update (Import::Merge), so choosing
+ # one is a GET for that screen. The form carries the choice back
+ # as `into`, the contact the save updates; none is a new contact.
+ class ImportTarget < Phlex::HTML
+ # @rbs @row: String
+ # @rbs @matches: Array[::ProTacts::Contact]
+ # @rbs @into: ::ProTacts::Contact?
+
+ # `row` is the row's own screen, `matches` the contacts it looks
+ # like, and `into` the one this screen is updating, if any.
+ #: (row: String, matches: Array[::ProTacts::Contact], into: ::ProTacts::Contact?) -> void
+ def initialize(row:, matches:, into:)
+ @row = row
+ @matches = matches
+ @into = into
+ end
+
+ # A div around the tablist, because the form it sits in is a
+ # column that stretches its children and the segments are a
+ # track of their own width.
+ def view_template
+ div do
+ div(role: "tablist", aria_label: "Import as", data: {variant: "segmented"}) do
+ tab(@row, "new contact", @into.nil?)
+ @matches.each do |match|
+ tab("#{@row}?into=#{match.id}", "update #{match.name || match.id}", @into&.id == match.id)
+ end
+ end
+ input(type: "hidden", name: "into", value: @into.id) if @into
+ end
+ end
+
+ private
+
+ #: (String href, String label, bool selected) -> void
+ def tab(href, label, selected)
+ a(href:, role: "tab", aria_selected: selected.to_s) { label }
+ end
+ end
+ end
+end
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 9e6d283..e57db9e 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -175,6 +175,15 @@ module ProTacts
@own = self.class.new(id: @id, stored: @stored, birthday: @birthday, inherited: [])
end
+ # The same contact over another card and birthday, still lent what
+ # its groups lend it: what an import proposes this contact become
+ # (Import::Merge), composed the way the stored one is so that the
+ # editor rendered over it carries the etag its save will recompute.
+ #: (stored: VCard, birthday: Birthday?) -> Contact
+ def with(stored:, birthday:)
+ self.class.new(id: @id, stored:, birthday:, inherited: @inherited)
+ end
+
#: () -> String
def etag
return @etag if defined?(@etag)
diff --git a/lib/pro_tacts/import/match.rb b/lib/pro_tacts/import/match.rb
new file mode 100644
index 0000000..76fe1b0
--- /dev/null
+++ b/lib/pro_tacts/import/match.rb
@@ -0,0 +1,130 @@
+require "pro_tacts/contact"
+
+module ProTacts
+ module Import
+ # The contacts already in the book that an arriving card may be
+ # the same person as, offered beside that card's editor as the
+ # ones it could update instead of arriving new
+ # (docs/plans/2026-09-23-merging-on-import.md).
+ #
+ # The three things a person is recognised by — the name, an email
+ # address, a phone number — because what an import meets is the
+ # same person spelled a little differently: a typo, a middle
+ # initial, a dot in an address. Edit distance for the name and
+ # the email; a phone number has to be the same number, its country
+ # code and punctuation aside, because one digit off is a different
+ # line rather than a typo of this one, and a household's lines are
+ # often a digit apart. A contact is offered when any one of the
+ # three comes close enough — the same email under a nickname is
+ # the case worth catching, and a sum would bury it — and the
+ # offers are ranked by all three, so the one agreeing on more
+ # comes first. The offer changes nothing until it is taken.
+ module Match
+ # How alike two values must be to offer the contact, as the share
+ # of the longer one an edit leaves alone: "Jon Smith" and "John
+ # Smith" are one edit in ten characters, 0.9.
+ THRESHOLD = 0.8 #: Float
+
+ # How many are offered, best first. The toggle above the editor
+ # is a row of them, and a row past three is a list to read.
+ LIMIT = 3 #: Integer
+
+ # A phone number's national number, near enough everywhere: the
+ # digits a country code or a trunk prefix sit in front of. Read
+ # from the right, so "+1 555 0100" and "(555) 0100" compare as
+ # the same number.
+ PHONE_DIGITS = 10 #: Integer
+
+ # The contacts `arriving` might be, most alike first.
+ #: (Contact arriving, Array[Contact] contacts) -> Array[Contact]
+ def self.candidates(arriving, contacts)
+ scored = contacts.filter_map { |contact|
+ fields = likeness(arriving, contact)
+ next if (fields.max || 0.0) < THRESHOLD
+
+ pair = [contact, fields.sum(0.0)] #: [Contact, Float]
+ pair
+ }
+ scored.sort_by { -it[1] }.first(LIMIT).map { it[0] }
+ end
+
+ # How alike the two are in each of the three, from 0 for nothing
+ # alike to 1 for a value they share.
+ #: (Contact arriving, Contact stored) -> Array[Float]
+ def self.likeness(arriving, stored)
+ [
+ closest(names(arriving), names(stored)),
+ closest(emails(arriving), emails(stored)),
+ phones(arriving).intersect?(phones(stored)) ? 1.0 : 0.0,
+ ]
+ end
+
+ # A phone number as the match compares it, and as the merge asks
+ # whether a contact already has it (Import::Merge): its digits,
+ # the last PHONE_DIGITS of them.
+ #: (String value) -> String
+ def self.phone(value)
+ value.delete("^0-9").chars.last(PHONE_DIGITS).join
+ end
+
+ # Levenshtein's distance: the fewest single-character insertions,
+ # deletions and substitutions that turn one string into the
+ # other, a row of the table at a time.
+ #: (String a, String b) -> Integer
+ def self.distance(a, b)
+ above = (0..b.length).to_a
+ a.each_char.with_index(1) do |x, i|
+ row = [i]
+ b.each_char.with_index(1) do |y, j|
+ substitution = above.fetch(j - 1) + (x == y ? 0 : 1)
+ deletion = above.fetch(j) + 1
+ insertion = row.fetch(j - 1) + 1
+ row << [substitution, deletion, insertion].min.to_i
+ end
+ above = row
+ end
+ above.fetch(b.length)
+ end
+
+ # The best likeness of any value on one side to any on the other,
+ # and none for a side with nothing to compare.
+ #: (Array[String] ours, Array[String] theirs) -> Float
+ def self.closest(ours, theirs)
+ ours.product(theirs).map { |a, b| likeness_of(a, b) }.max || 0.0
+ end
+
+ #: (String a, String b) -> Float
+ def self.likeness_of(a, b)
+ 1.0 - distance(a, b).fdiv([a.length, b.length].max.to_i)
+ end
+
+ # The name as its words, sorted: "Booles, Jane" and "Jane
+ # Booles" are one name written two ways, and an edit distance
+ # over the two spellings would call them strangers.
+ #: (Contact contact) -> Array[String]
+ def self.names(contact)
+ [contact.name.to_s.downcase.scan(/[[:alnum:]]+/).sort.join(" ")].reject(&:empty?)
+ end
+
+ # An address as the part before its @, without case. The domain
+ # is shared by everyone at it, so over whole addresses
+ # "ada@example.com" is four edits in sixteen from
+ # "mary@example.com" — two people alike for the length of a
+ # domain — while "jane.booles" at two providers is one person.
+ #: (Contact contact) -> Array[String]
+ def self.emails(contact)
+ contact.emails.map {
+ address = it.value.strip.downcase
+ address.include?("@") ? address.rpartition("@").first : address
+ }.reject(&:empty?)
+ end
+
+ #: (Contact contact) -> Array[String]
+ def self.phones(contact)
+ contact.phones.map { phone(it.value) }.reject(&:empty?)
+ end
+
+ private_class_method :closest, :likeness_of, :names, :emails, :phones
+ end
+ end
+end
diff --git a/lib/pro_tacts/import/merge.rb b/lib/pro_tacts/import/merge.rb
new file mode 100644
index 0000000..2e92f18
--- /dev/null
+++ b/lib/pro_tacts/import/merge.rb
@@ -0,0 +1,170 @@
+require "pro_tacts/birthday"
+require "pro_tacts/contact"
+require "pro_tacts/import/match"
+require "pro_tacts/import/vcf"
+require "pro_tacts/vcard"
+
+module ProTacts
+ module Import
+ # An arriving card folded into a contact the book already has: what
+ # the walk's editor renders when its toggle names that contact
+ # rather than a new one (docs/plans/2026-09-23-merging-on-import.md).
+ #
+ # The contact's own card is the base and keeps every byte it has;
+ # 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
+ # the card as exported the way every other line not coming in is
+ # (Admin::ImportOriginal), so nothing is lost without having been
+ # shown first.
+ #
+ # Nothing here writes. The editor renders over #contact, and the
+ # save derives it again to check the etag the form carried, so a
+ # contact that changed in between refuses the save the way any
+ # stale editor does.
+ class Merge
+ # @rbs @into: Contact
+ # @rbs @labels: Hash[String, Array[VCard::Parser::Line]]
+ # @rbs @groups: Hash[String, String]
+ # @rbs @named_groups: Array[String]
+ # @rbs @contact: Contact
+ # @rbs @left: Array[VCard::Parser::Line]
+
+ # What a contact holds many of: an arriving one joins the
+ # contact's unless the contact already has its value.
+ MANY = %w[TEL EMAIL ADR].freeze #: Array[String]
+
+ # 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]
+
+ # The envelope, which the contact has its own of — its UID above
+ # all, the arriving one naming a record in some other book
+ # (Import::Write#identified's reason). Neither kept nor a loss.
+ ENVELOPE = %w[BEGIN END VERSION UID].freeze #: Array[String]
+
+ # The contact the card is folded into, and the card: the staged
+ # one, already pared to what this book reads (Vcf.read).
+ #: (Contact into, VCard card) -> void
+ def initialize(into, card)
+ @into = into
+ @labels = labels(card)
+ @groups = {} #: Hash[String, String]
+ @named_groups = into.stored.properties.filter_map { it.group&.downcase }
+ taken = [] #: Array[String]
+ left = [] #: Array[VCard::Parser::Line]
+ birthday = into.birthday
+ card.lines.each do |line|
+ property = line.property
+ # A line that would not read rides along, Vcf.read's rule:
+ # there is nothing about it anyone could have decided.
+ if property.nil?
+ taken << line.verbatim unless line.verbatim.strip.empty?
+ next
+ end
+
+ name = property.name.upcase
+ if MANY.include?(name)
+ taken.concat(moved(line, property)) unless held?(property)
+ elsif ONE.include?(name)
+ own = into.stored.property(name)
+ if own.nil?
+ taken.concat(moved(line, property))
+ elsif own.value != property.value
+ left << line
+ end
+ elsif name == "BDAY"
+ # Into the model where the contact has no birthday at all,
+ # the split Store#put makes on the way in; a spelling the
+ # model does not read stays a line, that method's own rule.
+ arriving = Birthday.from_property(property)
+ if birthday.nil? && into.stored.lines.none? { it.names?("BDAY") }
+ if arriving
+ birthday = arriving
+ else
+ taken.concat(moved(line, property))
+ end
+ elsif arriving != birthday
+ left << line
+ end
+ elsif !ENVELOPE.include?(name) && name != Vcf::LABEL
+ left << line
+ end
+ end
+ @left = left
+ @contact = into.with(stored: into.stored.insert(taken), birthday:)
+ end
+
+ # The contact as it would be saved, which the editor renders.
+ attr_reader :contact
+
+ # The arriving lines the contact's own stood in for.
+ attr_reader :left
+
+ private
+
+ # Whether the contact already has this value, as it serves it:
+ # an address a group lends it is an address it has.
+ #: (VCard::Parser::Property property) -> bool
+ def held?(property)
+ key = key(property)
+ @into.properties.any? { it.name.casecmp?(property.name) && key(it) == key }
+ end
+
+ # A value as two spellings of it compare: a number by its digits
+ # (Match.phone), an address by its components, the rest without
+ # case or the space around it.
+ #: (VCard::Parser::Property property) -> String
+ def key(property)
+ case property.name.upcase
+ when "TEL" then Match.phone(property.text)
+ when "ADR" then property.components.map { it.strip.downcase }.join(";")
+ else property.text.strip.downcase
+ end
+ end
+
+ # A line's bytes as they join the contact's card, with the label
+ # hung off it. Apple numbers a card's `item1.` groups per card,
+ # so an arriving one can name a group the contact's card already
+ # has; it is renumbered past all of them, and its label
+ # (Vcf::LABEL) moves with it — Vcf.read's rule that a label goes
+ # wherever the line it names goes.
+ #: (VCard::Parser::Line line, VCard::Parser::Property property) -> Array[String]
+ def moved(line, property)
+ group = property.group
+ return [line.verbatim] if group.nil?
+
+ fresh = @groups[group.downcase]
+ lines = fresh ? [line] : [line, *@labels.fetch(group.downcase, [])]
+ fresh ||= (@groups[group.downcase] = fresh_group)
+ lines.map { it.verbatim.sub(/\A#{Regexp.escape(group)}\./i, "#{fresh}.") }
+ end
+
+ #: () -> String
+ def fresh_group
+ number = 1
+ number += 1 while @named_groups.include?("item#{number}")
+ name = "item#{number}"
+ @named_groups << name
+ name
+ end
+
+ # The arriving card's labels, by the group each names.
+ #: (VCard card) -> Hash[String, Array[VCard::Parser::Line]]
+ def labels(card)
+ labels = {} #: Hash[String, Array[VCard::Parser::Line]]
+ card.lines.each do |line|
+ property = line.property
+ next if property.nil? || !property.name.casecmp?(Vcf::LABEL)
+
+ group = property.group
+ (labels[group.downcase] ||= []) << line if group
+ end
+ labels
+ end
+ end
+ end
+end
diff --git a/lib/pro_tacts/import/write.rb b/lib/pro_tacts/import/write.rb
index 075acd1..8dca61c 100644
--- a/lib/pro_tacts/import/write.rb
+++ b/lib/pro_tacts/import/write.rb
@@ -100,6 +100,31 @@ module ProTacts
@store.contact(id) || raise("#{id} was written and is not stored")
end
+ # The other Save a row can make: its card folded into a contact
+ # the book already has, rather than written as a new one
+ # (Import::Merge, docs/plans/2026-09-23-merging-on-import.md).
+ # The contact editor's own write, Store#save_edit, because this
+ # is that contact edited — its id, its UID and its change-log
+ # entry stay its own — and then its groups moved the way the
+ # groups dialog moves them: `joins` and `leaves` are what the
+ # boxes beside it changed, `named` the groups they asked to be
+ # made.
+ #: (Store store, String id, VCard card, birthday: Birthday?, ?joins: Array[String], ?leaves: Array[String], ?named: Array[String]) -> Contact
+ def self.update(store, id, card, birthday:, joins: [], leaves: [], named: [])
+ new(store).update(id, card, birthday, joins, leaves, named)
+ end
+
+ #: (String id, VCard card, Birthday? birthday, Array[String] chosen, Array[String] leaving, Array[String] named) -> Contact
+ def update(id, card, birthday, chosen, leaving, named)
+ @store.save_edit(id, card, birthday:)
+
+ join = [*chosen, *named.map { group_id(it) }]
+ @store.regroup(id, join: join.uniq, leave: leaving) unless join.empty? && leaving.empty?
+
+ # Read back for #call's reason: the groups moved what it serves.
+ @store.contact(id) || raise("#{id} was edited and is not stored")
+ end
+
private
# The card under the id it is stored as. The source's UID goes
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index 370cf67..efa5e74 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -4,8 +4,11 @@ require "pro_tacts/admin/import_groups"
require "pro_tacts/admin/import_original"
require "pro_tacts/admin/import_saved"
require "pro_tacts/admin/import_sidebar"
+require "pro_tacts/admin/import_target"
require "pro_tacts/admin/import_upload"
require "pro_tacts/birthday"
+require "pro_tacts/import/match"
+require "pro_tacts/import/merge"
require "pro_tacts/import/write"
require "pro_tacts/import/staged"
require "pro_tacts/import/vcf"
@@ -56,8 +59,11 @@ module ProTacts
# save rewrites a card in place and never adds or removes one.
r.on Integer do |index|
r.is do
+ # `into` is the contact the row's editor is folding its
+ # card into, chosen at the top of that editor
+ # (Admin::ImportTarget); none is the card as a new one.
r.get do
- card_screen(upload, index)
+ card_screen(upload, index, into: r.params["into"].to_s)
end
r.post do
@@ -158,14 +164,22 @@ module ProTacts
# being nothing stored to open; its Save is the import POST's own
# double-write guard to catch.
#
+ # A row not saved yet can be written two ways, and `into` says
+ # which: blank for a new contact, or the id of one the book
+ # already has, whose editor this becomes with the card folded in
+ # (Import::Merge) — the lines that fold leaves behind struck on the
+ # card as exported beside it, as the ones the import itself drops
+ # are. An id naming nobody (a contact deleted since the toggle was
+ # drawn) is the new contact, there being nothing to fold into.
+ #
# `joined` and `named` are the groups a refused save had ticked,
# and none on the way in: nothing is staged between screens, the
# Save that would have written an answer being the Save that
# writes the contact, so a card opened afresh is asked the
# question with the defaults ticked (#import_picker) rather than
# with an earlier answer read back.
- #: (String upload, Integer index, ?notice: String?, ?joined: Array[String]?, ?named: Array[String]?) -> String?
- def card_screen(upload, index, notice: nil, joined: nil, named: nil)
+ #: (String upload, Integer index, ?into: String, ?notice: String?, ?joined: Array[String]?, ?named: Array[String]?) -> String?
+ def card_screen(upload, index, into: "", notice: nil, joined: nil, named: nil)
staged = staged_cards(upload)
return expired_screen if staged.nil?
@@ -180,35 +194,56 @@ module ProTacts
group, saved = Import::Staged.saved(upload)
stored = saved[index.to_s]
contact = stored ? store.contact(stored) : nil
- aside = Admin::ImportOriginal.new(card: original, dropped: Import::Vcf.read(original).dropped)
+ dropped = Import::Vcf.read(original).dropped
sidebar = walk_sidebar(upload, originals, revised, saved, current: index)
response["Content-Type"] = "text/html; charset=utf-8"
return Admin::ImportSaved.call(
contact:,
groups: store.groups_of(contact.id),
- aside:,
+ aside: Admin::ImportOriginal.new(card: original, dropped:),
sidebar:,
edit: "/import/#{upload}/#{index}/edit",
notice:,
) if contact
+ arriving = import_contact(card, index)
+ target = into.empty? ? nil : store.contact(into)
+ merge = target && Import::Merge.new(target, card)
Admin::ContactsEdit.call(
- contact: import_contact(card, index),
+ contact: merge ? merge.contact : arriving,
notice:,
action: "/import/#{upload}/#{index}",
# The submit says what it does — the import of this one
- # contact — because it is the one editor in the app whose Save
- # creates the record rather than amending it. The row beside
- # it wears its check only once this has been pressed
+ # contact, or the update of the one it is folded into —
+ # because this is the one editor in the app whose Save can
+ # create the record rather than amend it. The row beside it
+ # wears its check only once this has been pressed
# (Admin::ImportSidebar).
- save: "Import",
- aside:,
- fields: import_picker(group, joined, named),
+ save: merge ? "Update" : "Import",
+ aside: Admin::ImportOriginal.new(card: original, dropped: merge ? dropped + merge.left : dropped),
+ fields: import_picker(group, joined, named, member: target),
+ lead: import_target(upload, index, arriving, target),
sidebar:,
)
end
+ # The toggle at the top of an unsaved row's editor, or none when
+ # nothing in the book looks like the card: the contacts it might
+ # be (Import::Match), read off the card as it arrived rather than
+ # as the fold would make it. The one being updated is always among
+ # them — a toggle has to show the side it is on, and a contact
+ # edited since it was offered can have stopped looking like the
+ # card.
+ #: (String upload, Integer index, Contact arriving, Contact? target) -> Admin::ImportTarget?
+ def import_target(upload, index, arriving, target)
+ matches = Import::Match.candidates(arriving, store.contacts)
+ matches = [target, *matches] if target && matches.none? { it.id == target.id }
+ return nil if matches.empty?
+
+ Admin::ImportTarget.new(row: "/import/#{upload}/#{index}", matches:, into: target)
+ end
+
# The editor over a stored row's contact, reached from that row's
# read screen: #apply_edit's form — the contact's own card, its
# etag, no group boxes — posting to the row itself, where the
@@ -257,15 +292,26 @@ module ProTacts
# life of a server — the first card of the first import into an
# empty book, which is the write that creates it — and a row for
# a group that does not exist would be a worse answer than none.
- #: (String group, Array[String]? joined, Array[String]? named) -> Admin::ImportGroups
- def import_picker(group, joined, named)
+ #
+ # A card being folded into a contact the book already has
+ # (`member`) comes in under that contact's groups instead of
+ # everyone's book: they are ticked, and sent back as `was[]` so the
+ # 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)
choices = store.group_choices
lot = group.empty? ? nil : choices.find { it.name == group }
everyone = choices.find { it.name == Store::EVERYONE }
+ none = [] #: Array[String]
+ was = member ? store.groups_of(member.id).map(&:id) : none
+ ticked = member ? was : [everyone&.id].compact
Admin::ImportGroups.new(
groups: choices,
- joined: joined || [everyone&.id, lot&.id].compact,
+ joined: joined || [*ticked, lot&.id].compact.uniq,
named: named || [(group unless lot || group.empty?)].compact,
+ was:,
)
end
@@ -298,7 +344,22 @@ module ProTacts
land: "/import/#{upload}/#{index}")
end
- contact = import_contact(card, index)
+ # A card being folded into a contact the book already has
+ # (#card_screen's `into`): that contact's editor, the fold
+ # derived again here rather than trusted from the page, so the
+ # etag below refuses a save over a contact that changed since.
+ # One gone since is refused outright — writing the card as a new
+ # contact instead would be a different Save than the one pressed.
+ into = r.params["into"].to_s
+ target = into.empty? ? nil : store.contact(into)
+ if !into.empty? && target.nil?
+ return card_screen(upload, index, notice: "The contact this card was updating is gone; nothing was saved.")
+ end
+
+ merge = target && Import::Merge.new(target, card)
+ contact = merge ? merge.contact : import_contact(card, index)
+ # Which toggle the refusals below re-render under.
+ into = target ? target.id : ""
# The groups ride in the same form and are written by the same
# Save (Admin::ImportGroups): a card the walk has looked at is
@@ -308,14 +369,18 @@ module ProTacts
# which is a box like any other here (#import_picker). A name
# typed into the filter joins the names already standing for
# this contact, and is made by the write below
- # (Import::Write#group_id).
+ # (Import::Write#group_id). `was` is the groups a contact being
+ # updated was in when the page loaded, #apply_groups' own `was`,
+ # and none for a new one.
#
# Read before the refusals rather than after, so a card sent
# back to be fixed comes back with its boxes as they were
# ticked: nothing is staged, and the form is the only record of
# them until the write.
choices = store.group_choices
- ticked = ids_in(r.params["groups"]) & choices.map(&:id)
+ known = choices.map(&:id)
+ ticked = ids_in(r.params["groups"]) & known
+ was = ids_in(r.params["was"]) & known
standing = ids_in(r.params["named"]).map(&:strip).reject(&:empty?)
fresh = r.params["new"].to_s.strip
wanted = (fresh.empty? ? standing : standing + [fresh]).uniq
@@ -336,7 +401,7 @@ module ProTacts
middle = r.params["middle"].to_s.strip
last = r.params["last"].to_s.strip
if first.empty? && last.empty?
- return card_screen(upload, index, notice: "A contact needs a name.", joined: ticked, named: wanted)
+ return card_screen(upload, index, into:, notice: "A contact needs a name.", joined: ticked, named: wanted)
end
fields = r.params["birthday"]
@@ -345,7 +410,7 @@ module ProTacts
begin
Admin::CardForm.birthday(fields)
rescue ArgumentError
- return card_screen(upload, index, notice: "That birthday is not a shape a date can take.",
+ return card_screen(upload, index, into:, notice: "That birthday is not a shape a date can take.",
joined: ticked, named: wanted)
end
else
@@ -355,13 +420,35 @@ module ProTacts
# The snapshot guard the editor always carries, over the staged
# card rather than a stored one: two tabs open on the same
# import are the case, and applying this save over the other
- # one's would revert it.
+ # one's would revert it. Over a fold, it is the contact's own
+ # guard too.
if r.params["etag"].to_s != contact.etag
- return card_screen(upload, index, notice: "This card changed since the page loaded; nothing was saved.",
+ return card_screen(upload, index, into:, notice: "This card changed since the page loaded; nothing was saved.",
joined: ticked, named: wanted)
end
edited = Admin::CardForm.contact_card(contact, first, middle, last, r.params)
+ if target
+ # A stored contact, whose birthday the model holds: the contact
+ # editor's own save (#apply_edit), down to its one refusal.
+ if birthday && carries_own_bday?(contact)
+ return card_screen(upload, index, into:,
+ notice: "This contact's card carries its own birthday spelling; nothing was saved.",
+ joined: ticked, named: wanted)
+ end
+
+ written = Import::Write.update(store, target.id, edited, birthday:,
+ joins: ticked - was, leaves: was - ticked, named: wanted)
+ # Staged after the write rather than before, unlike a new
+ # contact's below: until it lands this row is still the card
+ # it arrived as, and staging the contact's card in its place
+ # would make that the card a refused or failed save came back
+ # to.
+ revised[index] = edited
+ Import::Staged.update(upload, joined(revised))
+ return walk_on(r, upload, index, written, saved, revised.length)
+ end
+
# The birthday goes back into the card, an import having no
# model to hold one until it is written. Skipped for a card carrying
# a BDAY spelling the model does not read: that line stayed in
@@ -391,12 +478,20 @@ module ProTacts
revised[index] = edited
Import::Staged.update(upload, joined(revised))
written = Import::Write.call(store, edited, joins: ticked, named: wanted, everyone: syncing)
+ walk_on(r, upload, index, written, saved, revised.length)
+ end
+
+ # A row's Save landed, as a new contact or an updated one: the row
+ # noted as saved, and the walk on from it. `saved` is the rows
+ # saved before this one, and `count` how many the file holds.
+ #: (untyped r, String upload, Integer index, Contact written, Hash[String, String] saved, Integer count) -> untyped
+ def walk_on(r, upload, index, written, saved, count)
Import::Staged.record(upload, index.to_s, written.id)
# The last one: there is nothing left to come back to, so the
# walk is over rather than a list of rows that all say the same
# thing.
- return close_walk(r, upload) if saved.length + 1 == revised.length
+ return close_walk(r, upload) if saved.length + 1 == count
# Onto the next row nobody has read, in the file's own order —
# the walk steps from card to card, a screen between saves
@@ -405,7 +500,7 @@ module ProTacts
# written is named here too; a `fetch` rather than a `first`
# because empty is the case the line above took, and reaching
# it is a broken assumption rather than a screen to render.
- remaining = (0...revised.length).reject { |i| saved.key?(i.to_s) || i == index }
+ remaining = (0...count).reject { |i| saved.key?(i.to_s) || i == index }
r.redirect "/import/#{upload}/#{remaining.fetch(0)}", 303
end
diff --git a/public/admin.css b/public/admin.css
index 269ef03..cc253e6 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -999,3 +999,16 @@ dialog > .field-stack > input[type="search"] {
.walk-list .diff-removed {
white-space: nowrap;
}
+
+/* What a walk's row saves into (Admin::ImportTarget): Gloss's
+ segmented tabs, whose track is one line of its own width. A match
+ is a person's whole name and there can be three of them, so the
+ track wraps between segments rather than inside one — a name
+ broken over two lines reads as two segments. The form's own
+ selector for the weight over Gloss's `all: unset`. */
+.card-body form [role="tablist"][data-variant="segmented"] {
+ flex-wrap: wrap;
+}
+.card-body form [role="tablist"][data-variant="segmented"] [role="tab"] {
+ white-space: nowrap;
+}
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 2a1c793..a37bdc6 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -14,7 +14,7 @@ module ProTacts
# ships no signatures and the views are ignored (see the
# Steepfile).
class ContactsEdit
- def self.call: (contact: Contact, ?notice: String?, ?action: String?, ?back: [String, String]?, ?aside: untyped, ?fields: untyped, ?sidebar: untyped, ?save: String?) -> String
+ def self.call: (contact: Contact, ?notice: String?, ?action: String?, ?back: [String, String]?, ?aside: untyped, ?fields: untyped, ?lead: untyped, ?sidebar: untyped, ?save: String?) -> String
end
class Dashboard
@@ -52,7 +52,11 @@ module ProTacts
end
class ImportGroups
- def self.new: (groups: Array[::ProTacts::Store::GroupChoice], joined: Array[String], named: Array[String]) -> instance
+ def self.new: (groups: Array[::ProTacts::Store::GroupChoice], joined: Array[String], named: Array[String], ?was: Array[String]) -> instance
+ end
+
+ class ImportTarget
+ def self.new: (row: String, matches: Array[Contact], into: Contact?) -> instance
end
class ImportSidebar
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index 8eafedb..67386eb 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -35,6 +35,17 @@ class AdminImportPagesTest < Minitest::Test
END:VCARD
CARD
+ # Someone the book already has, whom JANE looks like.
+ STORED_JANE = <<~CARD.gsub("\n", "\r\n")
+ BEGIN:VCARD
+ VERSION:3.0
+ N:Booles;Jane;;;
+ FN:Jane Booles
+ EMAIL;type=INTERNET:jane@example.com
+ UID:jane
+ END:VCARD
+ CARD
+
def app
ProTacts::Web
end
@@ -67,12 +78,16 @@ class AdminImportPagesTest < Minitest::Test
# page it came from was rendered with, and the boxes that page came
# with sent back the way a browser sends them — a card arrives with
# the groups it comes in under already ticked (Web#import_picker).
- def save(id, index, first:, last:, **params)
- get "/import/#{id}/#{index}"
+ # `into` opens the row folded into that contact rather than as a new
+ # one (Admin::ImportTarget), and the page's own hidden fields go
+ # back with the rest.
+ def save(id, index, first:, last:, into: nil, **params)
+ get "/import/#{id}/#{index}#{"?into=#{into}" if into}"
post "/import/#{id}/#{index}",
{"etag" => etag, "first" => first, "middle" => "", "last" => last,
"nickname" => "", "note" => "",
- "groups" => ticked("groups[]"), "named" => ticked("named[]")}.merge(params)
+ "groups" => ticked("groups[]"), "named" => ticked("named[]"),
+ "into" => hidden("into").first.to_s, "was" => hidden("was[]")}.merge(params)
end
# The values of the boxes the last page rendered ticked.
@@ -80,6 +95,11 @@ class AdminImportPagesTest < Minitest::Test
last_response.body.scan(/name="#{Regexp.escape(name)}" value="([^"]*)" checked/).flatten
end
+ # The values of the last page's hidden fields of that name.
+ def hidden(name)
+ last_response.body.scan(/<input type="hidden" name="#{Regexp.escape(name)}" value="([^"]*)">/).flatten
+ end
+
# The group this import files its arrivals under, named when the
# upload happens: every Save ticks it, so the name is read off the
# staged files themselves — there is no form for it, and no screen
@@ -744,6 +764,121 @@ class AdminImportPagesTest < Minitest::Test
# Swept out from under a screen left open, or come back to after
# its last row was saved: the import is gone and only the person
# has another copy.
+ # A card that looks like someone already in the book offers to
+ # update them instead, at the top of its editor: new until the
+ # toggle says otherwise, so nothing in the book changes by default.
+ def test_a_card_like_a_contact_in_the_book_offers_to_update_it
+ with_contacts({"jane" => STORED_JANE}) do |_store|
+ id = upload(JANE)
+
+ get "/import/#{id}/0"
+
+ assert_includes last_response.body, %(<div role="tablist" aria-label="Import as" data-variant="segmented">)
+ assert_includes last_response.body,
+ %(<a href="/import/#{id}/0" role="tab" aria-selected="true">new contact</a>)
+ assert_includes last_response.body,
+ %(<a href="/import/#{id}/0?into=jane" role="tab" aria-selected="false">update Jane Booles</a>)
+ refute_includes last_response.body, %(name="into")
+ assert_includes last_response.body, %(>Import</button>)
+ end
+ end
+
+ # A toggle with one side is not a choice.
+ def test_a_card_like_nobody_in_the_book_offers_nothing_to_update
+ with_contacts({"theo" => STORED_JANE.sub("Booles;Jane", "Marsh;Theo").sub("FN:Jane Booles", "FN:Theo Marsh")
+ .sub("jane@example.com", "theo@elsewhere.org")}) do |_store|
+ id = upload(JANE)
+
+ get "/import/#{id}/0"
+
+ refute_includes last_response.body, %(role="tablist")
+ end
+ end
+
+ # The update is the contact's own editor with the card folded in:
+ # the contact's name and email, the card's phone beside them, and
+ # the card's lines that the contact's own stood in for struck on
+ # the card as exported.
+ def test_updating_opens_the_contact_with_the_card_folded_in
+ with_contacts({"jane" => STORED_JANE}) do |_store|
+ id = upload(JANE.sub("FN:Jane Booles", "FN:Jane B. Booles"))
+
+ get "/import/#{id}/0?into=jane"
+
+ assert_includes last_response.body,
+ %(<a href="/import/#{id}/0?into=jane" role="tab" aria-selected="true">update Jane Booles</a>)
+ assert_includes last_response.body, %(<input type="hidden" name="into" value="jane">)
+ assert_includes last_response.body, %(value="jane@example.com")
+ assert_includes last_response.body, %(value="+1 555 0100")
+ assert_includes last_response.body, %(<li data-dropped><span>FN:Jane B. Booles</span>)
+ assert_includes last_response.body, %(>Update</button>)
+ end
+ end
+
+ # Its Save edits that contact rather than adding one: the row is
+ # in, it leads to the contact it was folded into, and the contact
+ # joins the group the import files under beside the groups it was
+ # already in.
+ def test_saving_an_update_changes_the_contact_rather_than_adding_one
+ with_contacts({"jane" => STORED_JANE}) do |store|
+ school = store.create_group(name: "school")
+ store.add_member(school, "jane")
+ id = upload(JANE + PLAIN)
+
+ save(id, 0, first: "Jane", last: "Booles", into: "jane")
+
+ assert_equal 303, last_response.status
+ assert_equal "/import/#{id}/1", last_response.headers["location"]
+ assert_equal ["jane"], store.contacts.map(&:id)
+ jane = store.contact("jane")
+
+ assert_equal ["+1 555 0100"], jane.phones.map(&:value)
+ assert_equal ["jane@example.com"], jane.emails.map(&:value)
+ assert_equal [import_group(id), "school"], store.groups_of("jane").map(&:name).sort
+
+ get "/import/#{id}/0"
+
+ assert_includes last_response.body, %(<dl class="detail-grid">)
+ assert_includes last_response.body, %(<a href="/import/#{id}/0" aria-current="page" data-state="saved">)
+ end
+ end
+
+ # The fold is derived again at the Save, so a contact edited since
+ # the page loaded refuses it the way any stale editor does.
+ def test_an_update_over_a_contact_changed_since_is_refused
+ with_contacts({"jane" => STORED_JANE}) do |store|
+ id = upload(JANE)
+ get "/import/#{id}/0?into=jane"
+ sent = etag
+ store.put("jane", ProTacts::VCard.new(STORED_JANE.sub("END:VCARD", "NICKNAME:Jay\r\nEND:VCARD")))
+
+ post "/import/#{id}/0",
+ "etag" => sent, "first" => "Jane", "middle" => "", "last" => "Booles",
+ "nickname" => "", "note" => "", "into" => "jane"
+
+ assert_includes last_response.body, "This card changed since the page loaded; nothing was saved."
+ assert_includes last_response.body, %(<input type="hidden" name="into" value="jane">)
+ assert_empty store.contact("jane").phones
+ end
+ end
+
+ # And one deleted since is not quietly a new contact instead.
+ def test_an_update_whose_contact_is_gone_is_refused
+ with_contacts({"jane" => STORED_JANE}) do |store|
+ id = upload(JANE)
+ get "/import/#{id}/0?into=jane"
+ sent = etag
+ store.delete("jane")
+
+ post "/import/#{id}/0",
+ "etag" => sent, "first" => "Jane", "middle" => "", "last" => "Booles",
+ "nickname" => "", "note" => "", "into" => "jane"
+
+ assert_includes last_response.body, "The contact this card was updating is gone; nothing was saved."
+ assert_empty store.contacts
+ end
+ end
+
def test_a_walk_that_is_gone_says_so
with_contacts({}) do |store|
id = upload(JANE)
diff --git a/test/pro_tacts/import/test_match.rb b/test/pro_tacts/import/test_match.rb
new file mode 100644
index 0000000..4e4b6a2
--- /dev/null
+++ b/test/pro_tacts/import/test_match.rb
@@ -0,0 +1,108 @@
+require_relative "../../test_helper"
+
+require "pro_tacts/contact"
+require "pro_tacts/import/match"
+
+# The contacts an arriving card might be
+# (docs/plans/2026-09-23-merging-on-import.md).
+class ImportMatchTest < Minitest::Test
+ Match = ProTacts::Import::Match
+
+ def contact(id, name, *lines)
+ card = ["BEGIN:VCARD", "VERSION:3.0", "FN:#{name}", *lines, "UID:#{id}", "END:VCARD", ""].join("\r\n")
+ ProTacts::Contact.new(id:, stored: ProTacts::VCard.new(card), birthday: nil, inherited: [])
+ end
+
+ def test_the_distance_is_levenshteins
+ assert_equal 0, Match.distance("booles", "booles")
+ assert_equal 3, Match.distance("kitten", "sitting")
+ assert_equal 4, Match.distance("", "jane")
+ assert_equal 1, Match.distance("jon", "john")
+ end
+
+ def test_a_name_spelled_a_little_differently_is_offered
+ arriving = contact("0", "Jon Smith")
+ john = contact("john", "John Smith")
+
+ assert_equal [john], Match.candidates(arriving, [john, contact("sam", "Sam Booles")])
+ end
+
+ # The words of a name, not the order an export wrote them in.
+ def test_a_name_written_family_first_is_the_same_name
+ arriving = contact("0", "Booles, Jane")
+ jane = contact("jane", "Jane Booles")
+
+ assert_equal [jane], Match.candidates(arriving, [jane])
+ end
+
+ def test_an_email_address_is_enough_and_its_case_is_not_part_of_it
+ arriving = contact("0", "J. B.", "EMAIL:Jane@Example.com")
+ jane = contact("jane", "Jane Booles", "EMAIL;type=INTERNET:jane@example.com")
+
+ assert_equal [jane], Match.candidates(arriving, [jane])
+ end
+
+ # The part of an address that names someone is the part before its
+ # @: everyone at a domain shares the rest.
+ def test_an_address_is_alike_by_the_part_before_its_domain
+ jane = contact("jane", "Jane Booles", "EMAIL:jane.booles@example.com")
+ ada = contact("ada", "Ada Lovelace", "EMAIL:ada@example.com")
+
+ assert_equal [jane], Match.candidates(contact("0", "J", "EMAIL:jane.boole@example.com"), [jane, ada])
+ assert_equal [jane], Match.candidates(contact("0", "J", "EMAIL:jane.booles@elsewhere.org"), [jane, ada])
+ assert_empty Match.candidates(contact("0", "M", "EMAIL:mary@example.com"), [ada])
+ end
+
+ # A country code on one side and not the other is still one number.
+ def test_a_phone_number_is_enough_however_it_is_written
+ arriving = contact("0", "Mom", "TEL:+1 (555) 010-0200")
+ mother = contact("mother", "Ruth Booles", "TEL;type=CELL:555-010-0200")
+
+ assert_equal [mother], Match.candidates(arriving, [mother])
+ end
+
+ # One digit off is another line: a household's numbers are often
+ # consecutive.
+ def test_a_phone_number_a_digit_off_is_not_the_same_number
+ arriving = contact("0", "Mom", "TEL:+44 7700 900101")
+
+ assert_empty Match.candidates(arriving, [contact("alicia", "Alicia Boole", "TEL:+44 7700 900103")])
+ end
+
+ def test_nothing_alike_is_nothing_offered
+ arriving = contact("0", "Jane Booles", "TEL:555 0100", "EMAIL:jane@example.com")
+
+ assert_empty Match.candidates(arriving, [contact("x", "Theo Marsh", "TEL:212 867 5309", "EMAIL:theo@elsewhere.org")])
+ end
+
+ def test_the_closest_come_first_and_only_a_few_of_them
+ arriving = contact("0", "Jane Booles")
+ exact = contact("exact", "Jane Booles")
+ near = contact("near", "Jane Boole")
+ others = %w[Jane\ Bools Jane\ Bowles Jane\ Booler].each_with_index.map { |name, i| contact("o#{i}", name) }
+
+ offered = Match.candidates(arriving, [near, *others, exact])
+
+ assert_equal Match::LIMIT, offered.length
+ assert_equal "exact", offered.first.id
+ end
+
+ # One field is enough to be offered, and agreeing on more ranks
+ # higher: an address two contacts share puts the one with the name
+ # first.
+ def test_the_contact_agreeing_on_more_comes_first
+ arriving = contact("0", "Ada Lovelace", "EMAIL:ada@example.com")
+ shared = contact("emoji", "Emoji Contact", "EMAIL:ada@example.com")
+ ada = contact("ada", "Ada Lovelace", "EMAIL:ada@example.com")
+
+ assert_equal %w[ada emoji], Match.candidates(arriving, [shared, ada]).map(&:id)
+ end
+
+ def test_a_card_with_nothing_to_compare_matches_nothing
+ arriving = ProTacts::Contact.new(
+ id: "0", stored: ProTacts::VCard.new("BEGIN:VCARD\r\nVERSION:3.0\r\nEND:VCARD\r\n"), birthday: nil, inherited: [],
+ )
+
+ assert_empty Match.candidates(arriving, [contact("jane", "Jane Booles")])
+ end
+end
diff --git a/test/pro_tacts/import/test_merge.rb b/test/pro_tacts/import/test_merge.rb
new file mode 100644
index 0000000..e72a7cf
--- /dev/null
+++ b/test/pro_tacts/import/test_merge.rb
@@ -0,0 +1,112 @@
+require_relative "../../test_helper"
+
+require "pro_tacts/contact"
+require "pro_tacts/import/merge"
+
+# An arriving card folded into a contact the book already has
+# (docs/plans/2026-09-23-merging-on-import.md).
+class ImportMergeTest < Minitest::Test
+ Merge = ProTacts::Import::Merge
+
+ STORED = <<~CARD.gsub("\n", "\r\n")
+ BEGIN:VCARD
+ VERSION:3.0
+ N:Booles;Jane;;;
+ FN:Jane Booles
+ TEL;type=CELL:555-010-0100
+ item1.EMAIL;type=INTERNET:jane@example.com
+ item1.X-ABLabel:_$!<Home>!$_
+ NOTE:Met at work
+ UID:jane
+ END:VCARD
+ CARD
+
+ ARRIVING = <<~CARD.gsub("\n", "\r\n")
+ BEGIN:VCARD
+ VERSION:3.0
+ N:Booles;Janet;;;
+ FN:Janet Booles
+ TEL;type=CELL:+1 (555) 010-0100
+ TEL;type=WORK:555 010 0199
+ item1.EMAIL:jane@work.example
+ item1.X-ABLabel:_$!<Work>!$_
+ NICKNAME:JB
+ NOTE:From the school list
+ BDAY:1985-04-12
+ UID:ABC-123
+ END:VCARD
+ CARD
+
+ def jane(card = STORED, birthday: nil)
+ ProTacts::Contact.new(id: "jane", stored: ProTacts::VCard.new(card), birthday:, inherited: [])
+ end
+
+ def merge(into = jane, arriving = ARRIVING) = Merge.new(into, ProTacts::VCard.new(arriving))
+
+ # The contact's own card is the base, byte for byte.
+ def test_the_contact_keeps_every_line_it_had
+ folded = merge.contact.stored.to_s
+
+ STORED.each_line.reject { it.start_with?("END:") }.each do |line|
+ assert_includes folded, line
+ end
+ assert_equal "jane", merge.contact.id
+ refute_includes folded, "ABC-123"
+ end
+
+ # A number already on the contact, written another way, stays one
+ # number; a new one comes in beside it.
+ def test_a_number_it_lacks_comes_in_and_one_it_has_does_not
+ phones = merge.contact.phones.map(&:value)
+
+ assert_equal ["555-010-0100", "555 010 0199"], phones
+ end
+
+ # Apple numbers `item` groups per card, so the arriving email's
+ # item1 would collide with the contact's own: it is renumbered, and
+ # its label goes with it.
+ def test_a_grouped_line_is_renumbered_with_its_label
+ folded = merge.contact.stored.to_s
+
+ assert_includes folded, "item2.EMAIL:jane@work.example\r\nitem2.X-ABLabel:_$!<Work>!$_\r\n"
+ assert_includes folded, "item1.X-ABLabel:_$!<Home>!$_\r\n"
+ end
+
+ # What a contact holds one of fills a gap and does not overwrite.
+ def test_the_books_own_name_and_note_stand_and_a_missing_nickname_comes_in
+ folded = merge.contact
+
+ assert_equal "Jane Booles", folded.name
+ assert_equal ["Met at work"], folded.notes.map(&:value)
+ assert_equal "JB", folded.nickname
+ end
+
+ # A birthday the contact has none of goes into the model, the split
+ # a stored contact is held in.
+ def test_a_birthday_the_contact_lacks_comes_into_the_model
+ assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), merge.contact.birthday
+ refute_includes merge.contact.stored.to_s, "BDAY"
+ end
+
+ def test_the_books_own_birthday_stands
+ ruth = ProTacts::Birthday.new(month: 4, day: 12)
+
+ assert_equal ruth, merge(jane(birthday: ruth)).contact.birthday
+ end
+
+ # What the fold leaves behind is what the card as exported strikes:
+ # the name and the note the contact's own stood in for, and nothing
+ # the contact already had.
+ def test_what_is_left_behind_is_what_differed
+ left = merge(jane(birthday: ProTacts::Birthday.new(month: 1, day: 2))).left.map { it.verbatim.chomp }
+
+ assert_equal ["N:Booles;Janet;;;", "FN:Janet Booles", "NOTE:From the school list", "BDAY:1985-04-12"], left
+ end
+
+ def test_folding_in_the_same_card_changes_nothing
+ same = merge(jane, STORED)
+
+ assert_equal STORED, same.contact.stored.to_s
+ assert_empty same.left
+ end
+end
diff --git a/test/pro_tacts/import/test_write.rb b/test/pro_tacts/import/test_write.rb
index ec870ec..5a4cf52 100644
--- a/test/pro_tacts/import/test_write.rb
+++ b/test/pro_tacts/import/test_write.rb
@@ -181,6 +181,29 @@ class ImportWriteTest < Minitest::Test
end
end
+ # The other Save a row can make: its card folded into a contact the
+ # book has (Import::Merge), written as that contact's own edit — its
+ # id stays its own and the change log says it was edited — with its
+ # groups moved by what the boxes beside it changed.
+ def test_an_update_edits_the_contact_it_names
+ with_contacts({}) do |store|
+ school = store.create_group(name: "school")
+ jane = Write.call(store, card, joins: [school])
+
+ updated = Write.update(store, jane.id, jane.stored.insert(["TEL:555 0100"]),
+ birthday: ProTacts::Birthday.new(month: 4, day: 12),
+ leaves: [school], named: ["import-20260923T000000Z"])
+
+ assert_equal jane.id, updated.id
+ assert_equal 1, store.contacts.length
+ assert_includes updated.vcard.to_s, "TEL:555 0100\r\n"
+ assert_equal ProTacts::Birthday.new(month: 4, day: 12), updated.birthday
+ assert_includes store.changes_of(jane.id).map(&:action), ProTacts::Store::Action::EDIT
+ assert_empty store.group(school).members
+ assert_equal [jane.id], store.all_groups.find { it.name == "import-20260923T000000Z" }.members
+ end
+ end
+
# Named for when it happened, so what imported together can be found
# together.
def test_the_default_group_is_named_for_the_moment