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
# The one-note invariant lands: a contact carries at most one NOTE,
# stored and served, and what a group lends composes into it as a
# section rather than a second line — because macOS keeps only the
# last of two NOTE lines and a PUT stores the truncation
# (docs/plans/2026-09-25-one-note-per-contact.md, which this migration
# makes true of the data the day it lands).
#
# What is already stored with several joins into one: the texts with a
# blank line between, which is the shape the write path joins foreign
# input into and the shape the sections read beside. No change-log
# entry is written, 002's own bargain: the stamps are left to say the
# same, and a client that does not refetch keeps its card until the
# next change — a PUT of which #subtract_notes reads as the
# truncation it is rather than a mangle.
#
# Spelled out against VCard directly rather than through a Store
# helper, so what this migration does cannot drift with the app's
# internals; its test pins the behavior.
require "pro_tacts/vcard"
Sequel.migration do
change do
self[:cards].order(:id).each do |row|
card = ProTacts::VCard.new(row.fetch(:vcard))
notes = card.lines.select { it.names?("NOTE") }
next unless notes.length > 1
texts = notes.map { it.property&.text || it.verbatim.chomp }
joined = card.replace("NOTE", ["NOTE:#{ProTacts::VCard.escape(texts.join("\n\n"))}\r\n"])
self[:cards].where(id: row.fetch(:id)).update(vcard: joined.to_s)
end
self[:group_properties].select(:group_id).distinct.each do |row|
gid = row.fetch(:group_id)
rows = self[:group_properties].where(group_id: gid).order(:position).all
notes = rows.select { it.fetch(:line)[/\A[^;:]*/].to_s.casecmp?("NOTE") == true }
next unless notes.length > 1
# The joined line keeps the earliest position — the position the
# composition lent in, and the one a propagated edit writes back
# to — and the rest of the rows go.
texts = notes.map {
ProTacts::VCard.new(it.fetch(:line)).lines.fetch(0).property&.text || it.fetch(:line)
}
keep = notes.fetch(0).fetch(:position)
self[:group_properties]
.where(group_id: gid, position: notes.drop(1).map { it.fetch(:position) }).delete
self[:group_properties].where(group_id: gid, position: keep)
.update(line: "NOTE:#{ProTacts::VCard.escape(texts.join("\n\n"))}")
end
end
end