1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
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 fold"). What it leaves behind is #left, struck on the card
# as exported (Admin::ImportOriginal).
#
# Nothing here writes. The editor renders over #contact, and the
# save derives it again to check the etag the form carried.
class Merge
# @rbs @into: Contact
# @rbs @labels: Hash[String, Array[VCard::Parser::Line]]
# @rbs @groups: Hash[String, String]
# @rbs @named_groups: Array[String]
# @rbs @arriving_notes: 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 IMPP 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 ORG X-ABSHOWAS].freeze #: Array[String]
# The envelope, which the contact has its own of — its UID above
# all, the arriving one naming a record in some other book
# (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]
arriving_notes = [] #: 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?
# A contact holds one note, so several arriving join into
# the one they fill (docs/plans/2026-09-25-one-note-per-
# contact.md); the joined line is written below the walk.
if name == "NOTE"
arriving_notes << property.text
else
taken.concat(moved(line, property))
end
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
# The arriving notes' one line, at the walk's end: whatever
# order they arrived in, the join is the value the book keeps
# and the one-note invariant asks for.
unless arriving_notes.empty?
taken << "NOTE:#{VCard.escape(arriving_notes.join("\n\n"))}\r\n"
end
@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)
if property.name.casecmp?("TEL")
return @into.properties.any? { it.name.casecmp?("TEL") && same_number?(it.text, property.text) }
end
key = key(property)
@into.properties.any? { it.name.casecmp?(property.name) && key(it) == key }
end
# A value as two spellings of it compare: 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 "ADR" then property.components.map { it.strip.downcase }.join(";")
else property.text.strip.downcase
end
end
# Two spellings of one number, the match's comparison
# (Match.phone): the same last ten digits, a country code or a
# trunk prefix on one side and not the other being one number
# (docs/plans/2026-09-23-merging-on-import.md, "Finding them").
# What the match can only score, the fold must decide — a number
# it calls held is one it drops — so two country codes that
# differ are two numbers, `+44 20 7946 0958` and `+1 207 946
# 0958` both kept; the leading zeros off a prefix are a trunk,
# which names no country. A value with no digits at all is no
# number, and compares as text.
#: (String ours, String theirs) -> bool
def same_number?(ours, theirs)
digits = [ours, theirs].map { it.delete("^0-9") }
return ours.strip.casecmp?(theirs.strip) if digits.any?(&:empty?)
return false unless Match.phone(ours) == Match.phone(theirs)
prefixes = digits.map { it[0...-Match::PHONE_DIGITS].to_s.sub(/\A0+/, "") }
prefixes.any?(&:empty?) || prefixes.uniq.length == 1
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