edited contact: a served card PUT back stores what was stored
A client re-PUTting the card it downloaded changed the stored card
whenever a group lent a NOTE: the member's own NOTE came back
re-escaped, trimmed, stripped of parameters, deleted when blank, or
CRLF on an LF card, and grew by a section each sync when it held a
line reading as a lent header. And where the member's own card held a
line byte-equal to a lent one, the stored copy was the one struck, so
the lent copy moved into its place. Each such sync logged a change
and moved the etag.
A NOTE that reads as what the contact served now restores the stored
line as it was; an edit to a section keeps the member's line wherever
its text still reads the same; taken lines leave by identity, the
lent copy rather than the first; and composed NOTE 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
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 3080471..0b99194 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -460,7 +460,8 @@ module ProTacts
# The one NOTE the served card carries, as a content line: the
# member's own text and the lent notes as sections, written
# through NoteSections and escaped the way the editors write a
- # text value. Nil where nothing is lent — a member whose groups
+ # text value, with no terminator so it takes the one of the line it
+ # replaces and an LF card stays one (VCard#replace). Nil where nothing is lent — a member whose groups
# hold no note serves its own NOTE's bytes as they lie, whatever
# they say, the editor's whole safety.
#
@@ -480,7 +481,7 @@ module ProTacts
}
member = @stored.lines.select { it.names?("NOTE") }
.filter_map { it.property&.text }.join("\n\n")
- "NOTE:#{VCard.escape(NoteSections.join(member, sections))}\r\n"
+ "NOTE:#{VCard.escape(NoteSections.join(member, sections))}"
end
# A property group to the `X-ABLabel` text anchoring it — the
diff --git a/lib/pro_tacts/edited_contact.rb b/lib/pro_tacts/edited_contact.rb
index 71c51b4..633803f 100644
--- a/lib/pro_tacts/edited_contact.rb
+++ b/lib/pro_tacts/edited_contact.rb
@@ -184,11 +184,17 @@ module ProTacts
# untouched line is struck first, and only what is left is
# attributed.
#
- # #substitute rather than #extract and #insert, which would move a
- # member's own lines of the same name to the card's end: the lines
- # this leaves keep their positions, so a submission that was the
- # served card round-trips to the bytes it was composed from and the
- # PUT can answer with a strong etag (RFC 6352 section 6.3.2.3).
+ # The taken lines leave by identity rather than through #extract
+ # and #insert, which would move a member's own lines of the same
+ # name to the card's end, or #substitute, which takes the first
+ # copy of identical bytes — the member's own, where its card
+ # carries what a group lends, since composition puts the lent copy
+ # last. Struck by identity, the lines this leaves keep their
+ # positions, so a submission that was the served card round-trips
+ # to the bytes it was composed from and the PUT can answer with a
+ # strong etag (RFC 6352 section 6.3.2.3). The pool is the
+ # submission's own lines (#unaccounted_lines), so each taken line
+ # is one of them.
#: (VCard vcard) -> [VCard, Array[GroupEdit]]
def subtract_inherited(vcard)
lent = @before&.inherited || [] #: Array[Contact::Inherited]
@@ -229,7 +235,7 @@ module ProTacts
report_ambiguous_inherited_lines(ambiguous)
report_unshareable_lines(unshareable)
- struck = taken.reduce(vcard) { |rest, line| rest.substitute(line.digest, []) }
+ struck = VCard.new(vcard.lines.reject { |line| taken.any? { it.equal?(line) } }.map(&:verbatim).join)
end
card, note_edits = subtract_notes(struck, note_rows)
@@ -276,6 +282,9 @@ module ProTacts
return [members_own(vcard, notes), []]
end
value = values.join("\n\n")
+ own = stored_before&.lines&.select { it.names?("NOTE") } || [] #: Array[VCard::Parser::Line]
+ return [vcard.replace("NOTE", own.map { it.verbatim.chomp }), []] if own.length <= 1 && value == served_note
+
split = NoteSections.split(value, labels)
if split.sections.empty?
# The two-line shape's truncation (see the method comment).
@@ -316,12 +325,35 @@ module ProTacts
)
end
}
- if split.member.empty?
- member = [] #: Array[String]
- else
- member = ["NOTE:#{VCard.escape(split.member)}\r\n"]
- end
- [vcard.replace("NOTE", member), edits]
+ [vcard.replace("NOTE", member_note(split.member, own, labels)), edits]
+ end
+
+ # What a no-op PUT sent back: the NOTE text the contact before this
+ # write served, which is what composition made of the member's own
+ # note and its groups' (Contact#composed_note). Read off the
+ # composed card rather than composed again, so the two cannot
+ # drift. Nil for a contact being created, which served nothing.
+ #: () -> String?
+ def served_note
+ @before&.vcard&.lines&.find { it.names?("NOTE") }&.property&.text
+ end
+
+ # The member's half of a split NOTE as the line to store. Where it
+ # still reads as the stored note did, the stored line stays byte
+ # for byte — its parameters, its escapes, the line breaks the
+ # split trims off — since rewriting it would change bytes nobody
+ # edited and move the etag on a sync that changed nothing else.
+ # Otherwise the text is escaped the way the editors write it, and
+ # empty text is no line. No terminator, so the line takes its
+ # neighbour's and an LF card stays one.
+ #: (String text, Array[VCard::Parser::Line] own, Array[String] labels) -> Array[String]
+ def member_note(text, own, labels)
+ line = own.fetch(0) if own.length == 1
+ stored = line&.property&.text
+ return [line.verbatim.chomp] if line && stored && NoteSections.split(stored, labels).member == text
+ return [] if text.empty?
+
+ ["NOTE:#{VCard.escape(text)}"]
end
# The submission's NOTE lines kept as the member's own: their
@@ -352,7 +384,7 @@ module ProTacts
#: (VCard vcard, Array[VCard::Parser::Line] notes) -> VCard
def joined_notes(vcard, notes)
texts = notes.map { it.property&.text || it.verbatim.chomp }
- vcard.replace("NOTE", ["NOTE:#{VCard.escape(texts.join("\n\n"))}\r\n"])
+ vcard.replace("NOTE", ["NOTE:#{VCard.escape(texts.join("\n\n"))}"])
end
# What a lent line that came back changed asks of its group: the
diff --git a/test/pro_tacts/test_edited_contact.rb b/test/pro_tacts/test_edited_contact.rb
index df3061f..847a309 100644
--- a/test/pro_tacts/test_edited_contact.rb
+++ b/test/pro_tacts/test_edited_contact.rb
@@ -1,5 +1,7 @@
require_relative "../test_helper"
+require "hegel"
+
require "pro_tacts/birthday"
require "pro_tacts/contact"
require "pro_tacts/edited_contact"
@@ -14,6 +16,7 @@ require "pro_tacts/vcard"
class EditedContactTest < Minitest::Test
include Sentry::TestHelper
include SentryMessages
+ include Hegel::Syntax::Methods
AIDEN = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Aiden\r\nUID:aiden\r\nEND:VCARD\r\n"
APRIL_12 = ProTacts::Birthday.new(year: 1985, month: 4, day: 12)
@@ -312,4 +315,68 @@ class EditedContactTest < Minitest::Test
decompose(served, before: before(stored, inherited: [*LENT, *LENT_NOTE])),
)
end
+
+ ## The round trip
+
+ # Raw NOTE values heavy in what the round trip has to carry back:
+ # escapes and the separators a client may leave bare, blank lines and
+ # trailing line breaks the section split trims, and a line that reads
+ # as a lent section's header.
+ NOTE_PIECES = [
+ "a", "b", " ", ",", "\\,", ";", "\\;", "\\\\", "\\n", "\\N",
+ "\\nShared · Neighbours:\\n", "Shared · Dog walkers:",
+ ].freeze #: Array[String]
+
+ # A stored card for the round trips: the member's lines in any order
+ # and either terminator, its own NOTE in any spelling the pieces
+ # make, and its own ADR possibly byte-equal to the one a group lends.
+ def stored_card(tc, pieces)
+ note = tc.draw(optional(tuples(
+ sampled_from(["NOTE", "NOTE;LANGUAGE=en", "note"]),
+ arrays(sampled_from(pieces), max_size: 6).map(&:join),
+ ).map { |name, value| "#{name}:#{value}" }))
+ own = tc.draw(arrays(sampled_from([OWN_ADDRESS, HOUSEHOLD_ADDRESS, "TEL:+1 555 0100"]), max_size: 3))
+ lines = [*own, *note].shuffle(random: Random.new(tc.draw(integers(min_value: 0, max_value: 1_000))))
+ eol = tc.draw(sampled_from(["\r\n", "\n"]))
+ ["BEGIN:VCARD", "VERSION:3.0", "FN:Aiden", *lines, "UID:aiden", "END:VCARD"].map { "#{it}#{eol}" }.join
+ end
+
+ # Whatever the member's card holds and whatever its groups lend, the
+ # card Contact#vcard composes, PUT back untouched, stores the bytes it
+ # was composed from and asks nothing of any group: the no-op a
+ # syncing client makes on every pass, which must not move the etag or
+ # log a change (RFC 6352 section 6.3.2.3).
+ def test_a_served_card_put_back_stores_what_was_stored
+ lendable = [*LENT, *LENT_NOTE,
+ ProTacts::Contact::Inherited.new(group: DOGWALKERS, position: 2, line: "NOTE:Sunday mornings.")]
+ Hegel.test(test_cases: 1_000) do |tc|
+ stored = stored_card(tc, NOTE_PIECES)
+ inherited = tc.draw(sets(sampled_from(lendable), max_size: 3)).to_a.sort_by(&:position)
+ birthday = tc.draw(optional(just(APRIL_12)))
+ contact = before(stored, birthday:, inherited:)
+
+ assert_equal [stored, birthday, []], decompose(contact.vcard.to_s, before: contact)
+ assert_empty sentry_messages
+ end
+ end
+
+ # An edit of one group's section asks that group for the new text
+ # and leaves the member's own NOTE as it was stored, byte for byte:
+ # nobody edited it, so rewriting it would change what the member
+ # holds on a write about the group. Member text here holds no line
+ # that reads as a header, which would be the mangle instead.
+ def test_an_edited_section_leaves_the_members_note_as_stored
+ pieces = NOTE_PIECES.reject { it.include?("Shared") }
+ Hegel.test(test_cases: 1_000) do |tc|
+ stored = stored_card(tc, pieces)
+ text = tc.draw(from_regex("[a-z ,;]{0,12}[a-z]", fullmatch: true))
+ tc.assume(text != NOTE_TEXT)
+ lent = [*LENT_NOTE, *tc.draw(optional(just(LENT.fetch(0))))]
+ contact = before(stored, inherited: lent)
+ edited = ProTacts::Contact::Inherited.new(group: NEIGHBOURS, position: 1, line: "NOTE:#{ProTacts::VCard.escape(text)}")
+ submitted = before(stored, inherited: [edited, *lent.drop(1)]).vcard.to_s
+
+ assert_equal [stored, nil, [neighbour_edit(edited.line)]], decompose(submitted, before: contact)
+ end
+ end
end