admin: read the edit forms back in Admin::CardForm
The splice that reads a form back into its card leaves web.rb to sit beside the views;
the Steepfile now names each view it skips, so this file stays checked, which a bare directory would refuse.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/Steepfile b/Steepfile
index 8c4ad9e..f062c0e 100644
--- a/Steepfile
+++ b/Steepfile
@@ -8,17 +8,22 @@
# (see sig/pro_tacts/). Both are documented where they are defined.
target :lib do
check "lib", inline: true
- # Phlex ships no RBS of its own, and lib/pro_tacts/admin is built on
- # it — unlike Sequel, Roda, Nokogiri, and friends, which have hand
- # written stand-ins under sig/gems. Ignored rather than stubbed for
- # now; add sig/gems/phlex.rbs and drop this once the admin surface is
- # worth typing.
+ # Phlex ships no RBS of its own, and the admin views are built on it —
+ # unlike Sequel, Roda, Nokogiri, and friends, which have hand written
+ # stand-ins under sig/gems. Ignored rather than stubbed for now; add
+ # sig/gems/phlex.rbs and drop this once the admin surface is worth
+ # typing. Named file by file so that what lives beside the views
+ # without being one — card_form.rb, the forms read back into cards —
+ # stays checked; a new file there is checked until it is listed.
#
# inline: true is load-bearing: `ignore` keeps separate lists for
# plain and inline sources, and these files were enrolled by
# `check ... inline: true`. A bare `ignore` feeds the other list and
# the files stay checked.
- ignore "lib/pro_tacts/admin", inline: true
+ ignore(*%w[
+ avatar contact_dialog contacts_edit contacts_index contacts_show device_setup format
+ group_label groups_edit groups_index groups_show icons layout phlex upcoming_birthdays
+ ].map { "lib/pro_tacts/admin/#{it}.rb" }, inline: true)
signature "sig"
library "date", "digest", "fileutils", "json", "logger", "pathname", "securerandom", "strscan", "yaml"
diff --git a/lib/pro_tacts/admin/card_form.rb b/lib/pro_tacts/admin/card_form.rb
new file mode 100644
index 0000000..1568afb
--- /dev/null
+++ b/lib/pro_tacts/admin/card_form.rb
@@ -0,0 +1,319 @@
+require "pro_tacts/birthday"
+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
+ # ADR's editable components: field name to position in the value
+ # (RFC 2426 section 3.2.1, minus the leading po box at position 0
+ # — no screen shows one, and the save preserves its bytes rather
+ # than letting a form field near them). In the value's own order,
+ # which is the order a rebuilt line's components join in.
+ ADDRESS_COMPONENTS = {
+ "extended" => 1,
+ "street" => 2,
+ "locality" => 3,
+ "region" => 4,
+ "postal_code" => 5,
+ "country" => 6,
+ }.freeze #: Hash[String, Integer]
+ private_constant :ADDRESS_COMPONENTS
+
+ # 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 last) -> VCard
+ def self.new_card(id, first, last)
+ VCard.new(
+ "BEGIN:VCARD\r\nVERSION:3.0\r\n" \
+ "N:#{VCard.escape(last)};#{VCard.escape(first)};;;\r\n" \
+ "FN:#{VCard.escape([first, last].reject(&:empty?).join(" "))}\r\n" \
+ "UID:#{id}\r\n" \
+ "END:VCARD\r\n",
+ )
+ end
+
+ # The contact editor's save, spliced into the stored card.
+ #: (Contact contact, String first, String last, Hash[String, untyped] params) -> VCard
+ def self.contact_card(contact, first, last, params)
+ nickname = params["nickname"].to_s.strip
+ note = params["note"].to_s.strip
+
+ # 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 = vcard.replace("N", [n_line(vcard, first, last)])
+ vcard = edited_fn(contact, vcard, first, last)
+ vcard = vcard.replace("NICKNAME", text_lines("NICKNAME", nickname))
+ vcard = vcard.replace("NOTE", text_lines("NOTE", note))
+ vcard = edited_phones(own, vcard, params)
+ vcard = edited_emails(own, vcard, params)
+ edited_addresses(own, 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 (Store::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.
+ #: (Store::Group group, Hash[String, untyped] params) -> Array[String]
+ def self.group_lines(group, params)
+ reading = group.reading
+ card = reading.stored.replace("NOTE", text_lines("NOTE", params["note"].to_s.strip))
+ card = edited_addresses(reading, card, params)
+ card.lines.filter_map {
+ line = VCard::Parser.unfold(it.verbatim).sub(/(\r\n|[\r\n])\z/, "")
+ 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.
+ #: (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?
+
+ Birthday.new(year:, month:, day:)
+ end
+
+ # The phones' half of the surgical save: each row names its line
+ # by digest, so a row the request leaves out is a line the save
+ # never mentions, and an unchanged row is skipped — the line keeps
+ # its own bytes by construction, not by careful re-rendering. A
+ # blank row removes its line; a changed row swaps the value under
+ # the line's own header (VCard.header_of), where the TYPE
+ # parameters no field models ride. The addresses come from the
+ # contact, never the request, so a doctored digest names nothing
+ # and a missing one touches nothing.
+ #: (Contact contact, VCard vcard, Hash[String, untyped] params) -> VCard
+ def self.edited_phones(contact, vcard, params)
+ rows = params["phone"]
+ if rows.is_a?(Hash)
+ contact.phones.each do |phone|
+ # The property is nil only for a line that would not read,
+ # and phones read from lines that did; the guard is the
+ # type's honesty, not a reachable case (n_line's own shape).
+ property = phone.line.property
+ next if property.nil?
+
+ submitted = rows[phone.line.digest]
+ next if submitted.nil? || submitted.to_s.strip == phone.value
+
+ value = submitted.to_s.strip
+ vcard = vcard.substitute(
+ phone.line.digest,
+ value.empty? ? [] : ["#{VCard.header_of(property)}#{VCard.escape(value)}"]
+ )
+ end
+ end
+
+ # The rows the add dialog reveals (Admin::ContactsEdit): each
+ # value lands as a bare TEL before END:VCARD, and a blank one
+ # inserts nothing — inserting absence is a no-op, the plan's
+ # rule for new rows, and the reason the rows nobody typed in
+ # cost nothing whether they were on screen or not. Array()
+ # because the field is a list and a request carrying one value
+ # is still a list of one.
+ submitted = Array(params["new_phone"]) #: Array[untyped]
+ added = submitted.filter_map {
+ value = it.to_s.strip
+ "TEL:#{VCard.escape(value)}\r\n" unless value.empty?
+ } #: Array[String]
+ vcard.insert(added)
+ end
+
+ # #edited_phones' walk, over EMAIL.
+ #: (Contact contact, VCard vcard, Hash[String, untyped] params) -> VCard
+ def self.edited_emails(contact, vcard, params)
+ rows = params["email"]
+ if rows.is_a?(Hash)
+ contact.emails.each do |email|
+ property = email.line.property
+ next if property.nil?
+
+ submitted = rows[email.line.digest]
+ next if submitted.nil? || submitted.to_s.strip == email.value
+
+ value = submitted.to_s.strip
+ vcard = vcard.substitute(
+ email.line.digest,
+ value.empty? ? [] : ["#{VCard.header_of(property)}#{VCard.escape(value)}"],
+ )
+ end
+ end
+
+ submitted = Array(params["new_email"]) #: Array[untyped]
+ added = submitted.filter_map {
+ value = it.to_s.strip
+ "EMAIL:#{VCard.escape(value)}\r\n" unless value.empty?
+ } #: Array[String]
+ vcard.insert(added)
+ end
+
+ # #edited_phones' walk, over a row that is six fields rather than
+ # one (Admin::ContactsEdit). Removal is the reader's own rule
+ # (Contact#address_of): a row blank throughout, po box included,
+ # removes the line, where a partially blanked one keeps it —
+ # partial blanks are legal empty components.
+ #: (Contact contact, VCard vcard, Hash[String, untyped] params) -> VCard
+ def self.edited_addresses(contact, vcard, params)
+ rows = params["address"]
+ if rows.is_a?(Hash)
+ contact.addresses.each do |address|
+ property = address.line.property
+ next if property.nil?
+
+ submitted = rows[address.line.digest]
+ next if !submitted.is_a?(Hash) || address_unchanged?(address, submitted)
+
+ line = address_line(address, submitted)
+ vcard = vcard.substitute(address.line.digest, line ? [line] : [])
+ end
+ end
+
+ # The add dialog names an added address by its add index
+ # (`new_address[i][street]`) rather than the bare [] every
+ # single-valued kind appends to, because Rack refuses a key
+ # after an empty one — so the rows arrive as a hash of hashes
+ # and read out by their values, several adds in one pass
+ # included.
+ submitted = params["new_address"]
+ added = submitted.is_a?(Hash) ? submitted.values : Array(submitted) #: Array[untyped]
+ vcard.insert(added.filter_map { address_line(nil, it) })
+ end
+
+ #: (Contact::Address address, untyped submitted) -> bool
+ def self.address_unchanged?(address, submitted)
+ ADDRESS_COMPONENTS.keys.all? { |name|
+ submitted[name].to_s.strip == address.public_send(name).to_s
+ }
+ end
+
+ # One address row's replacement line: the raw components spliced
+ # and re-joined under the line's own header, or nil when the result
+ # is blank throughout (blank is absent, the reader's own rule for
+ # an ADR — the caller removes the line). A component that submits
+ # its current reading keeps its own bytes — the po box always,
+ # having no field, and any other the form left alone — where a
+ # moved one re-escapes from the form; the splice over
+ # still-escaped components is n_line's own rule,
+ # unescape-then-re-escape not being byte-stable. A nil address is
+ # an added row: seven fresh positions, po box empty, a bare ADR
+ # header.
+ #: (Contact::Address? address, untyped submitted) -> String?
+ def self.address_line(address, submitted)
+ return unless submitted.is_a?(Hash)
+
+ property = address && address.line.property
+ if property.nil?
+ components = [""]
+ else
+ components = VCard.split_raw_components(property.value)
+ end
+ components << "" while components.length < 7
+ ADDRESS_COMPONENTS.each do |name, position|
+ value = submitted[name].to_s.strip
+ current = address && address.public_send(name).to_s
+ components[position] = value == current ? components.fetch(position) : VCard.escape(value)
+ end
+ return if components.all?(&:empty?)
+
+ header = property ? VCard.header_of(property) : "ADR:"
+ "#{header}#{components.join(";")}\r\n"
+ 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)}\r\n"]
+ 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 last) -> VCard
+ def self.edited_fn(contact, vcard, first, last)
+ family, given = contact.name_components || []
+ named = vcard.properties.any? { it.name.casecmp?("FN") }
+ return vcard if named && first == given.to_s && last == family.to_s
+
+ vcard.replace("FN", ["FN:#{VCard.escape([first, last].reject(&:empty?).join(" "))}\r\n"])
+ end
+
+ # N's replacement line. The first two components are the form's;
+ # the remaining three — additional, prefixes, suffixes (RFC 2426
+ # section 3.1.2) — 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 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.
+ #: (VCard vcard, String first, String last) -> String
+ def self.n_line(vcard, first, last)
+ property = vcard.properties.find { it.name.casecmp?("N") }
+ components = [] #: Array[String]
+ components.replace(VCard.split_raw_components(property.value)) if property
+ components << "" while components.length < 5
+ components[0] = VCard.escape(last)
+ components[1] = VCard.escape(first)
+ "N:#{components.join(";")}\r\n"
+ end
+
+ private_class_method :edited_phones, :edited_emails, :edited_addresses, :address_unchanged?,
+ :address_line, :birthday_component, :text_lines, :edited_fn, :n_line
+ end
+ end
+end
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 9315030..ebe7e84 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -19,8 +19,8 @@ module ProTacts
# (docs/plans/2026-09-07-web-birthday-editor.md).
#
# The fields prefill from the accessors' unescaped readings, and
- # blank equals absent on the way back (Web#edited_card), so write
- # and read agree on what an empty value means. Blank's meaning is
+ # blank equals absent on the way back (CardForm.contact_card), so
+ # write and read agree on what an empty value means. Blank's meaning is
# stated before the save, not only enforced by it: a standing row
# whose blanking deletes — a phone, email, or address line, the
# nickname or note property, a held birthday — wears
@@ -96,7 +96,7 @@ module ProTacts
# N's first two components (RFC 2426 section 3.1.2: family;
# given) — the two fields the create dialog also asks for. The
# remaining three are preserved byte-for-byte by the save's
- # raw splice (Web#n_line), never rendered here.
+ # raw splice (CardForm.n_line), never rendered here.
family, given = contact.name_components || []
@first = given
@last = family
@@ -189,7 +189,8 @@ module ProTacts
birthday_row
added_rows
# The first NOTE the card carries: this row edits one,
- # and a save writes one (Web#edited_card's replace).
+ # and a save writes one (CardForm.contact_card's
+ # replace).
note = @own.notes.first&.value
label(class: "field", data: {blank_removes: !!note}) do
span { "Note" }
@@ -221,7 +222,7 @@ module ProTacts
# scaffold docs/DESIGN.md refuses, and a trailing one left over
# from a row already filled is the same scaffold arriving late.
# A blank added row is a no-op rather than a removal
- # (Web#edited_phones), so these rows wear no removal state —
+ # (CardForm.edited_phones), so these rows wear no removal state —
# the class comment's exemption.
# That verb — make a row, now, without a round trip — is what
# CSS could not do and what Alpine is here for (see Layout).
@@ -348,8 +349,8 @@ module ProTacts
# in each field's name is the row's address; the po box has no
# field, and the save preserves its bytes. The removal state
# rides only on a line with no po box: the save's removal is
- # blank throughout, po box included (Web#address_line), so a
- # line surviving by its po box alone cannot be removed from
+ # blank throughout, po box included (CardForm.address_line), so
+ # a line surviving by its po box alone cannot be removed from
# this form at all — its row wears no state however blank it
# renders.
#: (Contact::Address address) -> void
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 0d84ffb..b11c4b5 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -10,6 +10,7 @@ require "rack/rewindable_input"
require "nokogiri"
require "roda"
+require "pro_tacts/admin/card_form"
require "pro_tacts/admin/contact_dialog"
require "pro_tacts/admin/contacts_edit"
require "pro_tacts/admin/contacts_index"
@@ -42,21 +43,6 @@ module ProTacts
__dir__ #: String
).parent.parent / "public" #: Pathname
- # ADR's editable components: field name to position in the value
- # (RFC 2426 section 3.2.1, minus the leading po box at position 0
- # — no screen shows one, and the save preserves its bytes rather
- # than letting a form field near them). In the value's own order,
- # which is the order a rebuilt line's components join in.
- ADDRESS_COMPONENTS = {
- "extended" => 1,
- "street" => 2,
- "locality" => 3,
- "region" => 4,
- "postal_code" => 5,
- "country" => 6,
- }.freeze #: Hash[String, Integer]
- private_constant :ADDRESS_COMPONENTS
-
# The store this app serves from. config.ru builds it and hands it in;
# nothing here reaches for a global to find one, which is what lets a
# test point the app at a throwaway database. Kept in Roda's own opts
@@ -143,7 +129,7 @@ module ProTacts
dashboard(query: r.params["q"], notice: "A contact needs a name.")
else
id = SecureRandom.uuid
- store.put(id, new_contact_card(id, first, last))
+ store.put(id, Admin::CardForm.new_card(id, first, last))
r.redirect "/contacts/#{id}", 303
end
end
@@ -857,7 +843,7 @@ module ProTacts
birthday =
if (fields = r.params["birthday"]).is_a?(Hash)
begin
- parse_birthday(fields)
+ Admin::CardForm.birthday(fields)
rescue ArgumentError
return edit_screen(contact, notice: "That birthday is not a shape a date can take.")
end
@@ -883,14 +869,13 @@ module ProTacts
return edit_screen(contact, notice: "This contact's card carries its own birthday spelling; nothing was saved.")
end
- store.rewrite(id, edited_card(contact, first, last, r.params), birthday:)
+ store.rewrite(id, Admin::CardForm.contact_card(contact, first, last, r.params), birthday:)
r.redirect "/contacts/#{id}", 303
end
# The group editor's POST, #apply_edit's shape over a group: the
- # snapshot guard, then the contact editor's own address splice and
- # note replace over the group's lines read as a card (Store::Group#reading),
- # and one store write for the lot.
+ # snapshot guard, then the lines the form splices
+ # (Admin::CardForm.group_lines), and one store write for the lot.
#: (untyped r, String id) -> String?
def apply_group_edit(r, id)
group = store.group(id)
@@ -900,9 +885,6 @@ module ProTacts
return group_edit_screen(group, notice: "This group changed since the page loaded; nothing was saved.")
end
- reading = group.reading
- card = reading.stored.replace("NOTE", text_lines("NOTE", r.params["note"].to_s.strip))
- card = edited_addresses(reading, card, r.params)
# Only ids that name a card: a membership row is a foreign key,
# and a doctored id is ordinary bad input rather than a 500. A
# POST carrying no list keeps the membership it found, the
@@ -916,7 +898,7 @@ module ProTacts
end #: Array[String]
begin
- store.edit_group(id, name: r.params["name"].to_s, lines: group_lines(card), members:)
+ store.edit_group(id, name: r.params["name"].to_s, lines: Admin::CardForm.group_lines(group, r.params), members:)
rescue Sequel::UniqueConstraintViolation
# A taken `sync:` name (db/migrations/007_sync_names.rb). The save
# is one transaction, so nothing of it landed.
@@ -925,17 +907,6 @@ module ProTacts
r.redirect "/groups/#{id}", 303
end
- # The card a group save spliced, 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.
- #: (VCard card) -> Array[String]
- def group_lines(card)
- card.lines.filter_map {
- line = VCard::Parser.unfold(it.verbatim).sub(/(\r\n|[\r\n])\z/, "")
- line unless line.empty?
- }
- end
-
#: (Store::Group group, ?notice: String) -> String
def group_edit_screen(group, notice: nil)
response["Content-Type"] = "text/html; charset=utf-8"
@@ -954,276 +925,6 @@ module ProTacts
Admin::ContactsEdit.call(contact:, notice:)
end
- # 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 last) -> VCard
- def new_contact_card(id, first, last)
- VCard.new(
- "BEGIN:VCARD\r\nVERSION:3.0\r\n" \
- "N:#{VCard.escape(last)};#{VCard.escape(first)};;;\r\n" \
- "FN:#{VCard.escape([first, last].reject(&:empty?).join(" "))}\r\n" \
- "UID:#{id}\r\n" \
- "END:VCARD\r\n",
- )
- end
-
- # The surgical save — the hazard it exists to remove, the
- # blank-equals-absent rule, and why REV is left alone are
- # docs/plans/2026-09-05-web-card-editor.md.
- #: (Contact contact, String first, String last, Hash[String, untyped] params) -> VCard
- def edited_card(contact, first, last, params)
- nickname = params["nickname"].to_s.strip
- note = params["note"].to_s.strip
-
- # 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 = vcard.replace("N", [n_line(vcard, first, last)])
- vcard = edited_fn(contact, vcard, first, last)
- vcard = vcard.replace("NICKNAME", text_lines("NICKNAME", nickname))
- vcard = vcard.replace("NOTE", text_lines("NOTE", note))
- vcard = edited_phones(own, vcard, params)
- vcard = edited_emails(own, vcard, params)
- edited_addresses(own, vcard, params)
- end
-
- # The phones' half of the surgical save: each row names its line
- # by digest, so a row the request leaves out is a line the save
- # never mentions, and an unchanged row is skipped — the line keeps
- # its own bytes by construction, not by careful re-rendering. A
- # blank row removes its line; a changed row swaps the value under
- # the line's own header (VCard.header_of), where the TYPE
- # parameters no field models ride. The addresses come from the
- # contact, never the request, so a doctored digest names nothing
- # and a missing one touches nothing.
- #: (Contact contact, VCard vcard, Hash[String, untyped] params) -> VCard
- def edited_phones(contact, vcard, params)
- rows = params["phone"]
- if rows.is_a?(Hash)
- contact.phones.each do |phone|
- # The property is nil only for a line that would not read,
- # and phones read from lines that did; the guard is the
- # type's honesty, not a reachable case (n_line's own shape).
- property = phone.line.property
- next if property.nil?
-
- submitted = rows[phone.line.digest]
- next if submitted.nil? || submitted.to_s.strip == phone.value
-
- value = submitted.to_s.strip
- vcard = vcard.substitute(
- phone.line.digest,
- value.empty? ? [] : ["#{VCard.header_of(property)}#{VCard.escape(value)}"]
- )
- end
- end
-
- # The rows the add dialog reveals (Admin::ContactsEdit): each
- # value lands as a bare TEL before END:VCARD, and a blank one
- # inserts nothing — inserting absence is a no-op, the plan's
- # rule for new rows, and the reason the rows nobody typed in
- # cost nothing whether they were on screen or not. Array()
- # because the field is a list and a request carrying one value
- # is still a list of one.
- submitted = Array(params["new_phone"]) #: Array[untyped]
- added = submitted.filter_map {
- value = it.to_s.strip
- "TEL:#{VCard.escape(value)}\r\n" unless value.empty?
- } #: Array[String]
- vcard.insert(added)
- end
-
- # #edited_phones' walk, over EMAIL.
- #: (Contact contact, VCard vcard, Hash[String, untyped] params) -> VCard
- def edited_emails(contact, vcard, params)
- rows = params["email"]
- if rows.is_a?(Hash)
- contact.emails.each do |email|
- property = email.line.property
- next if property.nil?
-
- submitted = rows[email.line.digest]
- next if submitted.nil? || submitted.to_s.strip == email.value
-
- value = submitted.to_s.strip
- vcard = vcard.substitute(
- email.line.digest,
- value.empty? ? [] : ["#{VCard.header_of(property)}#{VCard.escape(value)}"],
- )
- end
- end
-
- submitted = Array(params["new_email"]) #: Array[untyped]
- added = submitted.filter_map {
- value = it.to_s.strip
- "EMAIL:#{VCard.escape(value)}\r\n" unless value.empty?
- } #: Array[String]
- vcard.insert(added)
- end
-
- # #edited_phones' walk, over a row that is six fields rather than
- # one (Admin::ContactsEdit). Removal is the reader's own rule
- # (Contact#address_of): a row blank throughout, po box included,
- # removes the line, where a partially blanked one keeps it —
- # partial blanks are legal empty components.
- #: (Contact contact, VCard vcard, Hash[String, untyped] params) -> VCard
- def edited_addresses(contact, vcard, params)
- rows = params["address"]
- if rows.is_a?(Hash)
- contact.addresses.each do |address|
- property = address.line.property
- next if property.nil?
-
- submitted = rows[address.line.digest]
- next if !submitted.is_a?(Hash) || address_unchanged?(address, submitted)
-
- line = address_line(address, submitted)
- vcard = vcard.substitute(address.line.digest, line ? [line] : [])
- end
- end
-
- # The add dialog names an added address by its add index
- # (`new_address[i][street]`) rather than the bare [] every
- # single-valued kind appends to, because Rack refuses a key
- # after an empty one — so the rows arrive as a hash of hashes
- # and read out by their values, several adds in one pass
- # included.
- submitted = params["new_address"]
- added = submitted.is_a?(Hash) ? submitted.values : Array(submitted) #: Array[untyped]
- vcard.insert(added.filter_map { address_line(nil, it) })
- end
-
- #: (Contact::Address address, untyped submitted) -> bool
- def address_unchanged?(address, submitted)
- ADDRESS_COMPONENTS.keys.all? { |name|
- submitted[name].to_s.strip == address.public_send(name).to_s
- }
- end
-
- # One address row's replacement line: the raw components spliced
- # and re-joined under the line's own header, or nil when the result
- # is blank throughout (blank is absent, the reader's own rule for
- # an ADR — the caller removes the line). A component that submits
- # its current reading keeps its own bytes — the po box always,
- # having no field, and any other the form left alone — where a
- # moved one re-escapes from the form; the splice over
- # still-escaped components is n_line's own rule,
- # unescape-then-re-escape not being byte-stable. A nil address is
- # an added row: seven fresh positions, po box empty, a bare ADR
- # header.
- #: (Contact::Address? address, untyped submitted) -> String?
- def address_line(address, submitted)
- return unless submitted.is_a?(Hash)
-
- property = address && address.line.property
- if property.nil?
- components = [""]
- else
- components = VCard.split_raw_components(property.value)
- end
- components << "" while components.length < 7
- ADDRESS_COMPONENTS.each do |name, position|
- value = submitted[name].to_s.strip
- current = address && address.public_send(name).to_s
- components[position] = value == current ? components.fetch(position) : VCard.escape(value)
- end
- return if components.all?(&:empty?)
-
- header = property ? VCard.header_of(property) : "ADR:"
- "#{header}#{components.join(";")}\r\n"
- 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.
- #: (untyped fields) -> Birthday?
- def parse_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?
-
- Birthday.new(year:, month:, day:)
- 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 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 text_lines(name, value)
- return [] if value.empty?
-
- ["#{name}:#{VCard.escape(value)}\r\n"]
- 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 last) -> VCard
- def edited_fn(contact, vcard, first, last)
- family, given = contact.name_components || []
- named = vcard.properties.any? { it.name.casecmp?("FN") }
- return vcard if named && first == given.to_s && last == family.to_s
-
- vcard.replace("FN", ["FN:#{VCard.escape([first, last].reject(&:empty?).join(" "))}\r\n"])
- end
-
- # N's replacement line. The first two components are the form's;
- # the remaining three — additional, prefixes, suffixes (RFC 2426
- # section 3.1.2) — 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 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.
- #: (VCard vcard, String first, String last) -> String
- def n_line(vcard, first, last)
- property = vcard.properties.find { it.name.casecmp?("N") }
- components = [] #: Array[String]
- components.replace(VCard.split_raw_components(property.value)) if property
- components << "" while components.length < 5
- components[0] = VCard.escape(last)
- components[1] = VCard.escape(first)
- "N:#{components.join(";")}\r\n"
- end
-
# A card that breaks one of the parser's assumptions is not bad
# input, it is news that the assumption is wrong and every
# simplification resting on it is now suspect — an error, where a
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index f90c191..16a6d65 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -880,9 +880,9 @@ class AdminContactsPagesTest < Minitest::Test
end
# A po box has no field, and the save's removal is blank throughout,
- # po box included (Web#address_line) — so a line surviving by its po
- # box alone cannot be removed from this form, and its row, blank as
- # it renders, wears no removal state.
+ # po box included (Admin::CardForm.address_line) — so a line
+ # surviving by its po box alone cannot be removed from this form, and
+ # its row, blank as it renders, wears no removal state.
def test_a_po_box_only_address_row_wears_no_removal_state
po = ADA.sub(
"ADR;TYPE=home:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom",