vcard: give a property its two readings of a value
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index aff457d..c0e2a70 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -221,7 +221,7 @@ module ProTacts
#: () -> Array[String?]?
def name_components
property = properties.find { it.name.casecmp?("N") }
- property && components_of(property.value)
+ property && components_of(property)
end
# NICKNAME's value (RFC 2426 section 3.1.3), in text form — the
@@ -272,23 +272,23 @@ module ProTacts
def text_of(property)
return if property.nil?
- value = VCard.unescape(property.value)
+ value = property.text
value.empty? ? nil : value
end
# A structured value's components, nil in the positions the card
# left blank: an empty component is as absent as one past the end
# of the value, and reads the same.
- #: (String value) -> Array[String?]
- def components_of(value)
- VCard.split_components(value).map { it.empty? ? nil : it }
+ #: (VCard::Parser::Property property) -> Array[String?]
+ def components_of(property)
+ property.components.map { it.empty? ? nil : it }
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)
- components = components_of(property.value)
+ components = components_of(property)
return if components.none?
po_box, extended, street, locality, region, postal_code, country = components
diff --git a/lib/pro_tacts/vcard/parser.rb b/lib/pro_tacts/vcard/parser.rb
index 6b6914d..bb1554f 100644
--- a/lib/pro_tacts/vcard/parser.rb
+++ b/lib/pro_tacts/vcard/parser.rb
@@ -60,15 +60,36 @@ module ProTacts
# same thing — both arrive here as two pairs.
#
# `value` is the value's text, unfolded and otherwise exactly as it
- # was stored, still escaped. Unescaping it and splitting it on the
- # component separator are per-property semantics, and this layer has
- # none.
+ # was stored, still escaped — the one representation it keeps, a
+ # served card being these bytes going back out unparsed. #text and
+ # #components are the two readings of it, derived on demand rather
+ # than stored beside it: which reading applies depends on the
+ # property's value type, which this layer does not know.
#
# The signature lives in sig/pro_tacts/vcard/parser.rbs: a Data
# class has no constant super class for the inline syntax to read.
# @rbs skip
Property = Data.define(:group, :name, :parameters, :value)
+ # Reopened rather than defined in the block above: steep reads a
+ # define block's self as this class's, not the constant's, so the
+ # methods could not live there.
+ class Property
+ # The whole value unescaped (RFC 2426 section 2.4.2), for a
+ # property whose value is free text. A structured value read this
+ # way comes back with its separators melted into the text, which
+ # is why the choice is the caller's.
+ #: () -> String
+ def text = VCard.unescape(value)
+
+ # The value's components, for a structured one (RFC 2426 section
+ # 3.2.1). Splitting before unescaping is the whole point of
+ # keeping `value` escaped: an escaped "\;" stays inside its
+ # component instead of ending it.
+ #: () -> Array[String]
+ def components = VCard.split_components(value)
+ end
+
# One logical line of a card, parsed beside the exact bytes that
# carried it: `property` is the content line it read, nil when it
# read none (a blank line, or one that would not read), `error` the
@@ -86,9 +107,7 @@ module ProTacts
# @rbs skip
Line = Data.define(:property, :verbatim, :error)
- # Reopened rather than defined in the block above: steep reads a
- # define block's self as this class's, not the constant's, so the
- # methods could not live there.
+ # Reopened for the reason Property is.
class Line
# Whether this line names the property, a group prefix
# (`item1.BDAY`) counting as naming it. Asked of the line rather
diff --git a/test/pro_tacts/vcard/test_parser.rb b/test/pro_tacts/vcard/test_parser.rb
index 31ace17..3a629a5 100644
--- a/test/pro_tacts/vcard/test_parser.rb
+++ b/test/pro_tacts/vcard/test_parser.rb
@@ -356,4 +356,15 @@ class VCardParserTest < Minitest::Test
raise "parameters did not survive" unless property.parameters == values.map { [name, it] }
end
end
+
+ # One value, two readings, and only the caller knows which its property
+ # wants: N is structured, so the escaped "\;" is a character inside a
+ # component — read as text it comes back spelled like the separator
+ # between them.
+ def test_a_value_reads_as_text_or_as_components
+ property = properties(card("N:Smith\\; Jr.;John;;;")).fetch(0)
+
+ assert_equal "Smith; Jr.;John;;;", property.text
+ assert_equal ["Smith; Jr.", "John", "", "", ""], property.components
+ end
end