admin: edit a contact's name, nickname, and note
The save is addressed operations over the stored card (VCard#replace
through Store#rewrite), so a property the form has no field for is
never mentioned and cannot be lost — the spec the plan doc builds
everything around. The create route grew `r.is` because a bare verb
block matches any remaining path in Roda, and the collection's POST
would otherwise swallow the record's.
Assisted-by: GLM-5.3 via pi
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
new file mode 100644
index 0000000..570e2cc
--- /dev/null
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -0,0 +1,80 @@
+require "pro_tacts/admin/phlex"
+
+require "pro_tacts/admin/layout"
+
+module ProTacts
+ module Admin
+ # GET /contacts/:id/edit — the editor
+ # (docs/plans/2026-09-05-web-card-editor.md): an explicit mode
+ # rather than an always-editable page, one form over the
+ # properties the save knows how to address. This stage carries the
+ # cardinality-1 set — name, nickname, note — whose save is
+ # VCard#replace under each field; phones, emails, and addresses
+ # arrive with their own stages, birthday last.
+ #
+ # 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. The etag rides
+ # along hidden — the snapshot guard's half, the POST's refusal
+ # being the other. `autofocus` on the first field: this screen's
+ # entry point is the name, not the header search.
+ class ContactsEdit < Phlex::HTML
+ # @rbs @contact: Contact
+ # @rbs @notice: String?
+ # @rbs @first: String?
+ # @rbs @last: String?
+
+ #: (contact: Contact, ?notice: String?) -> void
+ def initialize(contact:, notice: nil)
+ @contact = contact
+ @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 (Web#n_line), never rendered here.
+ family, given = contact.name_components || []
+ @first = given
+ @last = family
+ end
+
+ def view_template
+ render Layout.new(title: "Edit #{@contact.name || @contact.id}", notice: @notice) do
+ a(href: "/contacts/#{@contact.id}", class: "type-label") {
+ "‹ #{@contact.name || @contact.id}"
+ }
+ div(class: "card") do
+ div(class: "card-body") do
+ form(action: "/contacts/#{@contact.id}", method: "post", class: "field-stack") do
+ input(type: "hidden", name: "etag", value: @contact.etag)
+ label(class: "field") do
+ plain "First"
+ input(type: "text", name: "first", value: @first,
+ required: true, autofocus: true)
+ end
+ label(class: "field") do
+ plain "Last"
+ input(type: "text", name: "last", value: @last)
+ end
+ label(class: "field") do
+ plain "Nickname"
+ input(type: "text", name: "nickname", value: @contact.nickname)
+ end
+ label(class: "field") do
+ plain "Note"
+ textarea(name: "note", rows: 4) { @contact.notes.to_s }
+ end
+ # Save is the form's submit; Cancel is navigation — a
+ # link in Gloss's `.btn` contract, which is what an
+ # anchor that acts like a button opts into.
+ div(class: "form-actions") do
+ button(type: "submit", data: {variant: "primary"}) { "Save" }
+ a(href: "/contacts/#{@contact.id}", class: "btn") { "Cancel" }
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/lib/pro_tacts/admin/contacts_show.rb b/lib/pro_tacts/admin/contacts_show.rb
index daa400a..ae31606 100644
--- a/lib/pro_tacts/admin/contacts_show.rb
+++ b/lib/pro_tacts/admin/contacts_show.rb
@@ -45,6 +45,13 @@ module ProTacts
if @contact.nickname
div(class: "type-h3 gl-muted", style: "margin-top: var(--gl-space-2xs);") { @contact.nickname }
end
+ # The record's one action, quiet and under the name
+ # — the edit screen is this page's other mode (see
+ # Admin::ContactsEdit). A link in Gloss's `.btn`
+ # contract: navigation wearing a button's clothes.
+ div(style: "margin-top: var(--gl-space-2xs);") do
+ a(href: "/contacts/#{@contact.id}/edit", class: "btn", data_size: "sm") { "edit" }
+ end
end
# Only a picture earns this slot: an initials circle
# beside the name in type-h2 would repeat what the name
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index ebcbbff..cddc905 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -10,6 +10,7 @@ require "nokogiri"
require "roda"
require "pro_tacts/admin/contact_dialog"
+require "pro_tacts/admin/contacts_edit"
require "pro_tacts/admin/contacts_index"
require "pro_tacts/admin/contacts_show"
require "pro_tacts/debug_logger"
@@ -101,20 +102,48 @@ module ProTacts
# browser cannot produce one (the dialog's first field is
# required), so this is the backstop, and a popover cannot be
# declared open in markup — the toast is the refusal the
- # re-rendered page can actually show.
- r.post do
- first = r.params["first"].to_s.strip
- last = r.params["last"].to_s.strip
- if first.empty? && last.empty?
- dashboard(query: r.params["q"], notice: "A contact needs a name.")
- else
- id = SecureRandom.uuid
- store.put(id, new_contact_card(id, first, last))
- r.redirect "/contacts/#{id}", 303
+ # re-rendered page can actually show. `r.is` because a bare
+ # verb block matches any remaining path in Roda — without it,
+ # the collection's create would swallow the record's apply,
+ # POST /contacts/:id below.
+ r.is do
+ r.post do
+ first = r.params["first"].to_s.strip
+ last = r.params["last"].to_s.strip
+ if first.empty? && last.empty?
+ dashboard(query: r.params["q"], notice: "A contact needs a name.")
+ else
+ id = SecureRandom.uuid
+ store.put(id, new_contact_card(id, first, last))
+ r.redirect "/contacts/#{id}", 303
+ end
end
end
r.on String do |id|
+ # The browser's edit of one contact
+ # (docs/plans/2026-09-05-web-card-editor.md): an explicit
+ # mode — GET renders the form, POST applies it, success is a
+ # 303 back to the details page so the back button cannot
+ # double-submit. POST stays the wire verb, the create's
+ # precedent: HTML forms speak only GET and POST, and the
+ # admin surface's one client is the form.
+ r.get "edit" do
+ contact = store.contact(id)
+
+ # No match falls through to the empty-body 404 the
+ # not_found handler fills in, same as the page below.
+ if contact
+ response["Content-Type"] = "text/html; charset=utf-8"
+ Admin::ContactsEdit.call(contact:)
+ end
+ end
+
+ # The edit's apply, under the same fall-through-to-404 rule.
+ r.post do
+ apply_edit(r, id)
+ end
+
# The picture the avatars render (Admin::Avatar): decoded
# bytes under their own content type, served from a route
# rather than inlined as base64 so a page of avatars is a
@@ -600,6 +629,47 @@ module ProTacts
nil
end
+ # The whole of the edit POST, a private method for the same reason
+ # write_card is one: a Roda route block cannot return early, so each
+ # refusal is a value the block ends with rather than a branch it
+ # exits. The checks run in write_card's order — the request's own
+ # validity first, the conditionals on stored state after.
+ #: (untyped r, String id) -> String?
+ def apply_edit(r, id)
+ # No match is nil, which falls through to the empty-body 404 the
+ # not_found handler fills in — the GET handler's rule.
+ contact = store.contact(id)
+ return if contact.nil?
+
+ # N and FN are mandatory (RFC 2426 section 4), so a save blank
+ # throughout is refused — the toast is the backstop, the form's
+ # required field being the browser's own refusal of the same.
+ first = r.params["first"].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?
+
+ # The snapshot guard, the lost-update half If-Match gives DAV
+ # clients (RFC 7232 section 3.1): the form carried the etag of
+ # the card it was rendered from, and a contact that hashes
+ # differently now was edited in between — another tab, or a
+ # client sync — so applying this save over that one would revert
+ # it. The refusal re-renders from the current card, so the screen
+ # shows what changed; the check shares write_card's millisecond
+ # race window between check and write, noted there.
+ return edit_screen(contact, notice: "This contact changed since the page loaded; nothing was saved.") if r.params["etag"].to_s != contact.etag
+
+ store.rewrite(id, edited_card(contact, first, last, r.params).to_s)
+ r.redirect "/contacts/#{id}", 303
+ end
+
+ # The re-render a refused save answers with: the edit screen again,
+ # from the current card, with the refusal as its toast.
+ #: (Contact contact, ?notice: String) -> String
+ def edit_screen(contact, notice: nil)
+ response["Content-Type"] = "text/html; charset=utf-8"
+ 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
@@ -617,6 +687,57 @@ module ProTacts
"END:VCARD\r\n"
end
+ # The surgical save (docs/plans/2026-09-05-web-card-editor.md):
+ # each field names the property it replaces on the stored card's
+ # own bytes, and a property the form has no field for is never
+ # mentioned — what is never mentioned cannot be lost. Blank equals
+ # absent throughout, the reader's own rule (Contact#text_of) in the
+ # other direction: a blank nickname or note removes the property,
+ # and a blank name was already refused above. REV is not touched —
+ # the change log is the record of when, and macOS re-stamps REV on
+ # its own next rewrite.
+ #: (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
+
+ card = contact.stored
+ card = card.replace("N", [n_line(card, first, last)])
+ card = card.replace("FN", ["FN:#{VCard.escape([first, last].reject(&:empty?).join(" "))}\r\n"])
+ card = card.replace("NICKNAME", text_lines("NICKNAME", nickname))
+ card = card.replace("NOTE", text_lines("NOTE", note))
+ card
+ 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
+
+ # N's replacement line: a splice over the still-escaped value
+ # (VCard.split_raw_components), not an unescape-then-re-escape round
+ # trip, which is not byte-stable. The first two components are the
+ # form's; the remaining three — additional, prefixes, suffixes (RFC
+ # 2426 section 3.1.2) — rejoin byte for byte. A card with no N a
+ # form can read (none, or a line that would not) splices into a
+ # bare five-component value; one short of five is padded, the same
+ # empties a whole-N writer would leave.
+ #: (VCard card, String first, String last) -> String
+ def n_line(card, first, last)
+ property = card.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/public/admin.css b/public/admin.css
index 488c66f..7248b89 100644
--- a/public/admin.css
+++ b/public/admin.css
@@ -4,6 +4,39 @@
rem width in the spirit of card.css's own max-width: 18rem; nothing
here is a hex, a px, or a duration. */
+/* The editor's submit row: the buttons sit beside each other under
+ the stacked fields, rather than in the stack's own gap. */
+.form-actions {
+ display: flex;
+ gap: var(--gl-space-2xs);
+}
+
+/* Gloss's field contract styles input and select but not textarea
+ (base.css only resets it), and the editor's note field is one —
+ multiline text under a .field caption. The box mirrors field.css's
+ input rule in the vars that rule itself uses, so the two controls
+ read as one component; resize stays vertical, the one freedom a
+ text area has that a text input does not. */
+textarea {
+ width: 100%;
+ padding: var(--gl-space-xs) var(--gl-space-s);
+ background: var(--gl-color-surface-card);
+ border: 1px solid var(--gl-color-border-default);
+ border-radius: var(--gl-radius-sm);
+ color: var(--gl-color-text-primary);
+ font-family: var(--gl-font-sans);
+ font-size: var(--gl-step--1);
+ text-transform: none;
+ letter-spacing: normal;
+ font-weight: 400;
+ outline: none;
+ resize: vertical;
+}
+textarea:focus {
+ border-color: var(--gl-color-accent);
+ box-shadow: 0 0 0 2px var(--gl-color-accent-soft);
+}
+
/* A chrome bar, not a content gutter: the block padding is the
scale's second-smallest step, so the header hugs its one line of
content instead of spacing it like page copy. Inline padding stays
diff --git a/sig/pro_tacts/admin.rbs b/sig/pro_tacts/admin.rbs
index 6e9a762..62ee2f2 100644
--- a/sig/pro_tacts/admin.rbs
+++ b/sig/pro_tacts/admin.rbs
@@ -10,6 +10,10 @@ module ProTacts
def self.call: (contact: Contact) -> String
end
+ class ContactsEdit
+ def self.call: (contact: Contact, ?notice: String?) -> String
+ end
+
class ContactsIndex
def self.call: (recent: Array[Store::RecentContact], upcoming: Array[Store::UpcomingBirthday], query: String?, ?notice: String?) -> String
end
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 7876d61..0966835 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -573,4 +573,186 @@ class AdminContactsPagesTest < Minitest::Test
assert_empty store.contacts
end
end
+
+ ## Editing contacts
+
+ # The edit screen: one form prefilled from the card — N's two
+ # leading components as the name fields, the accessors' unescaped
+ # readings as the rest — carrying the etag of the card it renders
+ # from, the snapshot guard's hidden half.
+ def test_the_edit_screen_prefills_the_cards_values
+ red = ADA.sub("FN:Ada Lovelace", "FN:Sarah\r\nNICKNAME:Red").sub("UID:ada", "UID:red")
+
+ with_contacts({"red" => red}) do |store|
+ get "/contacts/red/edit"
+
+ assert_equal 200, last_response.status
+ body = last_response.body
+ assert_includes body, '<form action="/contacts/red" method="post" class="field-stack">'
+ assert_includes body, '<input type="text" name="first" value="Ada" required autofocus>'
+ assert_includes body, '<input type="text" name="last" value="Lovelace">'
+ assert_includes body, '<input type="text" name="nickname" value="Red">'
+ assert_includes body, "<textarea name=\"note\" rows=\"4\">Countess of Lovelace.</textarea>"
+ # The etag carries its quotes, HTML-escaped in the attribute.
+ assert_includes body, %(<input type="hidden" name="etag" value=""#{store.contact("red").etag.delete('"')}"">)
+ end
+ end
+
+ def test_the_edit_screen_of_an_unknown_contact_is_404
+ with_contacts({}) { get "/contacts/nope/edit" }
+
+ assert_equal 404, last_response.status
+ end
+
+ # The details page names its other mode: the edit link sits under
+ # the name, the record's one action.
+ def test_the_details_page_links_to_the_edit_screen
+ with_contacts({"ada" => ADA}) do
+ get "/contacts/ada"
+
+ assert_includes last_response.body, 'href="/contacts/ada/edit"'
+ end
+ end
+
+ # The save is addressed operations, and this is the whole design:
+ # the four properties the form names are replaced, every other line
+ # of the stored card is byte-identical, and the write lands through
+ # Store#rewrite with its change-log entry.
+ def test_saving_the_edit_replaces_only_the_addressed_properties
+ with_contacts({"ada" => ADA}) do |store|
+ post "/contacts/ada", first: "Ada", last: "King", nickname: "Countess",
+ note: "First programmer.", etag: store.contact("ada").etag
+
+ assert_equal 303, last_response.status
+ assert_equal "/contacts/ada", last_response["Location"]
+
+ card = store.contact("ada").vcard.to_s
+ assert_includes card, "N:King;Ada;;;"
+ assert_includes card, "FN:Ada King"
+ assert_includes card, "NICKNAME:Countess"
+ assert_includes card, "NOTE:First programmer."
+ assert_includes card, "TEL;TYPE=mobile:+1-555-0100\r\n"
+ assert_includes card, "EMAIL;TYPE=home:ada@example.com\r\n"
+ assert_includes card, "ADR;TYPE=home:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom\r\n"
+ assert_includes card, "UID:ada\r\n"
+ # The birthday is model state the edit never touched, composed
+ # back into the served card.
+ assert_includes card, "BDAY:1985-12-10\r\n"
+ assert(store.changes.any? { it.action == "edit" && it.card_id == "ada" })
+ end
+ end
+
+ # Blank equals absent on the way back: a blank nickname or note
+ # removes the property, as a blank value is absent to the reader
+ # (Contact#text_of) — and filling one in adds it where the card
+ # carried none.
+ def test_a_blank_nickname_or_note_removes_the_property
+ with_contacts({"ada" => ADA}) do |store|
+ post "/contacts/ada", first: "Ada", last: "Lovelace", nickname: "", note: "", etag: store.contact("ada").etag
+
+ assert_equal 303, last_response.status
+ card = store.contact("ada").vcard.to_s
+ refute_includes card, "NICKNAME"
+ refute_includes card, "NOTE"
+ 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;
+ # 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")
+
+ with_contacts({"ada" => honorific}) do |store|
+ post "/contacts/ada", first: "Ada", 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"
+ end
+ end
+
+ # A card whose N stops short of five components is padded with the
+ # same empties a whole-N writer would leave.
+ def test_a_short_n_is_padded_to_five_components
+ short = ADA.sub("N:Lovelace;Ada;;;", "N:Lovelace;Ada")
+
+ with_contacts({"ada" => short}) do |store|
+ post "/contacts/ada", first: "Ada", last: "King", etag: store.contact("ada").etag
+
+ assert_equal 303, last_response.status
+ assert_includes store.contact("ada").vcard.to_s, "N:King;Ada;;;\r\n"
+ end
+ end
+
+ # Values are text and the card is structure: punctuation escapes in
+ # the written lines, and a note's line breaks travel as the escape
+ # rather than ending the property's line.
+ def test_punctuation_and_line_breaks_escape_in_the_written_lines
+ with_contacts({"ada" => ADA}) do |store|
+ post "/contacts/ada", first: "Ada", last: "King",
+ nickname: "Countess, of mathematics", note: "line one\nline two",
+ etag: store.contact("ada").etag
+
+ assert_equal 303, last_response.status
+ card = store.contact("ada").vcard.to_s
+ assert_includes card, "NICKNAME:Countess\\, of mathematics\r\n"
+ assert_includes card, "NOTE:line one\\nline two\r\n"
+
+ follow_redirect!
+ assert_includes last_response.body, "Countess, of mathematics"
+ end
+ end
+
+ # A fold on a line the save never mentioned is the byte-identity
+ # claim at its sharpest: the continuation survives with its own
+ # bytes, not normalized into the unfolded reading.
+ def test_a_folded_line_the_save_never_mentioned_is_left_alone
+ folded = ADA.sub("TEL;TYPE=mobile:+1-555-0100\r\n", "TEL;TYPE=mobile:+1-555-\r\n 0100\r\n")
+
+ with_contacts({"ada" => folded}) do |store|
+ post "/contacts/ada", first: "Ada", last: "King", etag: store.contact("ada").etag
+
+ assert_equal 303, last_response.status
+ assert_includes store.contact("ada").vcard.to_s, "TEL;TYPE=mobile:+1-555-\r\n 0100\r\n"
+ end
+ end
+
+ # The snapshot guard: a save whose etag names a card the store no
+ # longer holds is refused with a toast, and nothing is written —
+ # another tab or a client sync edited the contact in between, and
+ # the save would have reverted it.
+ def test_a_save_over_a_stale_snapshot_is_refused
+ with_contacts({"ada" => ADA}) do |store|
+ post "/contacts/ada", first: "Ada", last: "King", etag: '"stale"'
+
+ assert_equal 200, last_response.status
+ assert_includes last_response.body,
+ '<div role="status" data-fixed><span>This contact changed since the page loaded; nothing was saved.</span></div>'
+ # The refusal re-renders the screen from the current card.
+ assert_includes last_response.body, 'value="Ada"'
+ assert_includes store.contact("ada").vcard.to_s, "FN:Ada Lovelace"
+ end
+ end
+
+ # The backstop for the one request no browser can send (the first
+ # field is required): a refused save, nothing written — N and FN are
+ # mandatory, so a name blank throughout cannot be saved.
+ def test_a_nameless_edit_is_refused_with_a_toast
+ with_contacts({"ada" => ADA}) do |store|
+ post "/contacts/ada", first: " ", last: "", etag: store.contact("ada").etag
+
+ assert_equal 200, last_response.status
+ assert_includes last_response.body, '<div role="status" data-fixed><span>A contact needs a name.</span></div>'
+ assert_includes store.contact("ada").vcard.to_s, "FN:Ada Lovelace"
+ end
+ end
+
+ def test_saving_an_unknown_contact_is_404
+ with_contacts({}) { post "/contacts/nope", first: "No", etag: '"x"' }
+
+ assert_equal 404, last_response.status
+ end
end