birthday: read a BDAY that spells out VALUE=date
Every date from the iPhone spelled VALUE=date out loud;
the default says nothing new, so the bare-line read is allowed.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/docs/apple-contacts.md b/docs/apple-contacts.md
index 6c71c19..f46f797 100644
--- a/docs/apple-contacts.md
+++ b/docs/apple-contacts.md
@@ -470,7 +470,7 @@ of the year 2 and goes back that way. A day-only birthday iOS never
displayed comes back as a year-2 date it made up.
Every birthday it writes spells out `value=date`, the default value type
-of RFC 2426 section 3.1.5, which `ProTacts::Birthday` does not read. The
+of RFC 2426 section 3.1.5, so the parameter adds nothing to the line. The
carry across a rewrite (`plans/2026-09-01-birthdays-across-a-rewrite.md`)
kept the three shapes iOS dropped; the made-up dates replaced what was
stored. Verified 2026-09-11 against iOS 26.6.2 (dataaccessd/1.0).
diff --git a/lib/pro_tacts/admin/format.rb b/lib/pro_tacts/admin/format.rb
index 10e8c71..3b73442 100644
--- a/lib/pro_tacts/admin/format.rb
+++ b/lib/pro_tacts/admin/format.rb
@@ -28,16 +28,16 @@ module ProTacts
# form serves it (the editor can save a shape nothing renders,
# and the details page still shows what the record holds) — then
# any well-shaped stored spelling parsed by Birthday.from_value,
- # bare properties only, like the model's own reads, and the raw
- # value for everything else, shown as stored. The prose itself is
- # Birthday#to_s, every shape's one rendering.
+ # bare properties only (VALUE=date aside), like the model's own
+ # reads, and the raw value for everything else, shown as stored.
+ # The prose itself is Birthday#to_s, every shape's one rendering.
#: (Contact contact) -> String?
def self.birthday(contact)
property = contact.properties.find { it.name.casecmp?("BDAY") }
return contact.birthday&.to_s if property.nil?
birthday = contact.birthday
- if birthday.nil? && property.group.nil? && property.parameters.empty?
+ if birthday.nil? && property.group.nil? && Birthday.significant_parameters(property).empty?
birthday = Birthday.from_value(property.value)
end
birthday&.to_s || property.value
diff --git a/lib/pro_tacts/birthday.rb b/lib/pro_tacts/birthday.rb
index 959e545..c16225c 100644
--- a/lib/pro_tacts/birthday.rb
+++ b/lib/pro_tacts/birthday.rb
@@ -181,28 +181,39 @@ module ProTacts
# Reads one parsed BDAY property into the model, or nil for every
# spelling the model does not recompose — which keeps it in the card
# verbatim rather than losing it (RFC 6352 section 6.3.2.2). Only the
- # two forms #to_line emits are accepted, and only bare: any extra
- # parameter travels with the line, so a line carrying one is left
- # whole. A grouped `item1.BDAY` likewise stays, because its label
- # pairing belongs to the card.
+ # two forms #to_line emits are accepted, and only bare but for
+ # VALUE=date (#significant_parameters): any other parameter travels
+ # with the line, so a line carrying one is left whole. A grouped
+ # `item1.BDAY` likewise stays, because its label pairing belongs to
+ # the card.
def self.from_property(property)
return nil if property.group
- if property.parameters.empty? && (m = property.value.match(FULL_DATE))
+ parameters = significant_parameters(property)
+ if parameters.empty? && (m = property.value.match(FULL_DATE))
new(year: m[1].to_i, month: m[2].to_i, day: m[3].to_i)
- elsif apple_no_year?(property) && (m = property.value.match(OMIT_YEAR_DATE))
+ elsif apple_no_year?(parameters) && (m = property.value.match(OMIT_YEAR_DATE))
new(month: m[1].to_i, day: m[2].to_i)
end
end
+ # A BDAY's parameters without VALUE=date, which names the default
+ # value type (RFC 2426 section 3.1.5) and so says nothing the bare
+ # line does not. iOS spells it out on every birthday it writes
+ # (docs/apple-contacts.md, "iOS writes a birthday it cannot hold as
+ # a date it made up").
+ def self.significant_parameters(property)
+ property.parameters.reject { |name, value| name.casecmp?("VALUE") && value.casecmp?("date") }
+ end
+
# The parameter half of the Apple form, exactly: the one parameter,
# named for the year it stands in. A different sentinel or a value
# disagreeing with the year in the date is not the verified form and
# is not claimed.
- def self.apple_no_year?(property)
- return false unless property.parameters.length == 1
+ def self.apple_no_year?(parameters)
+ return false unless parameters.length == 1
- name, value = property.parameters.fetch(0)
+ name, value = parameters.fetch(0)
name.casecmp?("X-APPLE-OMIT-YEAR") && value == OMIT_YEAR
end
@@ -224,7 +235,7 @@ module ProTacts
# the loss — Store#put, to Sentry.
def self.rendered?(property)
!from_property(property).nil? ||
- (property.group.nil? && property.parameters.empty? && property.value.match?(REDUCED_DATE))
+ (property.group.nil? && significant_parameters(property).empty? && property.value.match?(REDUCED_DATE))
end
end
end
diff --git a/sig/pro_tacts/birthday.rbs b/sig/pro_tacts/birthday.rbs
index ce352cb..d217ba0 100644
--- a/sig/pro_tacts/birthday.rbs
+++ b/sig/pro_tacts/birthday.rbs
@@ -29,7 +29,9 @@ module ProTacts
def self.from_property: (VCard::Parser::Property property) -> Birthday?
- def self.apple_no_year?: (VCard::Parser::Property property) -> bool
+ def self.significant_parameters: (VCard::Parser::Property property) -> Array[[String, String]]
+
+ def self.apple_no_year?: (Array[[String, String]] parameters) -> bool
def self.unrendered_value?: (String value) -> bool
diff --git a/test/pro_tacts/admin/test_format.rb b/test/pro_tacts/admin/test_format.rb
index a42375e..89c3943 100644
--- a/test/pro_tacts/admin/test_format.rb
+++ b/test/pro_tacts/admin/test_format.rb
@@ -102,6 +102,14 @@ class FormatTest < Minitest::Test
assert_equal "February 30", ProTacts::Admin::Format.birthday(contact(nonsense))
end
+ # A line stored before the model read VALUE=date still carries it,
+ # and displays as the date it names.
+ def test_a_stored_value_date_birthday_displays_its_date
+ stored = CARD.sub("END:VCARD\r\n", "BDAY;value=date:1985-12-10\r\nEND:VCARD\r\n")
+
+ assert_equal "December 10, 1985", ProTacts::Admin::Format.birthday(contact(stored))
+ end
+
def test_a_birthday_that_will_not_parse_is_shown_as_stored
unparseable = CARD.sub("END:VCARD\r\n", "BDAY:not-a-date\r\nEND:VCARD\r\n")
diff --git a/test/pro_tacts/test_birthday.rb b/test/pro_tacts/test_birthday.rb
index 88014d8..0072c80 100644
--- a/test/pro_tacts/test_birthday.rb
+++ b/test/pro_tacts/test_birthday.rb
@@ -117,6 +117,26 @@ class BirthdayTest < Minitest::Test
assert_equal ProTacts::Birthday.new(month: 4, day: 12), ProTacts::Birthday.from_property(sentinel)
end
+ # VALUE=date names BDAY's default type (RFC 2426 section 3.1.5), and
+ # iOS spells it out on every birthday it writes.
+ def test_value_date_reads_as_the_bare_line_would
+ assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12),
+ ProTacts::Birthday.from_property(property("1985-04-12", parameters: [["value", "date"]]))
+ assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12),
+ ProTacts::Birthday.from_property(property("1985-04-12", parameters: [["VALUE", "DATE"]]))
+
+ sentinel = property("1604-04-12", parameters: [["VALUE", "date"], ["X-APPLE-OMIT-YEAR", "1604"]])
+ assert_equal ProTacts::Birthday.new(month: 4, day: 12), ProTacts::Birthday.from_property(sentinel)
+ end
+
+ # What iOS writes back for a no-year birthday: the sentinel's date
+ # without its parameter, a real 1604 as the bare line is
+ # (docs/plans/2026-08-31-partial-birthdays.md).
+ def test_a_value_date_1604_is_a_real_year
+ assert_equal ProTacts::Birthday.new(year: 1604, month: 3, day: 8),
+ ProTacts::Birthday.from_property(property("1604-03-08", parameters: [["value", "date"]]))
+ end
+
# A component out of range is unmodeled, not exceptional: the
# patterns encode the ranges, so the value never matches and the
# line stays wherever it was — Store reports it as unrecognized
@@ -132,6 +152,7 @@ class BirthdayTest < Minitest::Test
def test_what_the_model_cannot_recompose_reads_as_nil
[property("--0412"), property("1985-04"), property("19850412"),
property("1985-04-12", parameters: [["X-OTHER", "1"]]),
+ property("1985-04-12", parameters: [["VALUE", "text"]]),
property("1604-04-12", parameters: [["X-APPLE-OMIT-YEAR", "1604"], ["X-OTHER", "1"]]),
property("1985-04-12", group: "item1")].each do |unmodeled|
assert_nil ProTacts::Birthday.from_property(unmodeled), unmodeled.value
@@ -195,7 +216,9 @@ class BirthdayTest < Minitest::Test
def test_rendered_reads_the_spellings_clients_show
[property("1985-04-12"), property("1985-04-12T23:10:00Z"),
property("1604-04-12", parameters: [["X-APPLE-OMIT-YEAR", "1604"]]),
- property("--0412"), property("--04-12")].each do |rendered|
+ property("--0412"), property("--04-12"),
+ property("1985-04-12", parameters: [["value", "date"]]),
+ property("--0412", parameters: [["value", "date"]])].each do |rendered|
assert ProTacts::Birthday.rendered?(rendered), rendered.value
end
end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index cfee6bd..4667c87 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -704,6 +704,19 @@ class StoreTest < Minitest::Test
end
end
+ # iOS writes every birthday with VALUE=date spelled out
+ # (docs/apple-contacts.md), and the line moves into the model like a
+ # bare one, served back in the model's spelling.
+ def test_a_birthday_spelling_out_value_date_moves_into_the_model
+ with_store({"aiden" => AIDEN}) do |store|
+ store.put("aiden", vcard(AIDEN.sub("END:VCARD\r\n", "BDAY;value=date:1985-04-12\r\nEND:VCARD\r\n")))
+
+ assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), birthday_row(store, "aiden")
+ assert_equal AIDEN, card_row(store, "aiden").fetch(:vcard)
+ assert_equal AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nEND:VCARD\r\n"), store.contact("aiden").vcard.to_s
+ end
+ end
+
def test_an_apple_no_year_birthday_round_trips
with_store({"aiden" => AIDEN.sub("END:VCARD\r\n", "BDAY;X-APPLE-OMIT-YEAR=1604:1604-04-12\r\nEND:VCARD\r\n")}) do |store|
assert_equal ProTacts::Birthday.new(month: 4, day: 12), birthday_row(store, "aiden")