contact: give emails and addresses 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 813efbd..c4de3f1 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -61,18 +61,17 @@ 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
+ # Each also carries `line`, the parsed line the value was read
+ # from: the address a save names that 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.
+ # say "edit this phone."
# 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, :line)
# @rbs skip
- Email = Data.define(:value, :type)
+ Email = Data.define(:value, :type, :line)
# @rbs skip
Photo = Data.define(:mime_type, :bytes)
# @rbs skip
@@ -85,6 +84,7 @@ module ProTacts
:postal_code,
:country,
:type,
+ :line,
)
# A contact from its id, its stored card, and its birthday. The
@@ -257,15 +257,23 @@ module ProTacts
#: () -> Array[Email]
def emails
- of_name("EMAIL").filter_map do |property|
+ of_line("EMAIL").filter_map do |line|
+ property = line.property
+ next if property.nil?
+
value = text_of(property)
- Email.new(value:, type: type_of(property)) if value
+ Email.new(value:, type: type_of(property), line:) if value
end
end
#: () -> Array[Address]
def addresses
- of_name("ADR").filter_map { address_of(it) }
+ of_line("ADR").filter_map do |line|
+ property = line.property
+ next if property.nil?
+
+ address_of(property, line:)
+ end
end
# The card's NOTE (RFC 2426 section 3.6.2), in text form.
@@ -276,11 +284,6 @@ module ProTacts
private
- #: (String name) -> Array[VCard::Parser::Property]
- def of_name(name)
- 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
@@ -311,14 +314,15 @@ module ProTacts
end
# ADR's components, with a value that is blank throughout reading
- # as no address at all.
- #: (VCard::Parser::Property property) -> Address?
- def address_of(property)
+ # as no address at all. Carries the line it was read from, the
+ # other provenance-carrying shapes' own address for a save.
+ #: (VCard::Parser::Property property, line: VCard::Parser::Line) -> Address?
+ def address_of(property, line:)
components = components_of(property)
return if components.none?
po_box, extended, street, locality, region, postal_code, country = components
- Address.new(po_box:, extended:, street:, locality:, region:, postal_code:, country:, type: type_of(property))
+ Address.new(po_box:, extended:, street:, locality:, region:, postal_code:, country:, type: type_of(property), line:)
end
# RFC 2426 section 3.3.1: TYPE can repeat (TYPE=work;TYPE=voice) or
diff --git a/sig/pro_tacts/contact.rbs b/sig/pro_tacts/contact.rbs
index 119ae34..63a5576 100644
--- a/sig/pro_tacts/contact.rbs
+++ b/sig/pro_tacts/contact.rbs
@@ -16,8 +16,9 @@ module ProTacts
class Email
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 Photo
@@ -36,6 +37,7 @@ module ProTacts
attr_reader postal_code: String?
attr_reader country: String?
attr_reader type: String?
+ attr_reader line: VCard::Parser::Line
def self.new: (
po_box: String?,
@@ -45,7 +47,8 @@ module ProTacts
region: String?,
postal_code: String?,
country: String?,
- type: String?
+ type: String?,
+ line: VCard::Parser::Line
) -> instance
end
end
diff --git a/test/pro_tacts/test_contact.rb b/test/pro_tacts/test_contact.rb
index 1be305f..b888bac 100644
--- a/test/pro_tacts/test_contact.rb
+++ b/test/pro_tacts/test_contact.rb
@@ -140,6 +140,26 @@ class ContactTest < Minitest::Test
assert_equal "home", emails.fetch(0).type
end
+ # Each email carries the line it was read from, the phones' own
+ # address for a save: the digest names the served card's line, which
+ # is the stored one's too — composition only inserts the BDAY.
+ def test_every_email_carries_the_line_it_was_read_from
+ email = contact(STRUCTURED).emails.fetch(0)
+
+ assert_equal "EMAIL;TYPE=home:ada@example.com\r\n", email.line.verbatim
+ assert_equal Digest::SHA256.hexdigest("EMAIL;TYPE=home:ada@example.com\r\n"),
+ email.line.digest
+ end
+
+ # A line that would not read is a row no form can address: it reads
+ # as no email, and stays in the card's lines, served from its bytes.
+ def test_an_email_line_that_would_not_read_is_no_email
+ broken = STRUCTURED.sub("EMAIL;TYPE=home:ada@example.com\r\n", "EMAIL;=;:ada@example.com\r\n")
+
+ assert_empty contact(broken).emails
+ assert contact(broken).vcard.lines.any? { it.names?("EMAIL") && it.property.nil? }
+ end
+
def test_reads_an_address_as_its_seven_components
address = contact(STRUCTURED).addresses.fetch(0)
assert_nil address.po_box
@@ -152,6 +172,30 @@ class ContactTest < Minitest::Test
assert_equal "home", address.type
end
+ # The address carries its line like the single-valued shapes do —
+ # the digest is the row's address in a save.
+ def test_every_address_carries_the_line_it_was_read_from
+ address = contact(STRUCTURED).addresses.fetch(0)
+
+ assert_equal "ADR;TYPE=home:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom\r\n",
+ address.line.verbatim
+ assert_equal Digest::SHA256.hexdigest(
+ "ADR;TYPE=home:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom\r\n",
+ ), address.line.digest
+ end
+
+ # A line that would not read is a row no form can address — no
+ # address reads, and the line stays enumerable, served from its bytes.
+ def test_an_adr_line_that_would_not_read_is_no_address
+ broken = STRUCTURED.sub(
+ "ADR;TYPE=home:;;12 Analytical Way;London;England;NW1 1AA;United Kingdom\r\n",
+ "ADR;=;:;;12 Analytical Way\r\n",
+ )
+
+ assert_empty contact(broken).addresses
+ assert contact(broken).vcard.lines.any? { it.names?("ADR") && it.property.nil? }
+ end
+
# A blank position and one past the end of the value read the same:
# neither says anything the card did not.
def test_address_components_beyond_the_value_are_nil