contact: give phones the line they were read from
Assisted-by: GLM-5.3 via pi
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 0c9de38..813efbd 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -61,10 +61,16 @@ module ProTacts
# ADR's seven components (section 3.2.1: post office box, extended
# address, street, locality, region, postal code, country — nil
# where the card left the position blank or stopped short of it).
+ # Phone also carries `line`, the parsed line its value was read
+ # from: the address a save names that phone's row by
+ # (docs/plans/2026-09-05-web-card-editor.md) — the accessors fold
+ # lines into typed values, and without provenance no form could
+ # say "edit this phone." Email and Address gain their own with
+ # their stage.
# Data classes, whose members the inline syntax cannot read; the
# signatures live in sig/pro_tacts/contact.rbs.
# @rbs skip
- Phone = Data.define(:value, :type)
+ Phone = Data.define(:value, :type, :line)
# @rbs skip
Email = Data.define(:value, :type)
# @rbs skip
@@ -240,9 +246,12 @@ module ProTacts
#: () -> Array[Phone]
def phones
- of_name("TEL").filter_map do |property|
+ of_line("TEL").filter_map do |line|
+ property = line.property
+ next if property.nil?
+
value = text_of(property)
- Phone.new(value:, type: type_of(property)) if value
+ Phone.new(value:, type: type_of(property), line:) if value
end
end
@@ -272,6 +281,16 @@ module ProTacts
properties.select { it.name.casecmp?(name) }
end
+ # The lines naming `name`, parsed beside their bytes — the walk the
+ # provenance-carrying accessors read from, so a row can carry the
+ # line it was read from as its address. A line that would not
+ # read is a row no form can address and no accessor reads; it
+ # stays enumerable in VCard#lines, served from its bytes.
+ #: (String name) -> Array[VCard::Parser::Line]
+ def of_line(name)
+ vcard.lines.select { it.names?(name) }
+ end
+
# A text property's value, with the empty one reading as absent:
# a property whose value carries nothing is one no caller has to
# tell apart from a property that is not there.
diff --git a/sig/pro_tacts/contact.rbs b/sig/pro_tacts/contact.rbs
index edc960f..119ae34 100644
--- a/sig/pro_tacts/contact.rbs
+++ b/sig/pro_tacts/contact.rbs
@@ -8,8 +8,9 @@ module ProTacts
class Phone
attr_reader value: String
attr_reader type: String?
+ attr_reader line: VCard::Parser::Line
- def self.new: (value: String, type: String?) -> instance
+ def self.new: (value: String, type: String?, line: VCard::Parser::Line) -> instance
end
class Email
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index 9e355c8..1be305f 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -110,6 +110,29 @@ class ContactTest < Minitest::Test
contact(STRUCTURED).phones.map { [it.value, it.type] }
end
+ # Each phone carries the line it was read from — the address a save
+ # names the row by, and the bytes a value-only edit spares. The digest
+ # is over the served card's line; composition only inserts the BDAY
+ # before END, so it names the stored card's line too.
+ def test_every_phone_carries_the_line_it_was_read_from
+ with_birthday = ProTacts::Birthday.new(year: 1985, month: 12, day: 10)
+ phones = contact(STRUCTURED, birthday: with_birthday).phones
+
+ assert_equal ["TEL;TYPE=mobile:+1-555-0100\r\n", "TEL;TYPE=work:+1-555-0199\r\n"],
+ phones.map { it.line.verbatim }
+ assert_equal Digest::SHA256.hexdigest("TEL;TYPE=mobile:+1-555-0100\r\n"),
+ phones.first.line.digest
+ end
+
+ # A line that would not read is a row no form can address: it reads
+ # as no phone, and stays in the card's lines, served from its bytes.
+ def test_a_phone_line_that_would_not_read_is_no_phone
+ broken = STRUCTURED.sub("TEL;TYPE=mobile:+1-555-0100\r\n", "TEL;=;:+1-555-0100\r\n")
+
+ assert_equal [["+1-555-0199", "work"]], contact(broken).phones.map { [it.value, it.type] }
+ assert contact(broken).vcard.lines.any? { it.names?("TEL") && it.property.nil? }
+ end
+
def test_reads_email
emails = contact(STRUCTURED).emails
assert_equal 1, emails.length