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