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
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
require "pro_tacts/birthday"
require "pro_tacts/contact"
require "pro_tacts/vcard"
require "pro_tacts/vcard/parser"

module ProTacts
  module Admin
    # The admin editors' forms read back into cards: the fields
    # ContactsEdit and GroupsEdit render, spliced into the card each was
    # rendered from, and the create dialog's two names made into a new
    # one. The surgical save — the hazard it exists to remove, the
    # blank-equals-absent rule, and why REV is left alone — is
    # docs/plans/2026-09-05-web-card-editor.md.
    #
    # Not a view, unlike its neighbours, so the Steepfile checks it.
    module CardForm
      # The repeatable rows by the form's name for each kind: the field
      # a standing row posts under and, prefixed `new_`, the one an
      # added row does, each reading back into the row class that
      # writes it (#edited_rows). Public for the route's shape checks
      # (Web#card_form_params) and the editor's rows.
      ROWS = {
        "phone" => Contact::Phone,
        "email" => Contact::Email,
        "handle" => Contact::Handle,
        "address" => Contact::Address,
      }.freeze #: Hash[String, Contact::row_class]

      # A created contact's card: the envelope RFC 2426 section 4
      # requires — BEGIN, VERSION, and the N and FN that section makes
      # mandatory — plus the UID this server's id model is (see
      # Contact). Nothing else: a created card has no prior octets to
      # preserve, and every other property arrives by editing the
      # record later. The names escape, because a name is text — a
      # comma or semicolon in one must not read as structure (RFC 2426
      # section 2.4.2).
      #: (String id, String first, String middle, String last) -> VCard
      def self.new_card(id, first, middle, last)
        VCard.new(
          "BEGIN:VCARD\r\nVERSION:3.0\r\n" \
            "N:#{VCard.escape(last)};#{VCard.escape(first)};#{VCard.escape(middle)};;\r\n" \
            "FN:#{VCard.escape(display_name(first, middle, last))}\r\n" \
            "UID:#{id}\r\n" \
            "END:VCARD\r\n",
        )
      end

      # Whether the save is refused for want of a name, each kind
      # asking its own: a company its organization field, a person
      # either half of the pair (Admin::NamePair). The server's half
      # of the browser's `required`. `company` is the editor's Company
      # box, which posts "1" checked and the hidden "0" before it
      # otherwise, so every save names the kind it writes.
      #: (bool company, Hash[String, untyped] params) -> bool
      def self.nameless?(company, params)
        if company
          params["organization"].to_s.strip.empty?
        else
          params["first"].to_s.strip.empty? && params["last"].to_s.strip.empty?
        end
      end

      # The contact editor's save, spliced into the stored card. Where
      # each kind keeps its name, and the switch between the two, is
      # docs/plans/2026-10-07-switching-company-and-person.md.
      #: (Contact contact, bool company, String first, String middle, String last, Hash[String, untyped] params) -> VCard
      def self.contact_card(contact, company, first, middle, last, params)
        # The rows are read off the contact's own card, the one the form
        # rendered (Admin::ContactsEdit) and the one this save splices:
        # a line a group lends is on neither, so no digest here can name
        # one and no save can copy a group's value onto its member
        # (Contact#own).
        own = contact.own

        vcard = contact.stored
        vcard =
          if company
            company_name(vcard, params["organization"].to_s.strip)
          else
            person_name(contact, vcard, first, middle, last, params["organization"]&.strip)
          end
        vcard = edited_text(vcard, "NICKNAME", params["nickname"], own.nickname)
        vcard = edited_text(vcard, "NOTE", params["note"], note_of(own))
        vcard = edited_rows(own.phones, "phone", vcard, params)
        vcard = edited_rows(own.emails, "email", vcard, params)
        vcard = edited_rows(own.handles, "handle", vcard, params)
        edited_rows(own.addresses, "address", vcard, params)
      end

      # The group editor's save: the note replaced and the contact
      # editor's own address splice over the group's lines read as a
      # card (Group#reading), then back into the lines a group
      # holds — each unfolded and shorn of its terminator, the unit
      # CardDiff records a write in and group_properties stores.
      #: (Group group, Hash[String, untyped] params) -> Array[String]
      def self.group_lines(group, params)
        reading = group.reading
        card = edited_text(reading.stored, "NOTE", params["note"], note_of(reading))
        card = edited_rows(reading.addresses, "address", card, params)
        card.lines.filter_map {
          line = it.content
          line unless line.empty?
        }
      end

      # The birthday row's parse: each blank component is an absence
      # and three blanks are no birthday, so Birthday's constructor is
      # the whole grammar — six shapes, ranges standing where the
      # components stand — and the digit check before it keeps to_i
      # honest (a "19x5" is not the year 19). All three blank returns
      # before the constructor, the grammar refusing nothing-at-all;
      # raises ArgumentError otherwise, the constructor's own refusal
      # class, for the save to catch and re-render — nothing here
      # reaches Sentry, bad input being ordinary and the toast the
      # fallback.
      #
      # A full date in 1604 is a month and day, the sentinel read here as
      # every client's line reads it (Birthday::NO_YEAR): the model would
      # otherwise hold a year that serves as a bare 1604 date and reads
      # back without one at the next sync
      # (docs/plans/2026-09-11-every-birthday-in-the-model.md, "1604 means
      # no year").
      #: (untyped fields) -> Birthday?
      def self.birthday(fields)
        year = birthday_component(fields["year"])
        month = birthday_component(fields["month"])
        day = birthday_component(fields["day"])
        return if year.nil? && month.nil? && day.nil?

        year = nil if year == Birthday::NO_YEAR && month && day

        Birthday.new(year:, month:, day:)
      end

      # The note the editor renders: every NOTE the member's own card
      # carries, joined the way composition joins them
      # (Contact#composed_note), so a card from before the one-note
      # invariant shows its second note rather than losing it to a save
      # that wrote only the first (docs/plans/2026-09-25-one-note-per-
      # contact.md). Nil for none, the row's blank-removes cue.
      #: (Contact contact) -> String?
      def self.note_of(contact)
        notes = contact.notes.map(&:value)
        notes.join("\n\n") unless notes.empty?
      end

      # A text property's half of the surgical save: the lines stay as
      # they are where the field submits what the form rendered from
      # them, and are replaced by #text_lines otherwise. Compared as
      # the field comes back — stripped, a textarea's line breaks CRLF
      # (the HTML form-submission newline normalization) — so a save
      # that left the field alone cannot re-escape, unfold, or strip
      # the parameters off a line it never changed.
      #: (VCard vcard, String name, untyped submitted, String? rendered) -> VCard
      def self.edited_text(vcard, name, submitted, rendered)
        value = submitted.to_s.gsub(/\r\n?/, "\n").strip
        return vcard if value == rendered.to_s.strip

        vcard.replace(name, text_lines(name, value))
      end

      # The repeatable rows' half of the surgical save, walked once per
      # kind: each row names its line by digest, so a row the request
      # leaves out is a line the save never mentions, and a row whose
      # label and fields all submit what it read is skipped — the line
      # keeps its own bytes by construction, not by careful
      # re-rendering. Any other is the row's to rewrite (Contact::Phone#edit
      # and kin), which is where the kinds differ: what a blank removes
      # and where a label is written
      # (docs/plans/2026-10-22-a-row-edits-its-label.md). A row posting
      # no label leaves its label be, as the group editor's addresses
      # do. The rows come from the contact, never the request, so a
      # doctored digest names nothing and a missing one touches nothing.
      #
      # `field` is the form's name for the kind. The rows the add
      # dialog reveals (Admin::ContactsEdit) are named by their add
      # index (`new_phone[i][value]`) rather than a bare [], because
      # Rack refuses a key after an empty one — so they arrive as a
      # hash of hashes and read out by their values, landing as bare
      # lines before END:VCARD. A blank one inserts nothing.
      #: (Array[Contact::Phone | Contact::Email | Contact::Handle | Contact::Address] rows, String field, VCard vcard, Hash[String, untyped] params) -> VCard
      def self.edited_rows(rows, field, vcard, params)
        kind = ROWS.fetch(field)
        edits = hash_field(params, field)
        rows.each do |row|
          submitted = edits[row.line.digest]
          next if submitted.nil?

          label = submitted["label"]&.strip
          fields = submitted_fields(kind, submitted)
          next if (label.nil? || label == row.label.to_s.strip) && fields.all? { |name, value| value == row.fields.fetch(name).strip }

          vcard = row.edit(vcard, label, fields)
        end

        added = hash_field(params, "new_#{field}").values #: Array[Hash[String, untyped]]
        added.each do |row|
          vcard = kind.add(vcard, row["label"].to_s.strip, submitted_fields(kind, row))
        end
        vcard
      end

      #: (Contact::row_class kind, Hash[String, untyped] submitted) -> Hash[String, String]
      def self.submitted_fields(kind, submitted)
        kind.field_names.to_h {
          field = [it, submitted[it].to_s.strip] #: [String, String]
          field
        }
      end

      # A field the editors post as a hash keyed by row: none where the
      # form rendered no such rows, and a raise for any other shape.
      # Web#card_form_params answers that shape as a 400 before a save
      # gets here, so the raise is the assertion that it did.
      #: (Hash[String, untyped] params, String key) -> Hash[String, untyped]
      def self.hash_field(params, key)
        none = {} #: Hash[String, untyped]
        value = params.fetch(key, none)
        raise ArgumentError, "the form's #{key} field is not a hash" unless value.is_a?(Hash)

        value
      end

      # One component off the wire: nil for blank, the integer it names
      # otherwise. Digits only — a month select and ranged number inputs
      # are the browser's own constraint, and this is the server's.
      #: (untyped submitted) -> Integer?
      def self.birthday_component(submitted)
        value = submitted.to_s.strip
        return nil if value.empty?
        raise ArgumentError, "not a number a birthday holds: #{value.inspect}" unless value.match?(/\A\d{1,4}\z/)

        value.to_i
      end

      # A text property's replacement lines: the escaped value (RFC 2426
      # section 2.4.2 — the value is text, the line is structure), or none
      # at all — blank equals absent, the whole-property rule.
      #: (String name, String value) -> Array[String]
      def self.text_lines(name, value)
        return [] if value.empty?

        ["#{name}:#{VCard.escape(value)}"]
      end

      # A company's name, written where macOS writes one (exchange
      # ec5ce1d8dd03 in log/dev.log, 2026-10-07): ORG's first component
      # and FN both, under X-ABShowAs:COMPANY, with N empty. A company
      # has no person's name, and one left in N would be a name no
      # screen here shows (the editor hides the pair for a company), so
      # a person made a company leaves theirs behind, prefixes and
      # suffixes too, and a switch back is typed afresh, as Contacts.app
      # asked for it. Each line is rewritten only where it says
      # something else, so an untouched save of the card macOS wrote
      # keeps its bytes.
      #: (VCard vcard, String organization) -> VCard
      def self.company_name(vcard, organization)
        vcard = vcard.replace("N", ["N:;;;;"]) unless vcard.property("N")&.components&.all?(&:empty?)
        vcard = vcard.replace("FN", ["FN:#{VCard.escape(organization)}"]) unless vcard.property("FN")&.text == organization
        vcard = edited_organization(vcard, organization)
        return vcard if vcard.property("X-ABShowAs")&.text == "COMPANY"

        vcard.replace("X-ABShowAs", ["X-ABShowAs:COMPANY"])
      end

      # A person's name, into N and FN, and the flag gone: a person is
      # a card without X-ABShowAs, which is how macOS unticks Company
      # (exchange b5ce7eaebe0e). ORG is the person's employer, written
      # from the same field as a company's name; a POST without that
      # field leaves it be, the birthday group's posture.
      #: (Contact contact, VCard vcard, String first, String middle, String last, String? organization) -> VCard
      def self.person_name(contact, vcard, first, middle, last, organization)
        line = n_line(vcard, first, middle, last)
        vcard = vcard.replace("N", [line]) if line
        vcard = edited_fn(contact, vcard, first, middle, last)
        vcard = edited_organization(vcard, organization) if organization
        vcard.replace("X-ABShowAs", [])
      end

      # An organization's own name: ORG's first component (RFC 2426
      # section 3.5.5), the units after it being no part of it. Public
      # for the editor's prefill (Admin::ContactsEdit), which reads the
      # name this save writes.
      #: (VCard vcard) -> String?
      def self.organization_of(vcard)
        vcard.property("ORG")&.components&.first
      end

      # ORG with the organization field's name in it, or the card
      # untouched where the name already is that. The name is the first
      # component, the units after it rejoined byte for byte — n_line's
      # splice, for n_line's reason. Blank removes the property, the
      # whole-property rule, units and all: they are no row of their own
      # here, and a department of no organization is not one to keep.
      #: (VCard vcard, String organization) -> VCard
      def self.edited_organization(vcard, organization)
        return vcard if organization_of(vcard).to_s == organization
        return vcard.replace("ORG", []) if organization.empty?

        property = vcard.property("ORG")
        units = [] #: Array[String]
        units = VCard.split_raw_components(property.value).drop(1) if property
        vcard.replace("ORG", ["ORG:#{[VCard.escape(organization), *units].join(";")}"])
      end

      # FN's replacement, or the card untouched: the display name is the
      # card's own text (RFC 2426 section 3.1.1), not a rendering of N,
      # and "Dr. Ada B. Lovelace, Jr." holds parts no field on this form
      # does — the same parts n_line rejoins byte for byte rather than
      # dropping. Rebuilding it under a save that only moved a phone
      # number would lose them, so FN is rewritten only when a name
      # field moved under it, which is the one time the old display
      # name is stale. A card carrying no FN is the other case: the
      # property is mandatory (section 4), so a save fills it in rather
      # than leaving the absence it found.
      #: (Contact contact, VCard vcard, String first, String middle, String last) -> VCard
      def self.edited_fn(contact, vcard, first, middle, last)
        family, given, additional = contact.name_components || []
        named = !vcard.property("FN").nil?
        # A company's FN is its organization's name (#company_name), so
        # a card leaving that kind takes its display name from the pair.
        unchanged = first == given.to_s.strip && middle == additional.to_s.strip && last == family.to_s.strip
        return vcard if named && !contact.company? && unchanged

        vcard.replace("FN", ["FN:#{VCard.escape(display_name(first, middle, last))}"])
      end

      # FN's text, from the boxes that make it: the western order the
      # fixture sessions show macOS writing, each blank box left out
      # rather than spacing the line.
      #: (String first, String middle, String last) -> String
      def self.display_name(first, middle, last)
        [first, middle, last].reject(&:empty?).join(" ")
      end

      # N's replacement line. The first three components are the form's
      # — family, given, additional (RFC 2426 section 3.1.2) — and the
      # prefixes and suffixes after them rejoin byte for byte, which is
      # what the splice over the still-escaped value buys
      # (docs/plans/2026-09-05-web-card-editor.md). A component that
      # submits its current reading keeps its own bytes too, the rule
      # Contact.address_value already writes under: unescape-then-re-escape is
      # not byte-stable (VCard.split_raw_components), so a save that
      # moved a phone number must not rewrite the name it left alone. A
      # card with no N a form can read splices into a bare
      # five-component value; one short of five is padded, the same
      # empties a whole-N writer would leave. Nil where every name box
      # submits what it rendered, so the line keeps what no box models
      # — its parameters, its group, its folds — and is not rewritten.
      # A box's value arrives stripped, and is compared with the reading
      # stripped likewise.
      #: (VCard vcard, String first, String middle, String last) -> String?
      def self.n_line(vcard, first, middle, last)
        property = vcard.property("N")
        components = [] #: Array[String]
        readings = [] #: Array[String]
        if property
          components.replace(VCard.split_raw_components(property.value))
          readings.replace(property.components)
        end
        currents = Array.new(3) { readings[it].to_s.strip }
        return if property && [last, first, middle] == currents

        components << "" while components.length < 5
        [last, first, middle].each_with_index do |value, position|
          components[position] = value == currents.fetch(position) ? components.fetch(position) : VCard.escape(value)
        end
        "N:#{components.join(";")}"
      end

      private_class_method :edited_rows, :submitted_fields, :hash_field, :birthday_component, :text_lines, :edited_text, :edited_fn, :display_name,
                           :n_line, :company_name, :person_name, :edited_organization
    end
  end
end