store: match a lent line by its value, not its bytes
macOS respells each card that it touches in place,
so bytes read the group's own address as an edit to chase.
Compare an ADR by components, a NOTE by its text —
the group keeps its line, and the member's own card is unvexed.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index 9c54552..e93af1e 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -91,6 +91,13 @@ module ProTacts
# to spin.
GROUP_ID_ATTEMPTS = 8 #: Integer
+ # The property names whose value is structured rather than free
+ # text (RFC 2426 section 3.2.1), among the two a group may lend:
+ # ADR is components, NOTE is text (db/migrations/004_groups.rb).
+ # Which reading applies is the caller's to know — the parser holds
+ # no value types — and this is the one caller that compares values.
+ STRUCTURED_VALUES = %w[ADR].freeze #: Array[String]
+
# Sequel's migrations, run on open. They ship with the code rather
# than with a deployment, so the path is relative to this file.
# `__dir__` is nil only for code with no file behind it, which a
@@ -600,9 +607,9 @@ module ProTacts
# to the group would reach nobody.
#
# Each lent line is classified against the submission, and only one
- # of the four shapes moves a byte. The line coming back as the same
- # bytes is the group's, untouched, and it goes. One line of that
- # name left over is an edit to the shared value and none is a
+ # of the four shapes moves a byte. The line coming back with the
+ # same value is the group's, untouched, and it goes. One line of
+ # that name left over is an edit to the shared value and none is a
# deletion of it: both stay in the card as they arrived, because
# propagating either is the next task's (the plan's "Edits
# propagate to the group"), and storing the edit is what keeps it
@@ -623,12 +630,14 @@ module ProTacts
ambiguous = 0
inherited.each do |lent|
+ lent_line = parsed_line(lent.line)
candidates = unaccounted.select { it.names?(property_name(lent.line)) }
- # Blind to identical bytes the way the editor's digests are
- # (VCard::Parser::Line#digest): where a member's own card
- # spells a line exactly as its group does, which copy this
- # takes is undecidable and the bytes make it not matter.
- match = candidates.find { it.verbatim.chomp == lent.line.chomp }
+ # Blind to equal values the way the editor's digests are blind
+ # to identical bytes (VCard::Parser::Line#digest): where a
+ # member's own card carries the value its group lends, which
+ # copy this takes is undecidable and the values make it not
+ # matter.
+ match = candidates.find { same_value?(it, lent_line) }
if match
unaccounted.delete_at(
unaccounted.index(match) #: Integer
@@ -643,6 +652,51 @@ module ProTacts
untouched.reduce(vcard) { |rest, line| rest.substitute(line.digest, []) }
end
+ # A lent line as the parser reads it: one logical line, the group
+ # schema admitting no other shape (db/migrations/004_groups.rb).
+ #: (String line) -> VCard::Parser::Line
+ def parsed_line(line)
+ VCard.new(line).lines.fetch(0)
+ end
+
+ # Whether a submitted line says what the group lends, which is a
+ # question about the values and not about the bytes: macOS
+ # re-serializes every card it touches, so `ADR;TYPE=home` comes
+ # back `ADR;type=HOME;type=pref` on an address nobody edited, and a
+ # parameter the client does not model is dropped from a NOTE
+ # (docs/macos-contacts.md, "The client rewrites every card it
+ # touches"). Comparing bytes reads every such line as an edit.
+ #
+ # So the parameters are not compared at all — the client rewrites
+ # them, and invents them where the server sent none (the same
+ # doc's annotation probe answered a bare `ADR` with
+ # `ADR;type=WORK`). The cost is that a submission changing only a
+ # lent line's TYPE reads as untouched and is dropped, which is the
+ # trade this evidence leaves: no parameter a client returns can be
+ # told from one it made up.
+ #
+ # A line that will not read has no value to compare and falls back
+ # to its bytes, which still recognize the line nobody touched.
+ #: (VCard::Parser::Line line, VCard::Parser::Line lent) -> bool
+ def same_value?(line, lent)
+ value = value_of(lent)
+ return line.verbatim.chomp == lent.verbatim.chomp if value.nil?
+
+ value_of(line) == value
+ end
+
+ # What a line says, as the reading its property's value type calls
+ # for: an ADR compares component by component (RFC 2426 section
+ # 3.2.1) and a NOTE as its unescaped text (section 2.4.2). Nil for
+ # a line that would not read, which has no value at all.
+ #: (VCard::Parser::Line line) -> Array[String]?
+ def value_of(line)
+ property = line.property
+ return nil if property.nil?
+
+ STRUCTURED_VALUES.include?(property.name.upcase) ? property.components : [property.text]
+ end
+
# The submission's lines that the member's own stored card does not
# already explain — one struck per stored line of the same bytes,
# so a card that stores one of something and submits two leaves one
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 9924594..0b44cc5 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -1150,6 +1150,14 @@ class StoreTest < Minitest::Test
# The group's address as a client sends it back changed — one digit,
# so nothing but the value moved.
EDITED_ADDRESS = HOUSEHOLD_ADDRESS.sub("7 Calculus", "8 Calculus") #: String
+ # The group's lines as macOS returns them on a card whose address and
+ # note nobody edited: the parameter name lowercased, its value
+ # uppercased, `pref` filled in, an unmodeled parameter dropped, and
+ # every value byte-identical (docs/macos-contacts.md, "The client
+ # rewrites every card it touches" and "An annotation survives only on
+ # its own line").
+ RESERIALIZED_ADDRESS = HOUSEHOLD_ADDRESS.sub("ADR;TYPE=home:", "ADR;type=HOME;type=pref:") #: String
+ TAGGED_NOTE = HOUSEHOLD_NOTE.sub("NOTE:", "NOTE;LANGUAGE=en:") #: String
# The round trip the whole composition rests on: a member PUTs back
# what it downloaded, and what is stored is the card it was composed
@@ -1221,6 +1229,56 @@ class StoreTest < Minitest::Test
end
end
+ # The round trip as a real client makes it: macOS re-serializes every
+ # line of every card it touches, so the group's address comes back
+ # spelled its way on an address nobody edited. Matching bytes would
+ # call that an edit and materialize the group's line into the
+ # member's own card, which is what the composition exists to prevent.
+ def test_a_reserialized_lent_line_is_still_the_groups
+ with_store({"aiden" => AIDEN}) do |store|
+ add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+ served = store.contact("aiden").vcard.to_s
+
+ store.put("aiden", vcard(served.sub(HOUSEHOLD_ADDRESS, RESERIALIZED_ADDRESS)))
+
+ assert_equal AIDEN, card_row(store, "aiden").fetch(:vcard)
+ assert_equal served, store.contact("aiden").vcard.to_s
+ assert_empty sentry_messages
+ end
+ end
+
+ # The same for a note, whose re-serialization is a parameter the
+ # client does not model going missing.
+ def test_a_lent_note_stripped_of_its_parameter_is_still_the_groups
+ with_store({"aiden" => AIDEN}) do |store|
+ add_group(store, members: ["aiden"], lines: [TAGGED_NOTE])
+ served = store.contact("aiden").vcard.to_s
+
+ store.put("aiden", vcard(served.sub(TAGGED_NOTE, HOUSEHOLD_NOTE)))
+
+ assert_equal AIDEN, card_row(store, "aiden").fetch(:vcard)
+ assert_equal served, store.contact("aiden").vcard.to_s
+ assert_empty sentry_messages
+ end
+ end
+
+ # An edit still reads as one through the re-serialization it arrives
+ # wrapped in: the value moved, and only the value is compared.
+ def test_an_edit_survives_the_reserialization_it_arrives_in
+ edited = RESERIALIZED_ADDRESS.sub("7 Calculus", "8 Calculus")
+
+ with_store({"aiden" => AIDEN}) do |store|
+ add_group(store, members: ["aiden"], lines: [HOUSEHOLD_ADDRESS])
+ served = store.contact("aiden").vcard.to_s
+
+ store.put("aiden", vcard(served.sub(HOUSEHOLD_ADDRESS, edited)))
+
+ assert_includes card_row(store, "aiden").fetch(:vcard), edited
+ assert_includes store.contact("aiden").vcard.to_s, HOUSEHOLD_ADDRESS
+ assert_empty sentry_messages
+ end
+ end
+
# A deletion is detected and takes nothing with it: the group still
# lends the line, so the next read composes it back in. Whether a
# delete should reach the group at all is the plan's open question,