card form: an untouched save keeps every byte
The editor's save rewrote lines nobody changed: N always (losing its
parameters, group and folds, re-terminated CRLF, whitespace stripped
inside components), NICKNAME and NOTE always (re-escaped, parameters
gone), and a TEL, EMAIL or ADR whose value had whitespace around it.
A legacy card with two NOTEs showed the first and saved only it.

A field now counts as untouched when it submits what the form
rendered, compared as the browser and route hand it back (stripped,
the textarea CRLF), and an untouched field's lines stay as they are.
The note box shows every NOTE joined, as composition joins them, so a
save of two writes their join rather than dropping one. Written lines
take their neighbour's terminator.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAiyEheRBdzbBjZUDWZUPk
change
commit 7bf61b6272ffa97b9450e6ce8a095c19e6a6bb7f
author Claude <noreply@anthropic.com>
date
parent e1c714ae
diff --git a/lib/pro_tacts/admin/card_form.rb b/lib/pro_tacts/admin/card_form.rb
index 819ae15..0592c1c 100644
--- a/lib/pro_tacts/admin/card_form.rb
+++ b/lib/pro_tacts/admin/card_form.rb
@@ -64,9 +64,6 @@ module ProTacts
       # docs/plans/2026-10-07-switching-company-and-person.md.
       #: (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
-
         # 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
@@ -81,8 +78,8 @@ module ProTacts
           else
             person_name(contact, vcard, first, middle, last, params["organization"]&.to_s&.strip)
           end
-        vcard = vcard.replace("NICKNAME", text_lines("NICKNAME", nickname))
-        vcard = vcard.replace("NOTE", text_lines("NOTE", note))
+        vcard = edited_text(vcard, "NICKNAME", params["nickname"], own.nickname)
+        vcard = edited_text(vcard, "NOTE", params["note"], note_of(own))
         vcard = edited_values(own.phones, "phone", "TEL", vcard, params)
         vcard = edited_values(own.emails, "email", "EMAIL", vcard, params)
         edited_addresses(own, vcard, params)
@@ -96,7 +93,7 @@ module ProTacts
       #: (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_text(reading.stored, "NOTE", params["note"], note_of(reading))
         card = edited_addresses(reading, card, params)
         card.lines.filter_map {
           line = it.content
@@ -124,6 +121,33 @@ module ProTacts
         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 single-value rows' half of the surgical save, walked once
       # for the phones and once for the emails: each row names its line
       # by digest, so a row the request leaves out is a line the save
@@ -155,7 +179,7 @@ module ProTacts
             next if property.nil?
 
             submitted = edits[row.line.digest]
-            next if submitted.nil? || submitted.to_s.strip == row.value
+            next if submitted.nil? || submitted.to_s.strip == row.value.strip
 
             value = submitted.to_s.strip
             vcard = vcard.substitute(
@@ -168,7 +192,7 @@ module ProTacts
         submitted = Array(params["new_#{field}"]) #: Array[untyped]
         added = submitted.filter_map {
           value = it.to_s.strip
-          "#{name}:#{VCard.escape(value)}\r\n" unless value.empty?
+          "#{name}:#{VCard.escape(value)}" unless value.empty?
         } #: Array[String]
         vcard.insert(added)
       end
@@ -208,7 +232,7 @@ module ProTacts
       #: (Contact::Address address, untyped submitted) -> bool
       def self.address_unchanged?(address, submitted)
         Contact::ADDRESS_COMPONENTS.all? { |name|
-          submitted[name].to_s.strip == address.public_send(name).to_s
+          submitted[name].to_s.strip == address.public_send(name).to_s.strip
         }
       end
 
@@ -237,13 +261,13 @@ module ProTacts
         Contact::ADDRESS_COMPONENTS.each_with_index do |name, index|
           position = index + 1
           value = submitted[name].to_s.strip
-          current = address && address.public_send(name).to_s
+          current = address && address.public_send(name).to_s.strip
           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"
+        "#{header}#{components.join(";")}"
       end
 
       # One component off the wire: nil for blank, the integer it names
@@ -265,7 +289,7 @@ module ProTacts
       def self.text_lines(name, value)
         return [] if value.empty?
 
-        ["#{name}:#{VCard.escape(value)}\r\n"]
+        ["#{name}:#{VCard.escape(value)}"]
       end
 
       # A company's name, written where macOS writes one (exchange
@@ -280,12 +304,12 @@ module ProTacts
       # keeps its bytes.
       #: (VCard vcard, String organization) -> VCard
       def self.company_name(vcard, organization)
-        vcard = vcard.replace("N", ["N:;;;;\r\n"]) unless vcard.property("N")&.components&.all?(&:empty?)
-        vcard = vcard.replace("FN", ["FN:#{VCard.escape(organization)}\r\n"]) unless vcard.property("FN")&.text == 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\r\n"])
+        vcard.replace("X-ABShowAs", ["X-ABShowAs:COMPANY"])
       end
 
       # A person's name, into N and FN, and the flag gone: a person is
@@ -295,7 +319,8 @@ module ProTacts
       # 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)
-        vcard = vcard.replace("N", [n_line(vcard, first, middle, last)])
+        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", [])
@@ -324,7 +349,7 @@ module ProTacts
         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(";")}\r\n"])
+        vcard.replace("ORG", ["ORG:#{[VCard.escape(organization), *units].join(";")}"])
       end
 
       # FN's replacement, or the card untouched: the display name is the
@@ -343,9 +368,10 @@ module ProTacts
         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.
-        return vcard if named && !contact.company? && first == given.to_s && middle == additional.to_s && last == family.to_s
+        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))}\r\n"])
+        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
@@ -367,8 +393,12 @@ module ProTacts
       # 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
+      # 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]
@@ -377,16 +407,18 @@ module ProTacts
           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|
-          current = readings[position].to_s
-          components[position] = value == current ? components.fetch(position) : VCard.escape(value)
+          components[position] = value == currents.fetch(position) ? components.fetch(position) : VCard.escape(value)
         end
-        "N:#{components.join(";")}\r\n"
+        "N:#{components.join(";")}"
       end
 
       private_class_method :edited_values, :edited_addresses, :address_unchanged?,
-                           :address_line, :birthday_component, :text_lines, :edited_fn, :display_name,
+                           :address_line, :birthday_component, :text_lines, :edited_text, :edited_fn, :display_name,
                            :n_line, :company_name, :person_name, :edited_organization
     end
   end
diff --git a/lib/pro_tacts/admin/contacts_edit.rb b/lib/pro_tacts/admin/contacts_edit.rb
index 67b8bed..7c1995c 100644
--- a/lib/pro_tacts/admin/contacts_edit.rb
+++ b/lib/pro_tacts/admin/contacts_edit.rb
@@ -251,10 +251,9 @@ module ProTacts
                   end
                   birthday_row
                   added_rows
-                  # The first NOTE the card carries: this row edits one,
-                  # and a save writes one (CardForm.contact_card's
-                  # replace).
-                  note = @own.notes.first&.value
+                  # Every NOTE the card carries, joined: this row edits
+                  # one, and a save writes one (CardForm.note_of).
+                  note = CardForm.note_of(@own)
                   label(class: "field", data: {blank_removes: !!note}) do
                     span { "Note" }
                     textarea(name: "note", rows: 4,
diff --git a/lib/pro_tacts/admin/groups_edit.rb b/lib/pro_tacts/admin/groups_edit.rb
index 9483548..f71a53a 100644
--- a/lib/pro_tacts/admin/groups_edit.rb
+++ b/lib/pro_tacts/admin/groups_edit.rb
@@ -77,9 +77,9 @@ module ProTacts
                 end
                 @reading.addresses.each { |address| address_row(address) }
                 added_address_row
-                # The first NOTE, the contact editor's own rule: this
-                # row edits one, and a save writes one.
-                note = @reading.notes.first&.value
+                # Every NOTE, joined, the contact editor's own rule:
+                # this row edits one, and a save writes one.
+                note = CardForm.note_of(@reading)
                 label(class: "field", data: {blank_removes: !!note}) do
                   span { "Note" }
                   textarea(name: "note", rows: 4,
diff --git a/test/pro_tacts/admin/test_card_form.rb b/test/pro_tacts/admin/test_card_form.rb
new file mode 100644
index 0000000..8ea85f7
--- /dev/null
+++ b/test/pro_tacts/admin/test_card_form.rb
@@ -0,0 +1,95 @@
+require_relative "../../test_helper"
+
+require "hegel"
+
+require "pro_tacts/admin/card_form"
+require "pro_tacts/contact"
+require "pro_tacts/vcard"
+
+# The editor's save over the card it was rendered from, apart from the
+# route: test_contacts_pages.rb holds the save through a request, and
+# this the one rule a property states better than an example can.
+class CardFormTest < Minitest::Test
+  include Hegel::Syntax::Methods
+
+  # Value bytes heavy in what a save must not rewrite: escapes the
+  # writer would spell another way, separators a client left bare, and
+  # whitespace the form's fields strip.
+  PIECES = ["Ada", "Lo", " ", ",", "\\,", "\\;", "\\\\", "é"].freeze #: Array[String]
+
+  def value(tc, pieces = PIECES) = tc.draw(arrays(sampled_from(pieces), max_size: 4)).join
+
+  def components(tc, count) = Array.new(tc.draw(integers(min_value: 0, max_value: count))) { value(tc) }.join(";")
+
+  # One content line: a header in any of the spellings a client writes,
+  # the value, and perhaps a fold somewhere inside it (RFC 2426 section
+  # 2.6), terminated as the rest of the card is.
+  def line(tc, names, value, eol)
+    text = "#{tc.draw(sampled_from(names))}:#{value}"
+    at = tc.draw(optional(integers(min_value: 1, max_value: text.length)))
+    text = text.dup.insert(at, "#{eol} ") if at && at < text.length
+    "#{text}#{eol}"
+  end
+
+  # What the editor renders into each field, as the browser submits it
+  # back untouched and the route reads it (Admin::ContactsEdit,
+  # Web::Contacts): every box is the reading the form showed, the note
+  # textarea's line breaks come back CRLF (the HTML form-submission
+  # newline normalization), and the names arrive stripped.
+  def untouched(contact)
+    own = contact.own
+    family, given, additional = contact.name_components || []
+    params = {
+      "first" => given.to_s, "middle" => additional.to_s, "last" => family.to_s,
+      "nickname" => own.nickname.to_s,
+      "note" => ProTacts::Admin::CardForm.note_of(own).to_s.gsub("\n", "\r\n"),
+      "phone" => own.phones.to_h { [it.line.digest, it.value] },
+      "email" => own.emails.to_h { [it.line.digest, it.value] },
+      "address" => own.addresses.to_h { |address|
+        [address.line.digest, ProTacts::Contact::ADDRESS_COMPONENTS.to_h { [it, address.public_send(it).to_s] }]
+      },
+      "company" => contact.company? ? "1" : "0",
+    }
+    organization = ProTacts::Admin::CardForm.organization_of(contact.stored).to_s
+    params["organization"] = organization unless organization.empty?
+    params
+  end
+
+  # A save that changed nothing changes no byte, however the card spells
+  # what the form shows: parameters and groups the form never modeled,
+  # folds, LF terminators, escapes the writer spells another way,
+  # whitespace the fields strip, and a legacy card's second NOTE. The
+  # surgical save's whole promise (docs/plans/2026-09-05-web-card-editor.md),
+  # and a sync's: every rewritten byte moves the etag a client holds.
+  def test_an_untouched_save_keeps_every_byte
+    Hegel.test(test_cases: 1_000) do |tc|
+      eol = tc.draw(sampled_from(["\r\n", "\n"]))
+      lines = [
+        line(tc, ["N", "N;LANGUAGE=en", "item1.N", "n"], components(tc, 6), eol),
+        line(tc, ["FN"], "Ada Lovelace", eol),
+        *(tc.draw(booleans) ? [line(tc, ["NICKNAME", "NICKNAME;LANGUAGE=en"], value(tc), eol)] : []),
+        *Array.new(tc.draw(integers(min_value: 0, max_value: 2))) {
+          line(tc, ["NOTE", "NOTE;LANGUAGE=en"], value(tc, [*PIECES, "\\n", "\\N"]), eol)
+        },
+        *Array.new(tc.draw(integers(min_value: 0, max_value: 2))) {
+          line(tc, ["TEL", "TEL;TYPE=cell", "item2.TEL"], value(tc, [" ", "+1", "555", "0100"]), eol)
+        },
+        *Array.new(tc.draw(integers(min_value: 0, max_value: 1))) {
+          line(tc, ["EMAIL", "EMAIL;TYPE=INTERNET"], value(tc, [" ", "ada", "@", "example.com"]), eol)
+        },
+        *Array.new(tc.draw(integers(min_value: 0, max_value: 2))) {
+          line(tc, ["ADR", "ADR;TYPE=home"], components(tc, 8), eol)
+        },
+      ]
+      stored = ["BEGIN:VCARD#{eol}", "VERSION:3.0#{eol}", *lines, "UID:aiden#{eol}", "END:VCARD#{eol}"].join
+      contact = ProTacts::Contact.new(id: "aiden", stored: ProTacts::VCard.new(stored), birthday: nil, inherited: [])
+      params = untouched(contact)
+
+      saved = ProTacts::Admin::CardForm.contact_card(
+        contact, params["first"].strip, params["middle"].strip, params["last"].strip, params,
+      )
+
+      assert_equal stored, saved.to_s
+    end
+  end
+end