vcard: replace a property's lines in place
extract plus insert would drag an edited property to the card's
end; replace keeps it at its own position, which is what a surgical
edit over the stored bytes needs.
Assisted-by: GLM-5.3 via pi
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index da4d9ec..68dbdc6 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -50,10 +50,22 @@ module ProTacts
# Splits a structured value's ";"-delimited components (RFC 2426
# section 3.2.1, e.g. ADR and N) without breaking on an escaped
- # "\;", and unescapes each component in the same pass.
+ # "\;" — the writer's half of the split, over the still-escaped
+ # value: a splice replaces some components and joins the rest back
+ # byte for byte, where unescape-then-re-escape is not byte-stable
+ # (#unescape leaves an unrecognized escape like "\x" alone, and
+ # #escape would double its backslash).
+ #: (String value) -> Array[String]
+ def self.split_raw_components(value)
+ value.split(/(?<!\\);/, -1)
+ end
+
+ # split_raw_components with each component unescaped — the
+ # reader's half, for a caller asking what the components say
+ # rather than splicing them.
#: (String value) -> Array[String]
def self.split_components(value)
- value.split(/(?<!\\);/, -1).map { unescape(it) }
+ split_raw_components(value).map { unescape(it) }
end
# Folds a logical line into physical lines of at most LINE_LIMIT
@@ -159,6 +171,34 @@ module ProTacts
@lines = Parser.lines(@bytes)
end
+ # The lines naming `name` swapped for the given ones, at the first
+ # match's position — the operation a property a card carries one of
+ # (N, FN, NICKNAME, NOTE) is edited through, which must not drag
+ # the property to the card's end the way #extract plus #insert
+ # would. Empty lines remove the property: blank is absent, the
+ # reader's own rule (Contact#text_of) in the other direction. A
+ # card lacking the property is #insert's case, the lines landing
+ # before END:VCARD. Every other line keeps its own bytes, which is
+ # the editor's whole safety: a line a save never mentioned cannot
+ # be lost by an operation that only touches the lines it names.
+ #: (String name, Array[String] lines) -> VCard
+ def replace(name, lines)
+ all = self.lines
+ first = all.index { it.names?(name) }
+ return insert(lines) if first.nil?
+
+ # The replaced line's own terminator, so a card written in one
+ # line-break convention stays single-convention; a terminated
+ # replacement line keeps its own, folds and all.
+ terminator = terminator_of(all.fetch(first).verbatim)
+ replacements = lines.map { it.end_with?("\n") ? it : it + terminator }
+ kept = all.reject { it.names?(name) }.map(&:verbatim)
+ # `first` still names the splice point: the lines before it are
+ # untouched, and every line it counted past was a named one.
+ kept.insert(first, *replacements)
+ VCard.new(kept.join)
+ end
+
# Lines into the card immediately before END:VCARD. A line with no
# terminator of its own takes the END line's, so a card stays
# single-convention; a terminated line keeps its own, folds and
diff --git a/test/pro_tacts/test_vcard.rb b/test/pro_tacts/test_vcard.rb
index 278ea0e..36f5c54 100644
--- a/test/pro_tacts/test_vcard.rb
+++ b/test/pro_tacts/test_vcard.rb
@@ -72,6 +72,17 @@ class VCardTest < Minitest::Test
assert_equal ["Smith; Jr.", "John"], ProTacts::VCard.split_components("Smith\\; Jr.;John")
end
+ # The writer's splice: the same split with the escaping left alone,
+ # so components rejoin byte for byte — unescape-then-re-escape is
+ # not byte-stable (unescape leaves an unrecognized escape like "\\x"
+ # alone, and escape would double its backslash).
+ def test_split_raw_components_leaves_each_component_escaped
+ assert_equal ["Smith\\; Jr.", "John", "a\\xb"],
+ ProTacts::VCard.split_raw_components("Smith\\; Jr.;John;a\\xb")
+ assert_equal "Smith\\; Jr.;John;a\\xb",
+ ProTacts::VCard.split_raw_components("Smith\\; Jr.;John;a\\xb").join(";")
+ end
+
## Property tests
def test_unfolding_reverses_folding
diff --git a/test/pro_tacts/vcard/test_lines.rb b/test/pro_tacts/vcard/test_lines.rb
index f98e5e1..3f4491d 100644
--- a/test/pro_tacts/vcard/test_lines.rb
+++ b/test/pro_tacts/vcard/test_lines.rb
@@ -143,6 +143,60 @@ class VCardLinesTest < Minitest::Test
refute VCARD.new(CARD).lines.any? { it.broke_assumption? }
end
+ ## replace
+
+ # The cardinality-1 edit: the named lines swap for the new one at
+ # the first match's position — not dragged to the card's end — and
+ # every other line keeps its own bytes, folds and all.
+ def test_replace_swaps_a_property_in_place
+ card = CARD.sub("FN:Ada\r\n", "FN:Ada\r\nNICKNAME:Red\r\n")
+
+ assert_equal card.sub("NICKNAME:Red\r\n", "NICKNAME:Crimson\r\n"),
+ VCARD.new(card).replace("NICKNAME", ["NICKNAME:Crimson\r\n"]).to_s
+
+ folded = VCARD.new(CARD.sub("UID:ada\r\n", "UID:ada\r\nTEL:+1-555-\r\n 0100\r\n"))
+
+ assert_includes folded.replace("FN", ["FN:Grace\r\n"]).to_s, "TEL:+1-555-\r\n 0100\r\n"
+ end
+
+ # Empty lines remove the property — blank equals absent, the
+ # reader's rule (see Contact#text_of) in the other direction.
+ def test_replace_with_nothing_removes_the_property
+ card = CARD.sub("FN:Ada\r\n", "FN:Ada\r\nNICKNAME:Red\r\n")
+
+ assert_equal CARD, VCARD.new(card).replace("NICKNAME", []).to_s
+ end
+
+ # A card lacking the property is insert's case: the lines land
+ # before END:VCARD, and nothing at all happens for an empty ask.
+ def test_replace_inserts_when_the_card_lacks_the_property
+ assert_equal CARD.sub("END:VCARD\r\n", "NICKNAME:Red\r\nEND:VCARD\r\n"),
+ VCARD.new(CARD).replace("NICKNAME", ["NICKNAME:Red\r\n"]).to_s
+ assert_equal CARD, VCARD.new(CARD).replace("NICKNAME", []).to_s
+ end
+
+ # A card that carries the property twice is cardinality-broken
+ # data, and the one replacement line this writes is the repair:
+ # all the named lines go, not one of them.
+ def test_replace_takes_every_line_naming_the_property
+ twice = CARD.sub("FN:Ada\r\n", "FN:Ada\r\nNICKNAME:Red\r\nNICKNAME:Ruby\r\n")
+
+ assert_equal CARD.sub("FN:Ada\r\n", "FN:Ada\r\nNICKNAME:Crimson\r\n"),
+ VCARD.new(twice).replace("NICKNAME", ["NICKNAME:Crimson\r\n"]).to_s
+ end
+
+ # A bare replacement line takes the replaced line's own terminator,
+ # so a card the wire writes in LF stays single-convention; a
+ # terminated one keeps its own.
+ def test_replace_matches_the_cards_line_break_convention
+ lf = "BEGIN:VCARD\nFN:Ada\nNICKNAME:Red\nEND:VCARD\n"
+
+ assert_equal "BEGIN:VCARD\nFN:Ada\nNICKNAME:Crimson\nEND:VCARD\n",
+ VCARD.new(lf).replace("NICKNAME", ["NICKNAME:Crimson"]).to_s
+ assert_equal "BEGIN:VCARD\nFN:Ada\nNICKNAME:Crimson\r\nEND:VCARD\n",
+ VCARD.new(lf).replace("NICKNAME", ["NICKNAME:Crimson\r\n"]).to_s
+ end
+
## insert
def test_insert_takes_the_end_lines_terminator_for_a_bare_line