vcard: substitute a line addressed by its digest
The addressed half of the web card editor's phones stage
(docs/plans/2026-09-05-web-card-editor.md).
Assisted-by: GLM-5.3 via pi
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index 68dbdc6..b21978f 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -68,6 +68,27 @@ module ProTacts
split_raw_components(value).map { unescape(it) }
end
+ # A property's content line up to its colon — group, name,
+ # parameters — re-rendered, for an edit that swaps the value and
+ # keeps the header: a phone's line (say) carries parameters no
+ # form models, and macOS writes three TYPE parameters on one TEL
+ # (test/fixtures/cards/emoji.vcf), so a save that rebuilt the
+ # header from the form's fields would drop what the form never
+ # showed. The param values render as the grammar's own two
+ # spellings allow: bare when every char is PTEXT, quoted
+ # otherwise — the same line the parser read, either way. A folded
+ # header re-renders unfolded, a normalization a save applies only
+ # to the line it was asked to change.
+ #: (Parser::Property property) -> String
+ def self.header_of(property)
+ prefix = property.group ? "#{property.group}." : ""
+ parameters = property.parameters.map do |name, value|
+ bare = /\A#{Parser::PTEXT}\z/.match?(value)
+ ";#{name}=#{bare ? value : "\"#{value}\""}"
+ end
+ "#{prefix}#{property.name}#{parameters.join}:"
+ end
+
# Folds a logical line into physical lines of at most LINE_LIMIT
# octets, each continuation starting with a single space (RFC 2426
# section 2.6). The walk is character-wise so a multibyte character
@@ -199,6 +220,32 @@ module ProTacts
VCard.new(kept.join)
end
+ # The one-line edit, for a property a card carries many of (TEL,
+ # EMAIL, ADR): the line whose verbatim bytes hash to `digest`
+ # swaps for the given ones, or is removed when they are empty.
+ # Which line a digest names was settled by the caller that watched
+ # the card render; a digest matching no line here is that
+ # caller's anomaly and raises (KeyError) rather than guessing —
+ # the snapshot guard makes a miss news, not an ordinary case. A
+ # card carrying the same line twice is addressed as one row by
+ # both copies (they hash alike), and this substitutes the first;
+ # the digests cannot tell identical bytes apart. A replacement
+ # line with no terminator of its own takes the substituted line's,
+ # #replace's own convention rule.
+ #: (String digest, Array[String] lines) -> VCard
+ def substitute(digest, lines)
+ all = self.lines
+ index = all.index { it.digest == digest }
+ raise KeyError, "no line of this card hashes to #{digest}" if index.nil?
+
+ terminator = terminator_of(all.fetch(index).verbatim)
+ replacements = lines.map { it.end_with?("\n") ? it : it + terminator }
+ kept = all.map(&:verbatim)
+ kept.delete_at(index)
+ kept.insert(index, *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/lib/pro_tacts/vcard/parser.rb b/lib/pro_tacts/vcard/parser.rb
index bb1554f..36cafa1 100644
--- a/lib/pro_tacts/vcard/parser.rb
+++ b/lib/pro_tacts/vcard/parser.rb
@@ -1,4 +1,5 @@
+require "digest"
require "strscan"
require "pro_tacts/vcard"
@@ -131,6 +132,16 @@ module ProTacts
# taxonomy to tell the two apart.
#: () -> bool
def unreadable? = !error.nil? && !broke_assumption?
+
+ # The line's address for a save that names it: the SHA-256 of
+ # the verbatim bytes, folds and terminator included — constant
+ # size, pure ASCII, and no card content hidden in the page
+ # (docs/plans/2026-09-05-web-card-editor.md). Stable across
+ # the render-to-save cycle the snapshot guard certifies, and
+ # blind otherwise: identical bytes digest alike, which is the
+ # one pair of lines no address can tell apart.
+ #: () -> String
+ def digest = Digest::SHA256.hexdigest(verbatim)
end
# The content line, RFC 2426 section 4:
diff --git a/test/pro_tacts/test_vcard.rb b/test/pro_tacts/test_vcard.rb
index 36f5c54..6b6a20e 100644
--- a/test/pro_tacts/test_vcard.rb
+++ b/test/pro_tacts/test_vcard.rb
@@ -83,6 +83,41 @@ class VCardTest < Minitest::Test
ProTacts::VCard.split_raw_components("Smith\\; Jr.;John;a\\xb").join(";")
end
+ # The writer's line header, for a value-only edit: group, name,
+ # and every parameter re-render as they read, so a save swaps the
+ # value and keeps what the form never modeled. The macOS triple
+ # TYPE is the case the editor exists for; an unfolded, unquoted
+ # line re-renders as its own bytes.
+ def test_header_of_renders_group_name_and_every_parameter
+ card = ProTacts::VCard.new(
+ "BEGIN:VCARD\r\n" \
+ "TEL;type=CELL;type=VOICE;type=pref:+1-555-0100\r\n" \
+ "item1.EMAIL;TYPE=INTERNET:ada@example.com\r\n" \
+ "END:VCARD\r\n"
+ )
+ tel = card.properties.find { it.name.casecmp?("TEL") }
+ email = card.properties.find { it.name.casecmp?("EMAIL") }
+
+ assert_equal "TEL;type=CELL;type=VOICE;type=pref:", ProTacts::VCard.header_of(tel)
+ assert_equal "item1.EMAIL;TYPE=INTERNET:", ProTacts::VCard.header_of(email)
+
+ tel_line = card.lines.find { it.names?("TEL") }
+ assert_equal tel_line.verbatim, "#{ProTacts::VCard.header_of(tel)}#{tel.value}\r\n"
+ end
+
+ # A parameter value PTEXT cannot hold — the grammar's quote-worthy
+ # chars — renders in the grammar's other spelling, quoted; a value
+ # that fits renders bare, either way the line the parser read.
+ def test_header_of_quotes_what_ptext_cannot_hold
+ bare = ProTacts::VCard::Parser::Property.new(group: nil, name: "TEL",
+ parameters: [["TYPE", "cell"]], value: "+1")
+ quoted = ProTacts::VCard::Parser::Property.new(group: nil, name: "TEL",
+ parameters: [["X-FOO", "a:b,c"]], value: "+1")
+
+ assert_equal "TEL;TYPE=cell:", ProTacts::VCard.header_of(bare)
+ assert_equal 'TEL;X-FOO="a:b,c":', ProTacts::VCard.header_of(quoted)
+ 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 3f4491d..3150ddf 100644
--- a/test/pro_tacts/vcard/test_lines.rb
+++ b/test/pro_tacts/vcard/test_lines.rb
@@ -1,5 +1,7 @@
require_relative "../../test_helper"
+require "digest"
+
require "pro_tacts/vcard"
require "pro_tacts/vcard/parser"
@@ -104,6 +106,18 @@ class VCardLinesTest < Minitest::Test
assert_equal "BDAY:1985-04-\r\n 12\r\n", bday&.verbatim
end
+ # The line's address for a save: the SHA-256 of the verbatim bytes,
+ # so identical lines digest alike — the one pair no address tells
+ # apart — and any other difference is a different address.
+ def test_a_lines_digest_addresses_its_bytes
+ born = CARD.sub("UID:ada\r\n", "UID:ada\r\nBDAY:1985-04-12\r\n")
+ bday = VCARD.new(born).lines.find { it.names?("BDAY") }
+
+ assert_equal Digest::SHA256.hexdigest("BDAY:1985-04-12\r\n"), bday.digest
+ assert_equal bday.digest, VCARD.new(born + "BDAY:1985-04-12\r\n").lines.last.digest
+ refute_equal bday.digest, VCARD.new(born.sub("1985", "1986")).lines.find { it.names?("BDAY") }.digest
+ end
+
# A group prefix counts as naming the property; anything else does
# not, and a line that will not parse is a fact about the line,
# carried as data rather than raised.
@@ -197,6 +211,69 @@ class VCardLinesTest < Minitest::Test
VCARD.new(lf).replace("NICKNAME", ["NICKNAME:Crimson\r\n"]).to_s
end
+ ## substitute
+
+ # The one-line edit: the line whose bytes digest to the address
+ # swaps in place, and every other line keeps its own bytes — the
+ # fold on the untouched neighbor is the byte-identity claim at its
+ # sharpest.
+ def test_substitute_swaps_the_addressed_line_and_no_other
+ card = CARD.sub("FN:Ada\r\n", "FN:Ada\r\nTEL:+1-555-0100\r\nADR:;;12 Way\r\n")
+ digest = VCARD.new(card).lines.find { it.names?("TEL") }.digest
+
+ assert_equal card.sub("TEL:+1-555-0100\r\n", "TEL:+1-555-0199\r\n"),
+ VCARD.new(card).substitute(digest, ["TEL:+1-555-0199\r\n"]).to_s
+
+ folded = CARD.sub("FN:Ada\r\n", "FN:Ada\r\nTEL:+1-555-0100\r\nADR:;;12 \r\n Way\r\n")
+
+ assert_includes VCARD.new(folded).substitute(digest, ["TEL:+1-555-0199\r\n"]).to_s,
+ "ADR:;;12 \r\n Way\r\n"
+ end
+
+ # Empty lines remove the addressed line — the blank-equals-absent
+ # rule for a row the form names by digest.
+ def test_substitute_with_nothing_removes_the_addressed_line
+ card = CARD.sub("FN:Ada\r\n", "FN:Ada\r\nTEL:+1-555-0100\r\n")
+ digest = VCARD.new(card).lines.find { it.names?("TEL") }.digest
+
+ assert_equal CARD, VCARD.new(card).substitute(digest, []).to_s
+ end
+
+ # A digest matching no line is the caller's anomaly — the snapshot
+ # guard means a miss is news, not an ordinary case — and refuses
+ # loudly rather than guessing.
+ def test_substitute_of_a_digest_no_line_hashes_to_raises
+ card = CARD.sub("FN:Ada\r\n", "FN:Ada\r\nTEL:+1-555-0100\r\n")
+
+ error = assert_raises(KeyError) { VCARD.new(card).substitute("0" * 64, []) }
+
+ assert_match "no line", error.message
+ end
+
+ # A bare replacement line takes the substituted line's own
+ # terminator, #replace's convention rule; a terminated one keeps
+ # its own.
+ def test_substitute_matches_the_cards_line_break_convention
+ lf = "BEGIN:VCARD\nFN:Ada\nTEL:+1-555-0100\nEND:VCARD\n"
+ digest = VCARD.new(lf).lines.find { it.names?("TEL") }.digest
+
+ assert_equal "BEGIN:VCARD\nFN:Ada\nTEL:+1-555-0199\nEND:VCARD\n",
+ VCARD.new(lf).substitute(digest, ["TEL:+1-555-0199"]).to_s
+ assert_equal "BEGIN:VCARD\nFN:Ada\nTEL:+1-555-0199\r\nEND:VCARD\n",
+ VCARD.new(lf).substitute(digest, ["TEL:+1-555-0199\r\n"]).to_s
+ end
+
+ # Identical bytes digest alike, which is the one pair of lines no
+ # address can tell apart: the first is substituted, the second
+ # keeps its bytes.
+ def test_substitute_addresses_the_first_of_identical_lines
+ card = CARD.sub("FN:Ada\r\n", "FN:Ada\r\nTEL:+1-555-0100\r\nTEL:+1-555-0100\r\n")
+ digest = VCARD.new(card).lines.find { it.names?("TEL") }.digest
+
+ assert_equal CARD.sub("FN:Ada\r\n", "FN:Ada\r\nTEL:+1-555-0199\r\nTEL:+1-555-0100\r\n"),
+ VCARD.new(card).substitute(digest, ["TEL:+1-555-0199\r\n"]).to_s
+ end
+
## insert
def test_insert_takes_the_end_lines_terminator_for_a_bare_line