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
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
require "time"

require "pro_tacts/birthday"
require "pro_tacts/vcard/parser"

module ProTacts
  module Admin
    # Small display helpers shared by the admin views. Presentation, not
    # structure: these read a Contact but decide only how it looks.
    module Format
      # The name a listing row shows: "Name (Nickname)" when the card
      # carries a NICKNAME — the formal name, the one someone is
      # known by kept beside it — and the plain name otherwise. The
      # composition is the listing screens', not Contact's: the card
      # holds the two facts independently, and other screens (the
      # detail card, the birthdays column) show the plain name.
      #: (Contact | ListedContact contact) -> String
      def self.name_label(contact)
        nickname = contact.nickname
        name = contact.name
        return "#{name} (#{nickname})" if nickname && name

        nickname || name || contact.id
      end

      # The string a search compares on: the query and the value it
      # is matched against both lowercased and accents folded to the
      # letters they sit on, so "munoz" finds "Muñoz". NFD plus
      # \p{Mn} carries an accent off as a combining mark and leaves
      # every other script findable as itself — deliberately not
      # ASCII-only, which would strip a name no ASCII letter survives
      # down to nothing a query could reach. A letter that is whole
      # rather than base-plus-accent (ß, æ) stays: folding it is
      # transliteration, not this.
      #: (String value) -> String
      def self.search_key(value)
        value.downcase.unicode_normalize(:nfd).gsub(/\p{Mn}+/, "")
      end

      # A contact's initials. N's given and family names (RFC 2426
      # section 3.1.2) are structured data and the real answer to "what
      # are this contact's initials" — falling back, when the card
      # carries no N (an organization's own entry, say, with a name but
      # no person to split in two), to one first letter of FN rather
      # than a guess at where a first/last split would fall in free
      # text, and of the id when the card carries no name at all.
      #: (Contact | ListedContact contact) -> String
      def self.initials(contact)
        family, given = contact.name_components || []
        letters = [given, family].filter_map { it[0] if it }
        return letters.join.upcase unless letters.empty?

        (contact.name || contact.id).to_s.strip[0].to_s.upcase
      end

      # A contact's birthday, for display: the model's, including the
      # shapes no client is sent, then a BDAY still in the card — read the
      # way a write reads one, else shown raw. The prose is Birthday#to_s.
      #: (Contact | ListedContact contact) -> String?
      def self.birthday(contact)
        birthday = contact.birthday
        return birthday.to_s if birthday

        property = contact.properties.find { it.name.casecmp?("BDAY") }
        return nil if property.nil?

        Birthday.from_property(property)&.to_s || property.value
      end

      # ADR's components as display lines — street, then everything
      # after it — the shape the design's own address rows settled on,
      # and one shape for a contact's card and a group's alike.
      # Presentation: the components themselves are Contact's.
      #: (Contact::Address address) -> Array[String]
      def self.address_lines(address)
        [
          [address.street, address.extended].compact.join(" "),
          [address.locality, address.region, address.postal_code].compact.join(", "),
          address.country,
        ].compact.reject { it.empty? }
      end

      # A value — a property's or a parameter's — at or under this many
      # octets renders whole. Anything longer is a wall of folded or
      # unbroken octets (an inline PHOTO payload, a memoji's binary
      # plist parameter, a novel-length NOTE) and renders as its count.
      # The count says exactly what is there; what it stands in for is
      # not readable text.
      ELIDE_ABOVE = 120 #: Integer

      # A change log's line, elided as a card's is (#elide_line): text
      # in rather than a parsed line. Both change logs render these — a
      # contact's and a group's — which is why the machinery lives here
      # rather than in either view.
      #: (String text) -> String
      def self.elide_text(text)
        line = VCard::Parser.lines(text).first
        line ? elide_line(line).chomp : text
      end

      # One line of the display card: verbatim when nothing in it runs
      # past ELIDE_ABOVE; rebuilt from its parse when something does,
      # each long value standing in as its count. The rebuild is the
      # writer's spelling rather than the bytes' (VCard.header_of, which
      # quotes a parameter value PTEXT cannot hold) — an elided line is
      # a rendering — and the line keeps its own terminator so the
      # card's line-break convention survives.
      #: (VCard::Parser::Line line) -> String
      def self.elide_line(line)
        property = line.property
        return elide_unparsed(line.verbatim) if property.nil?

        long = property.parameters.any? { |_, value| value.bytesize > ELIDE_ABOVE } ||
          property.value.bytesize > ELIDE_ABOVE
        return line.verbatim unless long

        elided = property.with(parameters: property.parameters.map { |name, value| [name, count_octets(value)] })
        terminator = line.verbatim[/\r?\n\z/] || "\r\n"
        "#{VCard.header_of(elided)}#{count_octets(property.value)}#{terminator}"
      end

      # A line that would not parse has no structure to rebuild from,
      # so its value is elided by surgery on the bytes: everything up
      # to the first colon stands, and the rest is the count.
      #: (String verbatim) -> String
      def self.elide_unparsed(verbatim)
        prefix, value = verbatim.split(":", 2)
        return verbatim if value.nil?

        terminator = value.slice!(/\r?\n\z/) || ""
        "#{prefix}:#{count_octets(value)}#{terminator}"
      end
      private_class_method :elide_unparsed

      #: (String value) -> String
      def self.count_octets(value)
        value.bytesize > ELIDE_ABOVE ? "[#{value.bytesize} octets elided]" : value
      end
      private_class_method :count_octets

      # How much of an etag's hash is shown before it is cut. Twelve
      # hex digits is git's own longest short-hash, and this address
      # book will never hold enough cards for two to collide in a
      # reader's eye.
      ETAG_DIGITS = 12 #: Integer

      # An etag's hash, without the quotes an entity-tag is spelled
      # with (Contact.etag_for): those are the wire's punctuation, and
      # a screen showing a card's history is not the wire. Anything
      # not shaped like an entity-tag comes back untouched — there is
      # no second spelling to guess at.
      #: (String etag) -> String
      def self.etag_digest(etag)
        etag[/\A"(.*)"\z/, 1] || etag
      end

      # The hash cut down for display. Whole, it is 64 digits, which
      # wraps over two lines in a record's value column and reads as a
      # wall either way; the view hands the whole of it to the title
      # of the <abbr> this sits in, so the cut is a display and not a
      # loss.
      #: (String etag) -> String
      def self.short_etag(etag)
        digest = etag_digest(etag)
        return digest if digest.length <= ETAG_DIGITS

        "#{digest[0, ETAG_DIGITS]}…"
      end

      # A stamp like #time_ago's, rendered as itself minus the
      # milliseconds. What the change log records happened at a
      # moment someone may need to line up against a client's own logs,
      # so this one stays absolute where #time_ago goes coarse — and the
      # fraction is the clock's precision rather than the change's.
      # String surgery rather than a parse and a reformat, because the
      # column's spelling is fixed by the expression that writes it (see
      # db/migrations/001_create_contacts_schema.rb).
      #: (String iso8601) -> String
      def self.stamp(iso8601)
        iso8601.sub(/\.\d+Z\z/, "Z")
      end

      # A card's updated_at, UTC ISO 8601 to the millisecond (see
      # Store), rendered the way "recently updated" wants it: coarse and
      # relative, not a timestamp to read precisely.
      #: (String iso8601) -> String
      def self.time_ago(iso8601)
        seconds = (Time.now.utc - Time.iso8601(iso8601)).round
        return "just now" if seconds < 60

        minutes = seconds / 60
        return "#{minutes}m ago" if minutes < 60

        hours = (minutes / 60.0).round
        return "#{hours}h ago" if hours < 24

        days = (hours / 24.0).round
        return "#{days}d ago" if days < 30

        "#{(days / 30.0).round}mo ago"
      end

      # time_ago's other half: how far ahead a known day is, rendered
      # with the same coarseness. A birthday list spans weeks, so days
      # give way to weeks after a fortnight and to months after a
      # quarter.
      #: (Date date) -> String
      def self.time_until(date)
        days = (date - Date.today).to_i
        return "today" if days.zero?
        return "tomorrow" if days == 1
        return "in #{days}d" if days < 14
        return "in #{(days / 7.0).round}w" if days < 90

        "in #{(days / 30.0).round}mo"
      end
    end
  end
end