birthdays: render every shape through Birthday#to_s
Birthday#to_s renders all six grammar shapes calendar-free and
from_value parses any well-shaped stored spelling, so Format.birthday
collapses to model-or-parse plus to_s. Calendar-nonsense renders as
the day it names ("February 30, 1985") rather than the stored string,
retiring Format's rescue; the date patterns now encode component
ranges, so out-of-range values stop matching instead of raising.
Assisted-by: GLM-5.3 via pi
diff --git a/AGENTS.md b/AGENTS.md
index 480bc57..ee6b835 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -92,13 +92,12 @@ not a fixture; edit a `.vcf` to change what the replay serves.
Every request already runs under `Sentry::Rack::CaptureExceptions`, so an
unexpected failure that's allowed to raise is reported and visible —
rescuing it locally "to be safe" makes it neither. Rescue only what's an
- ordinary, anticipated case with a real fallback to show (e.g. a birthday
- whose stored value is well-shaped but calendar-nonsense — see
- `Admin::Format.birthday`), never "this might fail, better catch
- it." And never swallow one silently: whatever a rescue catches still
- goes to Sentry — `Sentry.capture_exception` at the minimum — so the
- fallback is what the screen shows, never a reason the error goes
- unreported.
+ ordinary, anticipated case with a real fallback to show (e.g. no row for
+ a requested id — see `Store#contact`'s 404 path), never "this might
+ fail, better catch it." And never swallow one silently: a rescue that
+ catches an error still sends it to Sentry — `Sentry.capture_exception`
+ at the minimum — so the fallback is what the screen shows, never a
+ reason the error goes unreported.
- `config.ru` must stay ASCII-only; a test enforces it, because boot crashes
under a C locale otherwise. Watch for em dashes in comments.
- `RUBYOPT=--enable-frozen-string-literal` is set in `.ramekin/config.kdl`.
diff --git a/lib/pro_tacts/admin/format.rb b/lib/pro_tacts/admin/format.rb
index 0966ea9..256d43b 100644
--- a/lib/pro_tacts/admin/format.rb
+++ b/lib/pro_tacts/admin/format.rb
@@ -1,4 +1,3 @@
-require "date"
require "time"
require "pro_tacts/birthday"
@@ -24,58 +23,22 @@ module ProTacts
(contact.name || contact.id).to_s.strip[0].to_s.upcase
end
- # The birthday a card carries, for display: the model's date when
- # the model holds one, the reduced no-year spelling rendered as its
- # day, and the raw property for anything else — a BDAY in a spelling
- # the model doesn't recompose is kept in the card verbatim on write,
- # so it's shown as stored.
+ # The birthday a card carries, for display: the model's when the
+ # card carries one it recomposes, otherwise 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.
#: (Contact contact) -> String?
def self.birthday(contact)
- birthday = contact.birthday
- return reduced_no_year(contact) || raw_birthday(contact) if birthday.nil?
-
- if birthday.year
- Date.new(birthday.year, birthday.month, birthday.day).strftime("%B %-d, %Y")
- else
- Date.new(2000, birthday.month, birthday.day).strftime("%B %-d")
- end
- rescue ArgumentError
- # A birthday whose stored value is well-shaped but
- # calendar-nonsense: the model checks component ranges, not the
- # calendar, so February 30 parses and only Date.new refuses it.
- # The ordinary anticipated case, shown as stored rather than
- # raised.
- raw_birthday(contact)
- end
-
- #: (Contact contact) -> String?
- def self.raw_birthday(contact)
- contact.properties.find { it.name.casecmp?("BDAY") }&.value
- end
-
- # A no-year BDAY in the reduced spelling (Birthday::REDUCED_DATE):
- # macOS reads it on a card it did not write, but the model
- # recomposes only the two forms it serves, so the line stays in the
- # card verbatim and this renders it as the day it names. Bare only,
- # like the model's own reads — a grouped or parametered BDAY's
- # pairing belongs to the card, not this row.
- #: (Contact contact) -> String?
- def self.reduced_no_year(contact)
property = contact.properties.find { it.name.casecmp?("BDAY") }
- return nil if property.nil? || property.group || !property.parameters.empty?
+ return nil if property.nil?
- match = property.value.match(Birthday::REDUCED_DATE)
- return nil if match.nil?
-
- # The pattern matches without capturing (it is a predicate
- # elsewhere); the matched string carries the components — two
- # digits of month and two of day, an optional dash between.
- digits = match[0].delete_prefix("--").delete("-")
- Date.new(2000, digits[0, 2].to_i, digits[2, 2].to_i).strftime("%B %-d")
- rescue ArgumentError
- # Calendar-nonsense in this spelling too (February 30): shown as
- # stored, like the modeled branch above.
- nil
+ birthday = contact.birthday
+ if birthday.nil? && property.group.nil? && property.parameters.empty?
+ birthday = Birthday.from_value(property.value)
+ end
+ birthday&.to_s || property.value
end
# A card's updated_at, UTC ISO 8601 to the millisecond (see
diff --git a/lib/pro_tacts/birthday.rb b/lib/pro_tacts/birthday.rb
index 67a8510..6c99b41 100644
--- a/lib/pro_tacts/birthday.rb
+++ b/lib/pro_tacts/birthday.rb
@@ -1,3 +1,5 @@
+require "date"
+
require "pro_tacts/vcard"
require "pro_tacts/vcard/parser"
@@ -33,39 +35,49 @@ module ProTacts
# stays in a card verbatim rather than drift to this model's
# spelling. The optional time is RFC 2426 section 3.1.5's other
# value shape, and it is accepted and dropped: a birthday is a
- # date, the one deliberate loss the design records.
- FULL_DATE = /\A(\d{4})-(\d{2})-(\d{2})(?:T\d{2}:?\d{2}(:?\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?\z/ #: Regexp
+ # date, the one deliberate loss the design records. Components in
+ # range, so an out-of-range date does not match and stays in the
+ # card rather than reaching a constructor that would refuse it.
+ FULL_DATE = /\A(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])(?:T\d{2}:?\d{2}(:?\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?\z/ #: Regexp
# The no-year shape macOS writes, with 1604 standing in for the year
# in both the parameter and the value (docs/macos-contacts.md,
# "Birthdays without a year"). 1604 because it is the first year of
# the Gregorian calendar; the parameter names the year to omit.
- OMIT_YEAR_DATE = /\A1604-(\d{2})-(\d{2})\z/ #: Regexp
+ # Components in range, like FULL_DATE.
+ OMIT_YEAR_DATE = /\A1604-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])\z/ #: Regexp
OMIT_YEAR = "1604" #: String
- # The BDAY values no client renders, spelled whole: the four
- # partial shapes #to_line has no form for, dashed as RFC 6350
- # section 4.3.1's examples spell them, components in range, each
- # pattern anchored alone so no edit to one can loosen another's.
- # A value off the list is not carried across a rewrite, by the
- # same probe: the served shapes in any spelling were visible, so
- # their absence is a deletion, and an unrecognized or out-of-range
- # value is cleaner dying with the rewrite than living forever
- # (docs/macos-contacts.md, "A birthday the client cannot render is
- # dropped from the card").
+ # The BDAY values no client renders, one named pattern per shape —
+ # dashed as RFC 6350 section 4.3.1's examples spell them,
+ # components in range, each anchored alone so no edit to one can
+ # loosen another's. Capturing, because #from_value reads the
+ # components back; the predicate reads below only match, which
+ # captures do not disturb. A value off the list is not carried
+ # across a rewrite, by the same probe: the served shapes in any
+ # spelling were visible, so their absence is a deletion, and an
+ # unrecognized or out-of-range value is cleaner dying with the
+ # rewrite than living forever (docs/macos-contacts.md, "A birthday
+ # the client cannot render is dropped from the card").
+ YEAR_AND_MONTH = /\A(\d{4})-(0[1-9]|1[0-2])\z/ #: Regexp
+ YEAR_ALONE = /\A(\d{4})\z/ #: Regexp
+ MONTH_ALONE = /\A--(0[1-9]|1[0-2])\z/ #: Regexp
+ DAY_ALONE = /\A---(0[1-9]|[12]\d|3[01])\z/ #: Regexp
+
UNRENDERED_VALUES = [
- /\A\d{4}-(?:0[1-9]|1[0-2])\z/, # year and month
- /\A\d{4}\z/, # year alone
- /\A--(?:0[1-9]|1[0-2])\z/, # month alone
- /\A---(?:0[1-9]|[12]\d|3[01])\z/, # day alone
+ YEAR_AND_MONTH,
+ YEAR_ALONE,
+ MONTH_ALONE,
+ DAY_ALONE,
] #: Array[Regexp]
# The reduced no-year values macOS reads on a card it did not write
# and converts to the sentinel on its own
# (docs/macos-contacts.md, "Birthdays without a year"). Together
# with #from_property's two spellings this is the rendered set: a
- # BDAY a client could see, and so could have deleted.
- REDUCED_DATE = /\A--(?:0[1-9]|1[0-2])-?(?:0[1-9]|[12]\d|3[01])\z/ #: Regexp
+ # BDAY a client could see, and so could have deleted. Capturing
+ # for #from_value, like the unrendered patterns above.
+ REDUCED_DATE = /\A--(0[1-9]|1[0-2])-?(0[1-9]|[12]\d|3[01])\z/ #: Regexp
# The only constructor, so no birthday exists in a shape the grammar
# rejects: one `in` clause per shape RFC 6350 section 4.3.1 admits,
@@ -108,6 +120,29 @@ module ProTacts
end
end
+ # The birthday in prose, one clause per shape the constructor
+ # admits — the same six, in the same order — so every display of a
+ # birthday agrees. Carries no calendar: the components were
+ # range-checked when built, and Date.new would only add a way to
+ # fail, on a value like February 30 that is impossible but
+ # well-shaped and renders as the day it names.
+ def to_s
+ y, m, d = year, month, day
+ if y && m && d
+ "#{Date::MONTHNAMES[m]} #{d}, #{y}"
+ elsif m && d
+ "#{Date::MONTHNAMES[m]} #{d}"
+ elsif y && m
+ "#{Date::MONTHNAMES[m]} #{y}"
+ elsif y
+ y.to_s
+ elsif m
+ "#{Date::MONTHNAMES[m]}"
+ else
+ d.to_s
+ end
+ end
+
# Whether any served card carries this birthday. The deletion rule
# turns on it: a client that never saw the birthday cannot have
# deleted it.
@@ -115,6 +150,32 @@ module ProTacts
!to_line.nil?
end
+ # Any well-shaped BDAY value as a model, regardless of spelling:
+ # a full date (the optional time accepted and dropped, as
+ # #from_property drops it), the reduced no-year, and the four
+ # UNRENDERED_VALUES shapes — every form the grammar admits. A
+ # display reader, the raw-value counterpart of #from_property: the
+ # write path keeps reading properties, and accepts only what
+ # #to_line can serve back, so what a client PUTs still recomposes
+ # into exactly the served forms. A bare 1604 date is a full date
+ # here, as it is there — the sentinel is the parameter's to claim.
+ # Nil for anything else, and the caller keeps the value as stored.
+ def self.from_value(value)
+ if (m = value.match(FULL_DATE))
+ new(year: m[1].to_i, month: m[2].to_i, day: m[3].to_i)
+ elsif (m = value.match(REDUCED_DATE))
+ new(month: m[1].to_i, day: m[2].to_i)
+ elsif (m = value.match(YEAR_AND_MONTH))
+ new(year: m[1].to_i, month: m[2].to_i)
+ elsif (m = value.match(YEAR_ALONE))
+ new(year: m[1].to_i)
+ elsif (m = value.match(MONTH_ALONE))
+ new(month: m[1].to_i)
+ elsif (m = value.match(DAY_ALONE))
+ new(day: m[1].to_i)
+ end
+ end
+
# 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
@@ -130,10 +191,6 @@ module ProTacts
elsif apple_no_year?(property) && (m = property.value.match(OMIT_YEAR_DATE))
new(month: m[1].to_i, day: m[2].to_i)
end
- rescue ArgumentError
- # A component out of range is unmodeled, not exceptional: the line
- # stays in the card as it came.
- nil
end
# The parameter half of the Apple form, exactly: the one parameter,
diff --git a/sig/pro_tacts/birthday.rbs b/sig/pro_tacts/birthday.rbs
index 35dbb42..ce352cb 100644
--- a/sig/pro_tacts/birthday.rbs
+++ b/sig/pro_tacts/birthday.rbs
@@ -6,6 +6,10 @@ module ProTacts
FULL_DATE: Regexp
OMIT_YEAR_DATE: Regexp
OMIT_YEAR: String
+ YEAR_AND_MONTH: Regexp
+ YEAR_ALONE: Regexp
+ MONTH_ALONE: Regexp
+ DAY_ALONE: Regexp
UNRENDERED_VALUES: Array[Regexp]
REDUCED_DATE: Regexp
@@ -17,8 +21,12 @@ module ProTacts
def to_line: () -> String?
+ def to_s: () -> String
+
def served?: () -> bool
+ def self.from_value: (String value) -> Birthday?
+
def self.from_property: (VCard::Parser::Property property) -> Birthday?
def self.apple_no_year?: (VCard::Parser::Property property) -> bool
diff --git a/test/pro_tacts/admin/test_format.rb b/test/pro_tacts/admin/test_format.rb
index ce08eaa..1d184f1 100644
--- a/test/pro_tacts/admin/test_format.rb
+++ b/test/pro_tacts/admin/test_format.rb
@@ -85,12 +85,12 @@ class FormatTest < Minitest::Test
assert_equal "December 10", ProTacts::Admin::Format.birthday(contact(basic))
end
- # Calendar-nonsense in the reduced spelling, shown as stored like
- # the modeled one below.
- def test_a_calendar_nonsense_reduced_no_year_birthday_is_shown_as_stored
+ # Calendar-nonsense in the reduced spelling, rendered as the day it
+ # names like the modeled one below.
+ def test_a_calendar_nonsense_reduced_no_year_birthday_renders_its_components
nonsense = CARD.sub("BDAY:1985-12-10", "BDAY:--02-30")
- assert_equal "--02-30", ProTacts::Admin::Format.birthday(contact(nonsense))
+ assert_equal "February 30", ProTacts::Admin::Format.birthday(contact(nonsense))
end
def test_a_birthday_that_will_not_parse_is_shown_as_stored
@@ -100,12 +100,26 @@ class FormatTest < Minitest::Test
end
# Well-shaped but calendar-nonsense: the model checks component
- # ranges, not the calendar, so February 30 parses and only Date.new
- # refuses it. The anticipated case, shown as stored rather than raised.
- def test_a_calendar_nonsense_birthday_is_shown_as_stored
+ # ranges, not the calendar, and rendering is calendar-free — the
+ # prose names the day it says rather than refusing to display.
+ def test_a_calendar_nonsense_birthday_renders_its_components
nonsense = CARD.sub("BDAY:1985-12-10", "BDAY:1985-02-30")
- assert_equal "1985-02-30", ProTacts::Admin::Format.birthday(contact(nonsense))
+ assert_equal "February 30, 1985", ProTacts::Admin::Format.birthday(contact(nonsense))
+ end
+
+ # The partial shapes no client renders still display: every form
+ # the grammar admits has Birthday#to_s's one prose.
+ def test_the_partial_shapes_display_their_components
+ year_month = CARD.sub("BDAY:1985-12-10", "BDAY:1985-10")
+ year = CARD.sub("BDAY:1985-12-10", "BDAY:1985")
+ month = CARD.sub("BDAY:1985-12-10", "BDAY:--07")
+ day = CARD.sub("BDAY:1985-12-10", "BDAY:---04")
+
+ assert_equal "October 1985", ProTacts::Admin::Format.birthday(contact(year_month))
+ assert_equal "1985", ProTacts::Admin::Format.birthday(contact(year))
+ assert_equal "July", ProTacts::Admin::Format.birthday(contact(month))
+ assert_equal "4", ProTacts::Admin::Format.birthday(contact(day))
end
def test_a_card_with_no_birthday_formats_none
diff --git a/test/pro_tacts/test_birthday.rb b/test/pro_tacts/test_birthday.rb
index 7a1652b..02983aa 100644
--- a/test/pro_tacts/test_birthday.rb
+++ b/test/pro_tacts/test_birthday.rb
@@ -61,6 +61,26 @@ class BirthdayTest < Minitest::Test
end
end
+ ## to_s
+
+ # One rendering per admitted shape, the same six the constructor
+ # accepts, so every display of a birthday agrees.
+ def test_to_s_renders_every_admitted_shape
+ assert_equal "April 12, 1985", ProTacts::Birthday.new(year: 1985, month: 4, day: 12).to_s
+ assert_equal "April 1985", ProTacts::Birthday.new(year: 1985, month: 4).to_s
+ assert_equal "1985", ProTacts::Birthday.new(year: 1985).to_s
+ assert_equal "April 12", ProTacts::Birthday.new(month: 4, day: 12).to_s
+ assert_equal "April", ProTacts::Birthday.new(month: 4).to_s
+ assert_equal "12", ProTacts::Birthday.new(day: 12).to_s
+ end
+
+ # Rendering carries no calendar: February 30 is impossible but
+ # well-shaped, and the prose names the day it says rather than
+ # refusing to render at all.
+ def test_to_s_renders_calendar_nonsense_as_the_day_it_names
+ assert_equal "February 30, 1985", ProTacts::Birthday.new(year: 1985, month: 2, day: 30).to_s
+ end
+
## from_property
# A parsed property, the shape a read out of a card takes.
@@ -97,9 +117,10 @@ class BirthdayTest < Minitest::Test
assert_equal ProTacts::Birthday.new(month: 4, day: 12), ProTacts::Birthday.from_property(sentinel)
end
- # A component out of range is unmodeled, not exceptional: from_property
- # reads nil and the line stays wherever it was. Store reports the value
- # as unrecognized, so the swallow is not silent.
+ # 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
+ # when a rewrite drops it.
def test_an_out_of_range_date_reads_as_nil
assert_nil ProTacts::Birthday.from_property(property("1985-13-40"))
end
@@ -117,6 +138,37 @@ class BirthdayTest < Minitest::Test
end
end
+ ## from_value
+
+ # The display reader: every well-shaped spelling models, regardless
+ # of what the write path serves — the two from_property reads (a
+ # date-time's time dropped on the way in, as there), the reduced
+ # no-year in both spellings, and the four no client renders.
+ def test_from_value_reads_every_well_shaped_spelling
+ assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), ProTacts::Birthday.from_value("1985-04-12")
+ assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12), ProTacts::Birthday.from_value("1985-04-12T23:10:00Z")
+ assert_equal ProTacts::Birthday.new(month: 4, day: 12), ProTacts::Birthday.from_value("--04-12")
+ assert_equal ProTacts::Birthday.new(month: 4, day: 12), ProTacts::Birthday.from_value("--0412")
+ assert_equal ProTacts::Birthday.new(year: 1985, month: 4), ProTacts::Birthday.from_value("1985-04")
+ assert_equal ProTacts::Birthday.new(year: 1985), ProTacts::Birthday.from_value("1985")
+ assert_equal ProTacts::Birthday.new(month: 4), ProTacts::Birthday.from_value("--04")
+ assert_equal ProTacts::Birthday.new(day: 12), ProTacts::Birthday.from_value("---12")
+ end
+
+ # A bare 1604 date is a full date — the sentinel is the parameter's
+ # to claim, exactly as from_property reads it.
+ def test_from_value_reads_a_bare_1604_date_as_a_full_date
+ assert_equal ProTacts::Birthday.new(year: 1604, month: 4, day: 12), ProTacts::Birthday.from_value("1604-04-12")
+ end
+
+ # Anything else is nothing display can name, and the caller keeps
+ # the value as stored.
+ def test_from_value_refuses_what_it_cannot_read
+ ["banana", "1985-13-40", "19850412", ""].each do |value|
+ assert_nil ProTacts::Birthday.from_value(value), value
+ end
+ end
+
## the values no client renders
def test_unrendered_value_accepts_the_four_shapes_spellings