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
module ProTacts
# The shape of the one NOTE a served card carries: the member's own
# text first, then a section per lending group — a blank line, the
# header `Shared · <label>:`, one newline, the body. Why a group's
# note rides inside the text value and nowhere else, and why the
# header is words, is docs/plans/2026-09-25-one-note-per-contact.md.
#
# Pure text algebra, the two halves of one round trip: #join is what
# composition writes, #split what a PUT is read with. Neither knows
# what a group is — attribution is EditedContact's, which knows what
# the contact's groups lend.
module NoteSections
# The fixed half of every header.
HEADER = "Shared · ".freeze #: String
# One labeled part of a combined value: the lending group's label
# and the text its row holds, shorn of its NOTE line. A Data class,
# whose members the inline parser cannot read (see
# sig/pro_tacts/contact.rbs).
# @rbs skip
Section = Data.define(:label, :text)
# What #split read out of a value: the member's own text ("" for
# none) and the sections in the order they stand. The same shape
# for a value with nothing lent, whose labels were empty and whose
# text is all member.
# @rbs skip
Split = Data.define(:member, :sections)
# The value to serve: member text and sections in the order given,
# blank line between parts. A part with no text is its header
# alone. Which sections and in what order is the caller's —
# inheritance order, the order the group's rows lend in.
#: (String member, Array[Section] sections) -> String
def self.join(member, sections)
parts = [] #: Array[String]
parts << member unless member.empty?
sections.each do |section|
parts << (section.text.empty? ? header(section.label) : "#{header(section.label)}\n#{section.text}")
end
parts.join("\n\n")
end
# The value read back: everything before the first header is the
# member's own text, each header opens the section of its label, and
# a section runs to the next header or the end. A header is a line
# exactly equal to `Shared · <label>:` with trailing whitespace
# gone, for one of the labels given — a label nothing lends is a
# header nowhere. Duplicate labels are the caller's to refuse before
# this: one header cannot say which of two it names. Leading and
# trailing blank lines come off both halves — they are the
# separators the composer writes and whatever a person left behind —
# and blank lines inside a body stay, a multiline note keeping its
# own paragraphs. A member text that was all blanks reads as "",
# which is the caller's no-note.
#: (String value, Array[String] labels) -> Split
def self.split(value, labels)
headers = labels.to_h {
[header(it), it] #: [String, String]
}
member = [] #: Array[String]
found = [] #: Array[Array[String]]
value.split("\n", -1).each do |line|
label = headers[line.rstrip]
if label.nil?
if found.empty?
member << line
else
found.fetch(-1) << line
end
else
found << [label]
end
end
Split.new(
member: trimmed(member),
sections: found.map { |lines| Section.new(label: lines.fetch(0), text: trimmed(lines.drop(1))) },
)
end
#: (String label) -> String
def self.header(label)
"#{HEADER}#{label}:"
end
# The lines with their leading and trailing blank lines off —
# everything a body is, and none of the space around it.
#: (Array[String] lines) -> String
def self.trimmed(lines)
from = lines.index { !it.strip.empty? } || lines.length
body = lines[from..] || [] #: Array[String]
upto = body.rindex { !it.strip.empty? } || -1
body[0..upto].to_a.join("\n")
end
end
end