contacts: take a middle name in the create and edit forms
N held a middle all along, but no box let you type it;
three boxes now, and FN grows the moment you supply it.
A box handing back what it read leaves the stored bytes as they stand;
the importer still refuses one, so no plan carries a middle in hand.
Assisted-by: Claude Opus 5 via Claude Code
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
diff --git a/lib/pro_tacts/admin/card_form.rb b/lib/pro_tacts/admin/card_form.rb
index 1568afb..e248f77 100644
--- a/lib/pro_tacts/admin/card_form.rb
+++ b/lib/pro_tacts/admin/card_form.rb
@@ -36,20 +36,20 @@ module ProTacts
# 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)
+ #: (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)};;;\r\n" \
- "FN:#{VCard.escape([first, last].reject(&:empty?).join(" "))}\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
# 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)
+ #: (Contact contact, String first, String middle, String last, Hash[String, untyped] params) -> VCard
+ def self.contact_card(contact, first, middle, last, params)
nickname = params["nickname"].to_s.strip
note = params["note"].to_s.strip
@@ -61,8 +61,8 @@ module ProTacts
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("N", [n_line(vcard, first, middle, last)])
+ vcard = edited_fn(contact, vcard, first, middle, last)
vcard = vcard.replace("NICKNAME", text_lines("NICKNAME", nickname))
vcard = vcard.replace("NOTE", text_lines("NOTE", note))
vcard = edited_phones(own, vcard, params)
@@ -284,36 +284,57 @@ module ProTacts
# 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 || []
+ #: (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.properties.any? { it.name.casecmp?("FN") }
- return vcard if named && first == given.to_s && last == family.to_s
+ return vcard if named && first == given.to_s && middle == additional.to_s && last == family.to_s
- vcard.replace("FN", ["FN:#{VCard.escape([first, last].reject(&:empty?).join(" "))}\r\n"])
+ vcard.replace("FN", ["FN:#{VCard.escape(display_name(first, middle, last))}\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)
+ # FN's text, from the boxes that make it: the western order the
+ # create dialog has always written (Admin::ContactDialog), 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
+ # address_line 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.
+ #: (VCard vcard, String first, String middle, String last) -> String
+ def self.n_line(vcard, first, middle, last)
property = vcard.properties.find { it.name.casecmp?("N") }
components = [] #: Array[String]
- components.replace(VCard.split_raw_components(property.value)) if property
+ readings = [] #: Array[String]
+ if property
+ components.replace(VCard.split_raw_components(property.value))
+ readings.replace(property.components)
+ end
components << "" while components.length < 5
- components[0] = VCard.escape(last)
- components[1] = VCard.escape(first)
+ # The form's three, in the value's own order: family, given,
+ # additional.
+ [last, first, middle].each_with_index do |value, position|
+ current = readings[position].to_s
+ components[position] = value == current ? components.fetch(position) : VCard.escape(value)
+ end
"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
+ :address_line, :birthday_component, :text_lines, :edited_fn, :display_name,
+ :n_line
end
end
end
diff --git a/lib/pro_tacts/admin/contact_dialog.rb b/lib/pro_tacts/admin/contact_dialog.rb
index dfa9c60..47aac8b 100644
--- a/lib/pro_tacts/admin/contact_dialog.rb
+++ b/lib/pro_tacts/admin/contact_dialog.rb
@@ -27,9 +27,11 @@ module ProTacts
# a contact carries arrives by editing the record — which belongs
# on the details page, where the surgical mutator will live. N and
# FN both written because RFC 2426 section 4 requires them of every
- # card; the two name fields are N's first two components
- # (family;given), and FN is derived from them in the western order
- # the fixture sessions show macOS writing.
+ # card; the three name fields are N's first three components
+ # (family;given;additional), and FN is derived from them in the
+ # western order the fixture sessions show macOS writing. The middle
+ # box stands outside the required pair — it is never the name a
+ # contact is made by — so it carries no rule of NamePair's.
#
# A popover cannot be declared open in markup, so the empty-name
# refusal is not a re-opened dialog: each name box is `required`
@@ -56,6 +58,10 @@ module ProTacts
plain "First"
input(**NamePair.first(nil, nil), autofocus: true)
end
+ label(class: "field") do
+ plain "Middle"
+ input(type: "text", name: "middle")
+ end
label(class: "field") do
plain "Last"
input(**NamePair.last(nil, nil))
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 9775962..de2de01 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -87,6 +87,7 @@ module ProTacts
# @rbs @own: Contact
# @rbs @notice: String?
# @rbs @first: String?
+ # @rbs @middle: String?
# @rbs @last: String?
#: (contact: Contact, ?notice: String?) -> void
@@ -98,12 +99,14 @@ module ProTacts
# composed contact's, the one a save is guarded against.
@own = contact.own
@notice = notice
- # 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 (CardForm.n_line), never rendered here.
- family, given = contact.name_components || []
+ # N's first three components (RFC 2426 section 3.1.2: family;
+ # given; additional) — the three fields the create dialog also
+ # asks for. The prefixes and suffixes after them are preserved
+ # byte-for-byte by the save's raw splice (CardForm.n_line),
+ # never rendered here.
+ family, given, additional = contact.name_components || []
@first = given
+ @middle = additional
@last = family
end
@@ -143,6 +146,15 @@ module ProTacts
span { "First" }
input(**NamePair.first(@first, @last), autofocus: true)
end
+ # Outside the required pair, and outside its removal
+ # state with it: a blanked middle name rewrites N's
+ # additional component like any other name box, and
+ # the row it would strike through is the name row,
+ # which no save can remove.
+ label(class: "field") do
+ span { "Middle" }
+ input(type: "text", name: "middle", value: @middle)
+ end
label(class: "field") do
span { "Last" }
input(**NamePair.last(@first, @last))
diff --git a/lib/pro_tacts/admin/name_pair.rb b/lib/pro_tacts/admin/name_pair.rb
index 2cf5ac7..e2e0a57 100644
--- a/lib/pro_tacts/admin/name_pair.rb
+++ b/lib/pro_tacts/admin/name_pair.rb
@@ -9,6 +9,10 @@ module ProTacts
# they change. The server's refusal of a save blank throughout
# (Web's contacts routes) is the backstop behind it.
#
+ # The middle box the same forms carry is not one of the halves and
+ # is not here: nobody is known by a middle name alone, so it is
+ # never required and has no state to share.
+ #
# Not a view, so the Steepfile checks it.
module NamePair
# The Alpine state the two boxes share, as properties for the
diff --git a/lib/pro_tacts/import/card.rb b/lib/pro_tacts/import/card.rb
index d8aeddb..024094d 100644
--- a/lib/pro_tacts/import/card.rb
+++ b/lib/pro_tacts/import/card.rb
@@ -99,10 +99,13 @@ module ProTacts
# make of these fields. The birthday rides beside the card as the
# store keeps it, and Contact composes the line back in
# (docs/plans/2026-09-11-every-birthday-in-the-model.md).
+ # No middle name: a plan's card holds first and last, and a source
+ # N carrying anything past them is refused rather than imported
+ # (Macos.name), so there is none here to write.
#: (String id) -> Contact
def contact(id)
- created = Contact.new(id:, stored: Admin::CardForm.new_card(id, first, last), birthday: nil, inherited: [])
- stored = Admin::CardForm.contact_card(created, first, last, {
+ created = Contact.new(id:, stored: Admin::CardForm.new_card(id, first, "", last), birthday: nil, inherited: [])
+ stored = Admin::CardForm.contact_card(created, first, "", last, {
"nickname" => nickname.to_s, "note" => note.to_s,
"new_phone" => phones, "new_email" => emails, "new_address" => addresses
})
diff --git a/lib/pro_tacts/web/contacts.rb b/lib/pro_tacts/web/contacts.rb
index bc6ab0d..b81b212 100644
--- a/lib/pro_tacts/web/contacts.rb
+++ b/lib/pro_tacts/web/contacts.rb
@@ -32,14 +32,19 @@ module ProTacts
r.is do
r.post do
first = r.params["first"].to_s.strip
+ middle = r.params["middle"].to_s.strip
last = r.params["last"].to_s.strip
+ # A middle name is not one of the halves that make a name: the
+ # form asks for it beside a pair it stands outside of
+ # (Admin::NamePair), and nobody is known by their middle name
+ # alone.
if first.empty? && last.empty?
dashboard(query: r.params["q"], notice: "A contact needs a name.")
else
# Store#put overwrites, so the width has to make a collision
# unthinkable rather than caught: 48 bits, for a family's book.
id = ChangeId.mint(12)
- store.put(id, Admin::CardForm.new_card(id, first, last))
+ store.put(id, Admin::CardForm.new_card(id, first, middle, last))
r.redirect "/contacts/#{id}", 303
end
end
@@ -132,6 +137,7 @@ module ProTacts
# name pair (Admin::NamePair) being the browser's own refusal of
# the same.
first = r.params["first"].to_s.strip
+ middle = r.params["middle"].to_s.strip
last = r.params["last"].to_s.strip
return edit_screen(contact, notice: "A contact needs a name.") if first.empty? && last.empty?
@@ -170,7 +176,7 @@ module ProTacts
return edit_screen(contact, notice: "This contact's card carries its own birthday spelling; nothing was saved.")
end
- store.rewrite(id, Admin::CardForm.contact_card(contact, first, last, r.params), birthday:)
+ store.rewrite(id, Admin::CardForm.contact_card(contact, first, middle, last, r.params), birthday:)
r.redirect "/contacts/#{id}", 303
end
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index cdbbbbb..ef7c25d 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -837,6 +837,7 @@ class AdminContactsPagesTest < Minitest::Test
# assertion keeps a regression from rendering a silent label).
assert_includes body, '<label class="field">First<input type="text" name="first" required ' \
':required="lastBlank" @input="firstBlank = !$el.value.trim()" autofocus></label>'
+ assert_includes body, '<label class="field">Middle<input type="text" name="middle"></label>'
assert_includes body, '<label class="field">Last<input type="text" name="last" required ' \
':required="firstBlank" @input="lastBlank = !$el.value.trim()"></label>'
assert_includes body, 'popovertargetaction="hide"'
@@ -868,6 +869,32 @@ class AdminContactsPagesTest < Minitest::Test
end
end
+ # The middle box writes N's additional component (RFC 2426 section
+ # 3.1.2) and joins the display name the other two make.
+ def test_creating_a_contact_with_a_middle_name
+ with_contacts({}) do |store|
+ post "/contacts", first: "Ada", middle: "Byron", last: "Lovelace"
+
+ assert_equal 303, last_response.status
+ id = last_response["Location"].delete_prefix("/contacts/")
+ card = store.contact(id).vcard.to_s
+ assert_includes card, "N:Lovelace;Ada;Byron;;"
+ assert_includes card, "FN:Ada Byron Lovelace"
+ end
+ end
+
+ # A middle name is not one of the halves a contact is named by, so a
+ # create carrying nothing else is the nameless one, refused.
+ def test_a_create_with_only_a_middle_name_is_refused
+ with_contacts({}) do |store|
+ post "/contacts", first: "", middle: "Byron", last: ""
+
+ assert_equal 200, last_response.status
+ assert_includes last_response.body, "A contact needs a name."
+ assert_empty store.contacts
+ end
+ end
+
# A single name is a fine contact: the model carries either half
# alone, and the card says exactly what was given.
def test_creating_a_contact_with_only_a_first_name
@@ -928,6 +955,7 @@ class AdminContactsPagesTest < Minitest::Test
assert_includes body, '<button type="submit" form="contact-form" data-variant="primary">Save</button>'
assert_includes body, '<input type="text" name="first" value="Ada" ' \
':required="lastBlank" @input="firstBlank = !$el.value.trim()" autofocus>'
+ assert_includes body, '<span>Middle</span><input type="text" name="middle">'
assert_includes body, '<input type="text" name="last" value="Lovelace" ' \
':required="firstBlank" @input="lastBlank = !$el.value.trim()">'
assert_includes body,
@@ -940,6 +968,19 @@ class AdminContactsPagesTest < Minitest::Test
end
end
+ # The middle box prefills from N's additional component, unescaped
+ # like every other reading the form starts from.
+ def test_the_edit_screen_prefills_the_middle_name
+ byron = ADA.sub("N:Lovelace;Ada;;;", "N:Lovelace;Ada;Byron\\, the poet;;")
+
+ with_contacts({"ada" => byron}) do
+ get "/contacts/ada/edit"
+
+ assert_equal 200, last_response.status
+ assert_includes last_response.body, '<input type="text" name="middle" value="Byron, the poet">'
+ end
+ end
+
# A family name alone is a name (`N:Prince;;;;`): the blank first box
# is not required over it, or the browser would refuse every save.
def test_a_family_name_alone_is_a_name_the_editor_can_save
@@ -1109,7 +1150,7 @@ class AdminContactsPagesTest < Minitest::Test
end
# FN is the card's own display name, not a rendering of N: a save
- # that left both name fields alone must not rebuild it from them,
+ # that left every name field alone must not rebuild it from them,
# or a card spelling the name in full loses every part the form has
# no field for — the parts N itself keeps.
def test_a_save_that_moves_no_name_field_keeps_the_cards_own_display_name
@@ -1118,7 +1159,8 @@ class AdminContactsPagesTest < Minitest::Test
with_contacts({"ada" => formal}) do |store|
digest = store.contact("ada").phones.first.line.digest
- post "/contacts/ada", first: "Ada", last: "Lovelace", note: "Countess of Lovelace.",
+ post "/contacts/ada", first: "Ada", middle: "B.", last: "Lovelace",
+ note: "Countess of Lovelace.",
etag: store.contact("ada").etag, phone: {digest => "+1-555-0199"}
assert_equal 303, last_response.status
@@ -1130,7 +1172,7 @@ class AdminContactsPagesTest < Minitest::Test
end
# A moved name field is the one time the old display name is stale,
- # and then FN is the form's reading — N's other three components
+ # and then FN is the form's reading — N's prefixes and suffixes
# staying where they were, this being an edit of the name and not of
# the card's spelling of it.
def test_a_moved_name_field_rewrites_the_display_name
@@ -1138,15 +1180,47 @@ class AdminContactsPagesTest < Minitest::Test
.sub("N:Lovelace;Ada;;;", "N:Lovelace;Ada;B.;Dr.;Jr.")
with_contacts({"ada" => formal}) do |store|
- post "/contacts/ada", first: "Ada", last: "King", etag: store.contact("ada").etag
+ post "/contacts/ada", first: "Ada", middle: "B.", last: "King", etag: store.contact("ada").etag
assert_equal 303, last_response.status
card = store.contact("ada").vcard.to_s
- assert_includes card, "FN:Ada King\r\n"
+ assert_includes card, "FN:Ada B. King\r\n"
assert_includes card, "N:King;Ada;B.;Dr.;Jr.\r\n"
end
end
+ # The middle box is a name field like the two beside it: moving it
+ # alone is what makes the old display name stale.
+ def test_a_moved_middle_name_rewrites_the_display_name
+ with_contacts({"ada" => ADA}) do |store|
+ post "/contacts/ada", first: "Ada", middle: "Byron", last: "Lovelace",
+ etag: store.contact("ada").etag
+
+ assert_equal 303, last_response.status
+ card = store.contact("ada").vcard.to_s
+ assert_includes card, "N:Lovelace;Ada;Byron;;\r\n"
+ assert_includes card, "FN:Ada Byron Lovelace\r\n"
+ end
+ end
+
+ # Blank equals absent in the name row too: emptying the middle box
+ # clears N's additional component and drops it from the display
+ # name. The line stays — N is mandatory (RFC 2426 section 4) and a
+ # component is not a property.
+ def test_a_blank_middle_name_clears_the_additional_component
+ byron = ADA.sub("FN:Ada Lovelace", "FN:Ada Byron Lovelace")
+ .sub("N:Lovelace;Ada;;;", "N:Lovelace;Ada;Byron;;")
+
+ with_contacts({"ada" => byron}) do |store|
+ post "/contacts/ada", first: "Ada", middle: "", last: "Lovelace", etag: store.contact("ada").etag
+
+ assert_equal 303, last_response.status
+ card = store.contact("ada").vcard.to_s
+ assert_includes card, "N:Lovelace;Ada;;;\r\n"
+ assert_includes card, "FN:Ada Lovelace\r\n"
+ end
+ end
+
# The mandatory property is filled in rather than left absent (RFC
# 2426 section 4): a card with no FN has no display name to keep, so
# the untouched-name rule does not apply to it.
@@ -1176,20 +1250,37 @@ class AdminContactsPagesTest < Minitest::Test
end
end
- # N's unedited components — additional, prefixes, suffixes — splice
- # through byte for byte, the still-escaped value never rounding
- # through unescape and re-escape. The escaped semicolon inside the
- # additional component is the one an unescaped split would break on;
+ # N's unedited components — the prefixes and suffixes no box shows —
+ # splice through byte for byte, the still-escaped value never
+ # rounding through unescape and re-escape. The escaped semicolon
+ # inside the prefixes is the one an unescaped split would break on;
# the seed's four components leave the fifth to the padding rule.
def test_the_names_unedited_components_are_preserved_byte_for_byte
- honorific = ADA.sub("N:Lovelace;Ada;;;", "N:Lovelace;Ada;Byron\\; Countess;Countess of Lovelace")
+ honorific = ADA.sub("N:Lovelace;Ada;;;", "N:Lovelace;Ada;;Countess\\; Dr.")
with_contacts({"ada" => honorific}) do |store|
- post "/contacts/ada", first: "Ada", last: "King", etag: store.contact("ada").etag
+ post "/contacts/ada", first: "Ada", middle: "", last: "King", etag: store.contact("ada").etag
assert_equal 303, last_response.status
- assert_includes store.contact("ada").vcard.to_s,
- "N:King;Ada;Byron\\; Countess;Countess of Lovelace;\r\n"
+ assert_includes store.contact("ada").vcard.to_s, "N:King;Ada;;Countess\\; Dr.;\r\n"
+ end
+ end
+
+ # A name box that submits what it was rendered from keeps its
+ # component's own bytes, rather than rounding through unescape and
+ # re-escape — which is not byte-stable: `\x` is no escape the
+ # reader knows, so it survives the read and the writer would
+ # double its backslash. The rule address_line already writes under
+ # (CardForm.n_line), at the name's grain.
+ def test_a_name_component_returned_unchanged_keeps_its_own_bytes
+ odd = ADA.sub("N:Lovelace;Ada;;;", "N:Lovelace;Ada;Byron\\x;;")
+
+ with_contacts({"ada" => odd}) do |store|
+ post "/contacts/ada", first: "Ada", middle: "Byron\\x", last: "King",
+ etag: store.contact("ada").etag
+
+ assert_equal 303, last_response.status
+ assert_includes store.contact("ada").vcard.to_s, "N:King;Ada;Byron\\x;;\r\n"
end
end