birthday: model every well-shaped BDAY, send only two
Every well-shaped birthday lands in the table; only two go out the door,
and the carry that chased dropped lines is not needed anymore.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/docs/plans/2026-09-11-every-birthday-in-the-model.md b/docs/plans/2026-09-11-every-birthday-in-the-model.md
new file mode 100644
index 0000000..4cd2eba
--- /dev/null
+++ b/docs/plans/2026-09-11-every-birthday-in-the-model.md
@@ -0,0 +1,92 @@
+# Every birthday goes in the model
+
+2026-09-11. Every well-shaped BDAY a client sends moves into the
+birthdays table, and only the two shapes that survive a round trip
+through both Apple clients are sent back. The other four are kept and
+shown in the admin, but no served card carries them.
+
+This replaces the carry in
+`2026-09-01-birthdays-across-a-rewrite.md`, and reverses the 1604 rule
+in `2026-08-31-partial-birthdays.md`.
+
+## What survives a round trip
+
+From the macOS probe (`docs/apple-contacts.md`, "A birthday the client
+cannot render is dropped from the card") and the iOS one ("iOS writes a
+birthday it cannot hold as a date it made up"):
+
+| Shape | macOS | iOS |
+|---|---|---|
+| Full date | survives | survives, with `value=date` added |
+| Month and day | the sentinel survives | survives only if 1604 means no year |
+| Year and month, year, day | dropped | dropped, or a date it made up |
+| Month alone | not observed | dropped |
+
+A full date and a month-day are the two `Birthday#to_line` sends.
+
+## Why the carry went
+
+The carry existed because a BDAY in a shape no client renders stayed
+in the stored card, got served, got dropped by the client, and had to
+be put back. The 2026-09-01 plan kept it in the card rather than the
+model because moving it would make it vanish from every served card.
+
+That vanishing costs nothing: no client displays those shapes, so
+serving them only ever gave a client something to drop or, on iOS, to
+turn into a made-up year-2 date. With every well-shaped BDAY in the
+model, no stored card holds a birthday a client cannot handle, and the
+only rule left is the one the model already had: a birthday row no
+client was sent survives a card sent back without one.
+
+The same move fixes a spelling iOS misreads. `BDAY:--11-27` served
+verbatim showed on iOS as January 27 of the year 2 and was written
+back that way; in the model it is sent as the sentinel instead.
+
+## 1604 means no year
+
+iOS writes the Apple sentinel back as `BDAY;value=date:1604-MM-DD`,
+without `X-APPLE-OMIT-YEAR`. The 2026-08-31 plan read a bare 1604 date
+as a real birthday in 1604, which would turn every no-year birthday
+into a 1604 one after an iOS save. A family address book has nobody
+born in 1604, so a full date in that year now reads as a month and day,
+parameter or not.
+
+The model can still hold a year of 1604 from the admin editor, and it
+goes out as a bare 1604 date that reads back as a month and day. That
+is the one shape this gives up.
+
+## One line, three outcomes
+
+`BirthdayLine.read` sorts a submitted BDAY line into one of three
+variants, and `Store#put` has one arm for each:
+
+- `Modeled`: a birthday the model takes. It leaves the card.
+- `Unrecognized`: a BDAY the model does not take — an unknown value, a
+ parameter other than `VALUE=date` or the sentinel's, or a grouped
+ `item1.BDAY`. It stays in the card verbatim and is reported.
+- `Unreadable`: a line the parser could not read. It stays in the card,
+ already reported by `Web#report_unreadable_lines`.
+
+A card with more than one BDAY keeps them all in the card and is
+reported whatever they hold: a contact has one birthday.
+
+The variants are matched with `case`/`when`. Steep 2.1.0.dev.1 does not
+check the bodies of `case`/`in` branches at all — a missing method in
+one reports nothing — while `case`/`when` narrows the union by class.
+`when` does not raise on an unmatched value, so each match ends in an
+explicit raise.
+
+## No migration
+
+Cards stored under the old rule can hold BDAY lines this change would
+move into the model, and a rewrite now drops such a line with a report.
+No deployed database held any cards when this landed, so nothing moves
+them.
+
+## Migration 002 reads through the new rule
+
+`db/migrations/002_birthdays.rb` calls `Birthday.from_property`, so
+run today against a database that predates it, it would move every
+well-shaped BDAY rather than the two spellings it moved when it was
+written. Every database that has run 002 is unaffected, and a new one
+has no cards when 002 runs.
diff --git a/lib/pro_tacts/admin/format.rb b/lib/pro_tacts/admin/format.rb
index 3b73442..ac81509 100644
--- a/lib/pro_tacts/admin/format.rb
+++ b/lib/pro_tacts/admin/format.rb
@@ -23,24 +23,18 @@ module ProTacts
(contact.name || contact.id).to_s.strip[0].to_s.upcase
end
- # A contact's birthday, for display: the model's — composed into
- # the served card when a client can carry it, held alone when no
- # 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 (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.
+ # A contact's birthday, for display: the model's, including the
+ # shapes no client is sent, then a BDAY still in the card — read the
+ # way a write reads one, else shown raw. The prose is Birthday#to_s.
#: (Contact contact) -> String?
def self.birthday(contact)
+ birthday = contact.birthday
+ return birthday.to_s if birthday
+
property = contact.properties.find { it.name.casecmp?("BDAY") }
- return contact.birthday&.to_s if property.nil?
+ return nil if property.nil?
- birthday = contact.birthday
- if birthday.nil? && property.group.nil? && Birthday.significant_parameters(property).empty?
- birthday = Birthday.from_value(property.value)
- end
- birthday&.to_s || property.value
+ Birthday.from_property(property)&.to_s || property.value
end
# ADR's components as display lines — street, then everything
diff --git a/lib/pro_tacts/birthday.rb b/lib/pro_tacts/birthday.rb
index c16225c..42b92f6 100644
--- a/lib/pro_tacts/birthday.rb
+++ b/lib/pro_tacts/birthday.rb
@@ -15,78 +15,54 @@ module ProTacts
# alone follows from its allowance of ISO 8601 truncated representation
# f), which it references but does not spell out.
#
- # vCard 3.0 cannot carry any of this, so a birthday is database state
- # that no stored card carries, composed into the served card on read
- # and subtracted out of a submitted one on write — Store owns that
- # surgery. Why it cannot live in the card, and what each shape puts
- # on the wire, are docs/plans/2026-08-31-partial-birthdays.md and
- # docs/plans/2026-09-01-birthdays-across-a-rewrite.md.
+ # Every well-shaped BDAY a client sends is read into this model, and
+ # only the two shapes that survive a round trip through both Apple
+ # clients go back out (#to_line); the other four are kept and shown
+ # but never sent. Store moves the line out of the card and composes it
+ # back in. The reasoning is docs/plans/2026-09-11-every-birthday-in-the-model.md;
+ # why a partial date cannot live in a vCard 3.0 card is
+ # docs/plans/2026-08-31-partial-birthdays.md.
#
# The signature lives in sig/pro_tacts/birthday.rbs: a Data class has
# no constant super class for the inline syntax to read.
# @rbs skip
class Birthday < Data.define(:year, :month, :day)
- # A full date, the one partial shape vCard 3.0 carries natively
- # (RFC 2425 section 5.8.4). The dashed form is what macOS writes;
- # the undashed one is legal too and deliberately unmodeled, so it
- # 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. 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/apple-contacts.md,
- # "Birthdays without a year"). 1604 because it is the first year of
- # the Gregorian calendar; the parameter names the year to omit.
- # 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
+ # The year Apple writes in place of a missing one. It means "no year"
+ # wherever it appears, parameter or not, because iOS writes the
+ # sentinel back without its parameter (docs/apple-contacts.md, "iOS
+ # writes a birthday it cannot hold as a date it made up").
+ NO_YEAR = 1604 #: Integer
OMIT_YEAR = "1604" #: String
- # 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/apple-contacts.md, "A birthday
- # the client cannot render is dropped from the card").
+ # The BDAY values this reads, one pattern per spelling, components
+ # in range so an out-of-range value does not match and stays in the
+ # card rather than reaching a constructor that would refuse it.
+ #
+ # A full date is dashed, as both clients write it; the undashed form
+ # RFC 2425 section 5.8.4 also allows is not read. The optional time
+ # is RFC 2426 section 3.1.5's other value shape, accepted and dropped:
+ # a birthday is a date.
+ 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 rest are RFC 6350 section 4.3.1's reduced and truncated forms,
+ # the month-day in both the basic and extended spelling.
+ MONTH_AND_DAY = /\A--(0[1-9]|1[0-2])-?(0[1-9]|[12]\d|3[01])\z/ #: Regexp
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 = [
- 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/apple-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. 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 value X-APPLE-OMIT-YEAR is allowed on: the sentinel date alone,
+ # no time, as macOS writes it (docs/apple-contacts.md, "Birthdays
+ # without a year").
+ OMIT_YEAR_DATE = /\A1604-(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,
- # each component's range standing where the component stands, so the
- # pattern is the grammar rather than a prose description of it. A
- # range admits any Comparable in its bounds, so the check is
- # Integer-grade only in practice: the two producers — SQLite's
- # integer columns and #to_i — cannot yield anything else, and a
- # String or nil-shaped miss falls to the else. Components are not
- # calendar-validated beyond their ranges: a partial date has no
- # whole date to be wrong about, and a nonsense but well-shaped one
- # round-trips rather than 500s a write.
+ # each component's range standing where the component stands. A range
+ # admits any Comparable in its bounds, but the two producers — SQLite's
+ # integer columns and #to_i — only yield Integers, and a String misses
+ # to the else. Components are not calendar-validated: a nonsense but
+ # well-shaped date round-trips rather than 500s a write.
def self.new(year: nil, month: nil, day: nil)
case [year, month, day]
in [0..9999, 1..12, 1..31]
@@ -103,12 +79,9 @@ module ProTacts
super
end
- # The two wire forms a client has been verified to accept: a plain
- # full date, and Apple's 1604 sentinel for a month and day. The other
- # four shapes have no verified spelling and reach no client — this
- # returns nil for them and no card grows a BDAY on their behalf.
- # UNRENDERED_VALUES is exactly those four shapes' spellings, so
- # widening this means narrowing that.
+ # The line sent to clients: a plain full date, or Apple's 1604
+ # sentinel for a month and day. Nil for the other four shapes, which
+ # no client displays, so no served card carries them.
def to_line
case [year, month, day]
in [Integer => y, Integer => m, Integer => d]
@@ -121,13 +94,9 @@ module ProTacts
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. No else: the six are
- # every shape a Birthday holds, so a seventh is better raising here
- # than rendering as whichever clause it fell through to.
+ # admits, so every display of a birthday agrees. No calendar: Date.new
+ # would only add a way to fail on a well-shaped February 30. No else:
+ # the six are every shape a Birthday holds.
def to_s
case [year, month, day]
in [Integer => y, Integer => m, Integer => d]
@@ -145,71 +114,35 @@ module ProTacts
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.
+ # Whether served cards carry this birthday. A client that was never
+ # sent a birthday cannot have deleted it (Store#put).
def served?
!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
- # 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.
+ # One parsed BDAY property as a birthday, or nil for a line the model
+ # does not take — which then stays in the card verbatim (RFC 6352
+ # section 6.3.2.2). A grouped `item1.BDAY` is not taken, because its
+ # label pairing belongs to the card, and neither is any parameter but
+ # VALUE=date and the Apple sentinel's.
def self.from_property(property)
return nil if property.group
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?(parameters) && (m = property.value.match(OMIT_YEAR_DATE))
- new(month: m[1].to_i, day: m[2].to_i)
+ if parameters.empty? || (apple_no_year?(parameters) && property.value.match?(OMIT_YEAR_DATE))
+ from_value(property.value)
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").
+ # line does not. iOS spells it out on every birthday it writes.
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.
+ # The Apple sentinel's parameter, exactly: the one parameter, naming
+ # 1604 as the year to omit.
def self.apple_no_year?(parameters)
return false unless parameters.length == 1
@@ -217,25 +150,25 @@ module ProTacts
name.casecmp?("X-APPLE-OMIT-YEAR") && value == OMIT_YEAR
end
- # Whether a BDAY value is one no client renders — the spellings a
- # rewrite carries across, so the birthday survives a client that
- # drops what it cannot display. A predicate rather than a reader:
- # nothing downstream wants the shape as a model, and a whitelist
- # cannot raise on what it does not recognize.
- def self.unrendered_value?(value)
- UNRENDERED_VALUES.any? { value.match?(it) }
- end
-
- # Whether a client renders this BDAY property as a birthday: one of
- # the two spellings #to_line emits (#from_property reads exactly
- # those), or a reduced no-year value macOS additionally reads. A
- # client that renders a BDAY can have deleted it, so its absence
- # from a rewrite is a removal; a property that is neither rendered
- # nor carried is neither, and the rewrite that drops it reports
- # the loss — Store#put, to Sentry.
- def self.rendered?(property)
- !from_property(property).nil? ||
- (property.group.nil? && significant_parameters(property).empty? && property.value.match?(REDUCED_DATE))
+ # A BDAY value in any spelling this reads, or nil.
+ def self.from_value(value)
+ if (m = value.match(FULL_DATE))
+ year = m[1].to_i
+ month = m[2].to_i
+ day = m[3].to_i
+ year == NO_YEAR ? new(month:, day:) : new(year:, month:, day:)
+ elsif (m = value.match(MONTH_AND_DAY))
+ 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
+ private_class_method :from_value, :apple_no_year?
end
end
diff --git a/lib/pro_tacts/birthday_line.rb b/lib/pro_tacts/birthday_line.rb
new file mode 100644
index 0000000..f84f3ea
--- /dev/null
+++ b/lib/pro_tacts/birthday_line.rb
@@ -0,0 +1,41 @@
+require "pro_tacts/birthday"
+require "pro_tacts/vcard/parser"
+
+module ProTacts
+ # What one BDAY line in a submitted card is, for Store#put to act on
+ # (docs/plans/2026-09-11-every-birthday-in-the-model.md). Match with
+ # case/when, not case/in: Steep does not check the bodies of case/in
+ # branches, and these branches are the ones worth checking.
+ #
+ # The variants are signed in sig/pro_tacts/birthday_line.rbs, for
+ # Birthday's reason.
+ module BirthdayLine
+ # A birthday the model takes: the line leaves the card.
+ # @rbs skip
+ class Modeled < Data.define(:birthday)
+ end
+
+ # A line that reads as a BDAY the model does not take. It stays in
+ # the card verbatim and is reported.
+ # @rbs skip
+ class Unrecognized < Data.define
+ end
+
+ # A line the parser could not read as a BDAY at all. It stays in the
+ # card, and Web#report_unreadable_lines has already reported it.
+ # @rbs skip
+ class Unreadable < Data.define
+ end
+
+ # The line's property is checked by name as well as read, because a
+ # line matched as BDAY by its bytes can still fail to parse into one.
+ #: (VCard::Parser::Line line) -> (Modeled | Unrecognized | Unreadable)
+ def self.read(line)
+ property = line.property
+ return Unreadable.new unless property&.name&.casecmp?("BDAY")
+
+ birthday = Birthday.from_property(property)
+ birthday ? Modeled.new(birthday:) : Unrecognized.new
+ end
+ end
+end
diff --git a/lib/pro_tacts/store.rb b/lib/pro_tacts/store.rb
index eaa4b01..28528a0 100644
--- a/lib/pro_tacts/store.rb
+++ b/lib/pro_tacts/store.rb
@@ -5,6 +5,7 @@ require "sequel"
require "sentry-ruby"
require "pro_tacts/birthday"
+require "pro_tacts/birthday_line"
require "pro_tacts/card_diff"
require "pro_tacts/contact"
require "pro_tacts/vcard"
@@ -367,49 +368,42 @@ module ProTacts
# the bind is the third line.
#: (String id, VCard vcard) -> Contact
def put(id, vcard)
- # The birthday half of the split a write makes, one arm per shape
- # a submitted card's BDAY lines can take. One line that reads as
- # a modeled birthday moves out of the card and into the model;
- # any other BDAY — the vCard 4.0 forms, a foreign sentinel, a
- # line that will not parse, a BDAY sharing its line's bytes with
- # another, more than one — is data the model
- # cannot recompose and stays in the card byte for byte, with the
- # model emptied so nothing composes a second BDAY beside it
- # (RFC 6352 section 6.3.2.2). No BDAY at all is a client's
- # rewrite, and macOS Contacts drops the BDAY lines it cannot
- # render from every card it writes (docs/apple-contacts.md):
- # what the stored card held in a spelling no client renders
- # rides across the rewrite, what it held that a client could
- # see was the user's deletion, an unseen model row survives as
- # nothing a client ever saw, and what nobody recognizes is
- # reported rather than lost in silence.
+ # The birthday half of the split a write makes
+ # (docs/plans/2026-09-11-every-birthday-in-the-model.md). A card
+ # holds at most one BDAY, and one that reads as a birthday leaves
+ # the card for the model. Any other stays in the card byte for
+ # byte, reported, with the model emptied so nothing composes a
+ # second BDAY beside it (RFC 6352 section 6.3.2.2).
existing = birthday_of(id)
# The card as it stands before this write, read once for the two
- # halves that need it: the rewrite arm below, which carries
- # unrendered BDAY lines across, and the subtraction after the
- # case, which accounts for the member's own lines before
- # attributing any to a group.
+ # halves that need it: the no-BDAY arm below, which reports what
+ # it drops, and the subtraction after it, which accounts for the
+ # member's own lines before attributing any to a group.
own = stored_card(id)
+ bdays, rest = vcard.extract("BDAY")
birthday, stored =
- case vcard.extract("BDAY")
- in [[line], rest]
- report_unrecognized_bday_lines([line])
- property = bday_of(line)
- birthday = property && Birthday.from_property(property)
- [birthday, birthday ? rest : vcard]
- in [[], _]
- # The rewrite arm: carry the unrendered lines out of the
- # stored card, report the unrecognized ones' loss, and keep
- # whatever an unseen model row holds.
- carried, lost = carried_and_lost_bday_lines(own)
- kept = existing && !existing.served? ? existing : nil
- report_lost_bday_lines(lost)
- [kept, vcard.insert(carried)]
- in [lines, _]
- # More than one BDAY: cardinality-broken data, kept verbatim
- # and reported like any other unrecognized line.
- report_unrecognized_bday_lines(lines)
+ if bdays.empty?
+ # No BDAY: a client that was sent the birthday removed it, and
+ # one that was never sent it cannot have. A BDAY line left in
+ # the stored card goes with the rewrite, which is worth saying.
+ report_lost_bday_lines(own)
+ [existing && !existing.served? ? existing : nil, vcard]
+ elsif bdays.length > 1
+ report_many_bday_lines(bdays.length)
[nil, vcard]
+ else
+ line = BirthdayLine.read(bdays.fetch(0))
+ case line
+ when BirthdayLine::Modeled
+ [line.birthday, rest]
+ when BirthdayLine::Unrecognized
+ report_unrecognized_bday_line
+ [nil, vcard]
+ when BirthdayLine::Unreadable
+ [nil, vcard]
+ else
+ raise "no arm for #{line.class}"
+ end
end
# The other half of the split, the same shape as the birthday's:
@@ -832,63 +826,21 @@ module ProTacts
row && VCard.new(row.fetch(:vcard).to_s)
end
- # The BDAY a decision may be made of: the line's property, when it
- # has one and it names BDAY. Found by name rather than position,
- # and safe to act on because the line is the unit a rewrite or a
- # move to the model carries and a line holds at most one property
- # (VCard::Parser::Line) — so moving its bytes moves this and
- # nothing else. Nil for any other shape, the empty line a failed
- # read leaves included.
- #: (VCard::Parser::Line line) -> VCard::Parser::Property?
- def bday_of(line)
- property = line.property
- property if property&.name&.casecmp?("BDAY")
- end
-
- # A stored card's BDAY lines in two piles: the verbatim lines a
- # rewrite carries across — values no client renders — and the ones
- # it drops unwitnessed, which no client renders and no whitelist
- # recognizes. Lines a client rendered are in neither pile: their
- # absence is a deletion the rewrite already honors.
- #: (VCard? vcard) -> [Array[String], Array[VCard::Parser::Line]]
- def carried_and_lost_bday_lines(vcard)
- return [[], []] if vcard.nil?
-
- bdays, = vcard.extract("BDAY")
- carried, rest = bdays.partition { |line|
- property = bday_of(line)
- property && Birthday.unrendered_value?(property.value)
- }
- lost = rest.reject { |line|
- property = bday_of(line)
- property && Birthday.rendered?(property)
- }
- [carried.map(&:verbatim), lost]
- end
-
- # The arrival report: a BDAY line this server can neither model,
- # recognize as rendered, nor recognize as carried is unexpected
+ # The arrival report: a BDAY the model does not take is unexpected
# input, and storing it verbatim would be the last anyone heard of
# it. The message carries no card content
- # (ProTacts::SentryScrubber's line); the values stay on the
- # machine, where the admin view shows them raw.
- #: (Array[VCard::Parser::Line] lines) -> void
- def report_unrecognized_bday_lines(lines)
- unrecognized = lines.count { |line|
- # A line with no property is one this server could not read at
- # all, and a value it never read is not a value it failed to
- # recognize. The line's bytes are stored either way.
- next false if line.property.nil?
-
- property = bday_of(line)
- property.nil? || (!Birthday.rendered?(property) && !Birthday.unrendered_value?(property.value))
- }
- return if unrecognized.zero?
+ # (ProTacts::SentryScrubber's line); the value stays on the
+ # machine, where the admin view shows it raw.
+ #: () -> void
+ def report_unrecognized_bday_line
+ Sentry.capture_message("a submitted card carried a BDAY line the model does not take", level: :warning)
+ end
- Sentry.capture_message(
- "a submitted card carried #{unrecognized} BDAY line(s) no client renders and no whitelist recognizes",
- level: :warning,
- )
+ # A contact has one birthday, so a card with several cannot say
+ # which it means, whatever each line holds.
+ #: (Integer count) -> void
+ def report_many_bday_lines(count)
+ Sentry.capture_message("a submitted card carried #{count} BDAY lines", level: :warning)
end
# The submitted card with the lines its groups lend it taken back
@@ -1158,7 +1110,7 @@ module ProTacts
# attribute — two lines of that name arrived that the member's card
# does not explain, and calling either the edit would be a guess.
# The card is stored as it arrived and the line is news, the same
- # bargain report_unrecognized_bday_lines makes, and carries no card
+ # bargain report_unrecognized_bday_line makes, and carries no card
# content for the same reason (ProTacts::SentryScrubber).
#: (Integer count) -> void
def report_ambiguous_inherited_lines(count)
@@ -1189,17 +1141,17 @@ module ProTacts
)
end
- # The loss report, the rewrite's half of the arrival one: a stored
- # BDAY no client renders and no whitelist recognizes is about to be
- # dropped, and nobody would know.
- #: (Array[VCard::Parser::Line] lines) -> void
- def report_lost_bday_lines(lines)
+ # The loss report, the rewrite's half of the arrival one: a BDAY
+ # line still in the stored card is one the model did not take, and a
+ # rewrite without it is about to drop it with nobody told.
+ #: (VCard? own) -> void
+ def report_lost_bday_lines(own)
+ return if own.nil?
+
+ lines, = own.extract("BDAY")
return if lines.empty?
- Sentry.capture_message(
- "a rewrite dropped #{lines.length} BDAY line(s) no client renders and no whitelist carries",
- level: :warning,
- )
+ Sentry.capture_message("a rewrite dropped #{lines.length} BDAY line(s) the model did not take", level: :warning)
end
# The birthday travels beside the card rather than inside it, and
diff --git a/sig/pro_tacts/birthday.rbs b/sig/pro_tacts/birthday.rbs
index d217ba0..100f552 100644
--- a/sig/pro_tacts/birthday.rbs
+++ b/sig/pro_tacts/birthday.rbs
@@ -3,15 +3,15 @@
# birthday.rb and signed here instead.
module ProTacts
class Birthday
- FULL_DATE: Regexp
- OMIT_YEAR_DATE: Regexp
+ NO_YEAR: Integer
OMIT_YEAR: String
+ FULL_DATE: Regexp
+ MONTH_AND_DAY: Regexp
YEAR_AND_MONTH: Regexp
YEAR_ALONE: Regexp
MONTH_ALONE: Regexp
DAY_ALONE: Regexp
- UNRENDERED_VALUES: Array[Regexp]
- REDUCED_DATE: Regexp
+ OMIT_YEAR_DATE: Regexp
attr_reader year: Integer?
attr_reader month: Integer?
@@ -25,16 +25,12 @@ module ProTacts
def served?: () -> bool
- def self.from_value: (String value) -> Birthday?
-
def self.from_property: (VCard::Parser::Property property) -> Birthday?
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
-
- def self.rendered?: (VCard::Parser::Property property) -> bool
+ def self.from_value: (String value) -> Birthday?
end
end
diff --git a/sig/pro_tacts/birthday_line.rbs b/sig/pro_tacts/birthday_line.rbs
new file mode 100644
index 0000000..05fc917
--- /dev/null
+++ b/sig/pro_tacts/birthday_line.rbs
@@ -0,0 +1,21 @@
+# BirthdayLine's variants are Data classes, whose members the inline
+# parser cannot read (see sig/pro_tacts/birthday.rbs for the same
+# limit). Each is marked `@rbs skip` in birthday_line.rb and signed
+# here; the module's own reader carries its signature inline.
+module ProTacts
+ module BirthdayLine
+ class Modeled
+ attr_reader birthday: Birthday
+
+ def self.new: (birthday: Birthday) -> instance
+ end
+
+ class Unrecognized
+ def self.new: () -> instance
+ end
+
+ class Unreadable
+ def self.new: () -> instance
+ end
+ end
+end
diff --git a/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response b/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
index aee624a..567431e 100644
--- a/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/ios-exchange/03-propfind-addressbook-bootstrap/response
@@ -51,7 +51,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-basic-noyear.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"bbc86afedab3b3dfa349da2ff6ed736922389a68074fc7a7905b40b1825bc425"</d:getetag>
+ <d:getetag>"81f0da34c506a6c92460dda5bf490dc4282cdf83c8b5d0b99bc6f4348f2bbd7b"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -78,7 +78,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-day-only.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"25ef184d775a0c7551be4cf5397292a36982d89565aa33e314d65bc0aae0299c"</d:getetag>
+ <d:getetag>"63611249675e47a061f15a77ec97c1c3c370915f6772aeb827a9dbc8b5223d2a"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -87,7 +87,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-extended-noyear.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"303991514fc3eb60721a39f014cf533a407039f558b4dfc5556bfc31c35173e9"</d:getetag>
+ <d:getetag>"6dca6844193e0f29ff9b8ef6051bbe6d049d0d649467404edaa84b2abea3f6e9"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -96,7 +96,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-month-only.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"6aafe94f0d0857d91ee3a8bba7a49f9ceb758270cb5a2b4d35e7f48026494881"</d:getetag>
+ <d:getetag>"c6fdf9e92e169a012b5afedb252156de2e447a31f4ad2e9a2956504111203776"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -114,7 +114,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-year-month.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"aa31020a69a1123e0c3bfefa0cf34a8ecf8c58e2f4af6f293c62920a06a26357"</d:getetag>
+ <d:getetag>"5444c5bd0fad1dd3bccb1111f52590d329078e28d9003d1c65fd0ba2f7544ec7"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -123,7 +123,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-year-only.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"05e62746a7c24908a89e3ea9f4333a4033ed18d7398b1ff1c1ad192850924313"</d:getetag>
+ <d:getetag>"f8d1a272129107f5575cc0f3b186a4c830ae5d49294df31f46a80c42f5840772"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
diff --git a/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
index b17fc5b..9e9de80 100644
--- a/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
+++ b/test/fixtures/macos-exchange/06-propfind-addressbook-listing/response
@@ -26,7 +26,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-basic-noyear.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"bbc86afedab3b3dfa349da2ff6ed736922389a68074fc7a7905b40b1825bc425"</d:getetag>
+ <d:getetag>"81f0da34c506a6c92460dda5bf490dc4282cdf83c8b5d0b99bc6f4348f2bbd7b"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -53,7 +53,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-day-only.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"25ef184d775a0c7551be4cf5397292a36982d89565aa33e314d65bc0aae0299c"</d:getetag>
+ <d:getetag>"63611249675e47a061f15a77ec97c1c3c370915f6772aeb827a9dbc8b5223d2a"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -62,7 +62,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-extended-noyear.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"303991514fc3eb60721a39f014cf533a407039f558b4dfc5556bfc31c35173e9"</d:getetag>
+ <d:getetag>"6dca6844193e0f29ff9b8ef6051bbe6d049d0d649467404edaa84b2abea3f6e9"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -71,7 +71,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-month-only.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"6aafe94f0d0857d91ee3a8bba7a49f9ceb758270cb5a2b4d35e7f48026494881"</d:getetag>
+ <d:getetag>"c6fdf9e92e169a012b5afedb252156de2e447a31f4ad2e9a2956504111203776"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -89,7 +89,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-year-month.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"aa31020a69a1123e0c3bfefa0cf34a8ecf8c58e2f4af6f293c62920a06a26357"</d:getetag>
+ <d:getetag>"5444c5bd0fad1dd3bccb1111f52590d329078e28d9003d1c65fd0ba2f7544ec7"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -98,7 +98,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-year-only.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"05e62746a7c24908a89e3ea9f4333a4033ed18d7398b1ff1c1ad192850924313"</d:getetag>
+ <d:getetag>"f8d1a272129107f5575cc0f3b186a4c830ae5d49294df31f46a80c42f5840772"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
diff --git a/test/fixtures/macos-exchange/08-report-sync-collection/response b/test/fixtures/macos-exchange/08-report-sync-collection/response
index 56d1a44..f6c4ddb 100644
--- a/test/fixtures/macos-exchange/08-report-sync-collection/response
+++ b/test/fixtures/macos-exchange/08-report-sync-collection/response
@@ -16,7 +16,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-basic-noyear.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"bbc86afedab3b3dfa349da2ff6ed736922389a68074fc7a7905b40b1825bc425"</d:getetag>
+ <d:getetag>"81f0da34c506a6c92460dda5bf490dc4282cdf83c8b5d0b99bc6f4348f2bbd7b"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -43,7 +43,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-day-only.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"25ef184d775a0c7551be4cf5397292a36982d89565aa33e314d65bc0aae0299c"</d:getetag>
+ <d:getetag>"63611249675e47a061f15a77ec97c1c3c370915f6772aeb827a9dbc8b5223d2a"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -52,7 +52,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-extended-noyear.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"303991514fc3eb60721a39f014cf533a407039f558b4dfc5556bfc31c35173e9"</d:getetag>
+ <d:getetag>"6dca6844193e0f29ff9b8ef6051bbe6d049d0d649467404edaa84b2abea3f6e9"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -61,7 +61,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-month-only.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"6aafe94f0d0857d91ee3a8bba7a49f9ceb758270cb5a2b4d35e7f48026494881"</d:getetag>
+ <d:getetag>"c6fdf9e92e169a012b5afedb252156de2e447a31f4ad2e9a2956504111203776"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -79,7 +79,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-year-month.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"aa31020a69a1123e0c3bfefa0cf34a8ecf8c58e2f4af6f293c62920a06a26357"</d:getetag>
+ <d:getetag>"5444c5bd0fad1dd3bccb1111f52590d329078e28d9003d1c65fd0ba2f7544ec7"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -88,7 +88,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-year-only.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"05e62746a7c24908a89e3ea9f4333a4033ed18d7398b1ff1c1ad192850924313"</d:getetag>
+ <d:getetag>"f8d1a272129107f5575cc0f3b186a4c830ae5d49294df31f46a80c42f5840772"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
diff --git a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
index aee624a..567431e 100644
--- a/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
+++ b/test/fixtures/macos-exchange/09-propfind-addressbook-bootstrap/response
@@ -51,7 +51,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-basic-noyear.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"bbc86afedab3b3dfa349da2ff6ed736922389a68074fc7a7905b40b1825bc425"</d:getetag>
+ <d:getetag>"81f0da34c506a6c92460dda5bf490dc4282cdf83c8b5d0b99bc6f4348f2bbd7b"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -78,7 +78,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-day-only.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"25ef184d775a0c7551be4cf5397292a36982d89565aa33e314d65bc0aae0299c"</d:getetag>
+ <d:getetag>"63611249675e47a061f15a77ec97c1c3c370915f6772aeb827a9dbc8b5223d2a"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -87,7 +87,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-extended-noyear.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"303991514fc3eb60721a39f014cf533a407039f558b4dfc5556bfc31c35173e9"</d:getetag>
+ <d:getetag>"6dca6844193e0f29ff9b8ef6051bbe6d049d0d649467404edaa84b2abea3f6e9"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -96,7 +96,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-month-only.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"6aafe94f0d0857d91ee3a8bba7a49f9ceb758270cb5a2b4d35e7f48026494881"</d:getetag>
+ <d:getetag>"c6fdf9e92e169a012b5afedb252156de2e447a31f4ad2e9a2956504111203776"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -114,7 +114,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-year-month.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"aa31020a69a1123e0c3bfefa0cf34a8ecf8c58e2f4af6f293c62920a06a26357"</d:getetag>
+ <d:getetag>"5444c5bd0fad1dd3bccb1111f52590d329078e28d9003d1c65fd0ba2f7544ec7"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
@@ -123,7 +123,7 @@ Content-Type: text/xml
<d:href>/dav/addressbook/bday-year-only.vcf</d:href>
<d:propstat>
<d:prop>
- <d:getetag>"05e62746a7c24908a89e3ea9f4333a4033ed18d7398b1ff1c1ad192850924313"</d:getetag>
+ <d:getetag>"f8d1a272129107f5575cc0f3b186a4c830ae5d49294df31f46a80c42f5840772"</d:getetag>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
diff --git a/test/pro_tacts/admin/test_contacts_pages.rb b/test/pro_tacts/admin/test_contacts_pages.rb
index 36f3eb0..5206828 100644
--- a/test/pro_tacts/admin/test_contacts_pages.rb
+++ b/test/pro_tacts/admin/test_contacts_pages.rb
@@ -1535,7 +1535,7 @@ class AdminContactsPagesTest < Minitest::Test
# submitted birthday would compose a second BDAY beside it. Refused
# whole, name edit included: one submit, one write.
def test_a_birthday_against_a_cards_own_spelling_is_refused
- resident = ADA.sub("BDAY:1985-12-10\r\n", "BDAY:1985-04\r\n")
+ resident = ADA.sub("BDAY:1985-12-10\r\n", "BDAY:1985-13\r\n")
with_contacts({"ada" => resident}) do |store|
assert_nil store.contact("ada").birthday
@@ -1549,7 +1549,7 @@ class AdminContactsPagesTest < Minitest::Test
contact = store.contact("ada")
assert_nil contact.birthday
assert_equal 1, contact.vcard.to_s.scan("BDAY").length
- assert_includes contact.vcard.to_s, "BDAY:1985-04\r\n"
+ assert_includes contact.vcard.to_s, "BDAY:1985-13\r\n"
assert_includes contact.vcard.to_s, "FN:Ada Lovelace\r\n"
end
end
diff --git a/test/pro_tacts/admin/test_format.rb b/test/pro_tacts/admin/test_format.rb
index 89c3943..937656c 100644
--- a/test/pro_tacts/admin/test_format.rb
+++ b/test/pro_tacts/admin/test_format.rb
@@ -77,10 +77,9 @@ class FormatTest < Minitest::Test
assert_equal "December 10", ProTacts::Admin::Format.birthday(contact(CARD, birthday:))
end
- # The reduced no-year spelling (Birthday::REDUCED_DATE), which macOS
- # reads on a card it did not write: the model does not recompose it,
- # so the line stays in the card verbatim — display renders it as a
- # day all the same, not as the stored string.
+ # A month-day spelling still in a card rather than the model: a write
+ # moves it out, and display reads it the way a write would
+ # (Birthday::MONTH_AND_DAY), not as the stored string.
def test_a_reduced_no_year_birthday_displays_its_day
reduced = CARD.sub("END:VCARD\r\n", "BDAY:--12-10\r\nEND:VCARD\r\n")
diff --git a/test/pro_tacts/test_birthday.rb b/test/pro_tacts/test_birthday.rb
index 0072c80..28f2584 100644
--- a/test/pro_tacts/test_birthday.rb
+++ b/test/pro_tacts/test_birthday.rb
@@ -45,9 +45,8 @@ class BirthdayTest < Minitest::Test
assert_equal "BDAY;X-APPLE-OMIT-YEAR=1604:1604-04-12", ProTacts::Birthday.new(month: 4, day: 12).to_line
end
- # The four shapes no client has been verified to accept reach no wire;
- # this is the set the deletion rule treats as invisible.
- def test_the_shapes_without_a_verified_spelling_serve_nothing
+ # The four shapes no client displays are never sent.
+ def test_the_shapes_no_client_displays_serve_nothing
[ProTacts::Birthday.new(year: 1985, month: 4), ProTacts::Birthday.new(year: 1985),
ProTacts::Birthday.new(month: 4), ProTacts::Birthday.new(day: 12)].each do |birthday|
assert_nil birthday.to_line
@@ -88,9 +87,18 @@ class BirthdayTest < Minitest::Test
ProTacts::VCard::Parser::Property.new(group:, name: "BDAY", parameters:, value:)
end
- def test_a_full_date_reads_into_the_model
- assert_equal ProTacts::Birthday.new(year: 1985, month: 4, day: 12),
- ProTacts::Birthday.from_property(property("1985-04-12"))
+ def test_every_well_shaped_spelling_reads
+ {
+ "1985-04-12" => ProTacts::Birthday.new(year: 1985, month: 4, day: 12),
+ "--0412" => ProTacts::Birthday.new(month: 4, day: 12),
+ "--04-12" => ProTacts::Birthday.new(month: 4, day: 12),
+ "1985-04" => ProTacts::Birthday.new(year: 1985, month: 4),
+ "1985" => ProTacts::Birthday.new(year: 1985),
+ "--04" => ProTacts::Birthday.new(month: 4),
+ "---12" => ProTacts::Birthday.new(day: 12),
+ }.each do |value, birthday|
+ assert_equal birthday, ProTacts::Birthday.from_property(property(value)), value
+ end
end
# docs/apple-contacts.md, "Birthdays without a year": the verified
@@ -101,6 +109,14 @@ class BirthdayTest < Minitest::Test
assert_equal ProTacts::Birthday.new(month: 4, day: 12), ProTacts::Birthday.from_property(sentinel)
end
+ # iOS writes the sentinel back without its parameter, so 1604 means no
+ # year on its own (docs/plans/2026-09-11-every-birthday-in-the-model.md).
+ def test_a_1604_date_has_no_year
+ [property("1604-03-08"), property("1604-03-08", parameters: [["value", "date"]])].each do |ios|
+ assert_equal ProTacts::Birthday.new(month: 3, day: 8), ProTacts::Birthday.from_property(ios)
+ end
+ end
+
# RFC 2426 section 3.1.5 lets BDAY carry a date and time; the time is
# the one thing ingest deliberately loses, because a birthday is a
# date — the design record's decision, not an oversight.
@@ -129,109 +145,19 @@ class BirthdayTest < Minitest::Test
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
- # 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
-
- # Everything the model cannot recompose reads as nil, keeping it in
- # the card verbatim (RFC 6352 section 6.3.2.2): extra parameters
- # travel with their line, and a grouped property's label pairing
- # belongs to the card.
- def test_what_the_model_cannot_recompose_reads_as_nil
- [property("--0412"), property("1985-04"), property("19850412"),
+ # Everything else reads as nil and stays in the card verbatim (RFC
+ # 6352 section 6.3.2.2): a value out of range or in no spelling this
+ # reads, a parameter that travels with its line, the sentinel's
+ # parameter on a date it does not stand in for, and a grouped
+ # property, whose label pairing belongs to the card.
+ def test_what_the_model_does_not_take_reads_as_nil
+ [property("1985-13-40"), property("--0432"), property("19850412"), property("banana"), property(""),
property("1985-04-12", parameters: [["X-OTHER", "1"]]),
property("1985-04-12", parameters: [["VALUE", "text"]]),
+ property("1985-04-12", parameters: [["X-APPLE-OMIT-YEAR", "1604"]]),
property("1604-04-12", parameters: [["X-APPLE-OMIT-YEAR", "1604"], ["X-OTHER", "1"]]),
- property("1985-04-12", group: "item1")].each do |unmodeled|
+ property("1985-04-12", group: "item1"), property("--0412", group: "item1")].each do |unmodeled|
assert_nil ProTacts::Birthday.from_property(unmodeled), unmodeled.value
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
-
- # 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
- ["1985-04", "1985", "--04", "---12"].each do |value|
- assert ProTacts::Birthday.unrendered_value?(value), value
- end
- end
-
- # The rendered set matches none of the four, and a value the grammar
- # will not read matches nothing. The padded pair pins the anchors —
- # each pattern is anchored alone, so no spelling matches inside a
- # longer or padded value.
- def test_unrendered_value_refuses_everything_else
- ["1985-04-12", "--0412", "--04-12", "19850412", "1985-4", "1985-13", "--00", "", "x1985", "1985-04\n"].each do |value|
- refute ProTacts::Birthday.unrendered_value?(value), value
- end
- end
-
- ## the values a client renders
-
- # from_property's two spellings, the reduced no-year values macOS
- # additionally reads, and their case: a client that renders a BDAY
- # can have deleted it, so its absence from a rewrite is a removal.
- 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"),
- property("1985-04-12", parameters: [["value", "date"]]),
- property("--0412", parameters: [["value", "date"]])].each do |rendered|
- assert ProTacts::Birthday.rendered?(rendered), rendered.value
- end
- end
-
- # What no client renders — the carried shapes, the out-of-range, the
- # unrecognizable — is not rendered, and a rewrite dropping it is
- # something Store reports rather than a deletion it honors.
- def test_rendered_refuses_what_no_client_shows
- [property("1985-04"), property("1985"), property("--04"), property("---12"),
- property("1985-13"), property("--0432"), property("19850412"),
- property("banana"),
- property("--0412", group: "item1")].each do |unrendered|
- refute ProTacts::Birthday.rendered?(unrendered), unrendered.value
- end
- end
end
diff --git a/test/pro_tacts/test_store.rb b/test/pro_tacts/test_store.rb
index 4667c87..481b4c7 100644
--- a/test/pro_tacts/test_store.rb
+++ b/test/pro_tacts/test_store.rb
@@ -752,7 +752,7 @@ class StoreTest < Minitest::Test
# The card's own line speaks for itself, and nothing composes a
# second one beside it. A fold travels with its line, byte for byte.
def test_an_unmodeled_bday_stays_in_the_card_and_empties_the_model
- ["BDAY:--0412", "BDAY:1985-\r\n 04\r\n", "BDAY:1985-04-12\r\nBDAY:1986-04-12\r\n"].each do |bday|
+ ["BDAY:1985-13", "BDAY:1985-\r\n 13\r\n", "BDAY:1985-04-12\r\nBDAY:1986-04-12\r\n"].each do |bday|
unmodeled = AIDEN.sub("END:VCARD\r\n", "#{bday}END:VCARD\r\n")
with_store({"aiden" => AIDEN_BORN}) do |store|
@@ -783,27 +783,42 @@ class StoreTest < Minitest::Test
end
end
- # macOS Contacts drops the lines it cannot render from every card it
- # writes, so a rewrite that omits the BDAY carries them across rather
- # than reading the absence as a deletion (docs/apple-contacts.md, "A
- # birthday the client cannot render is dropped from the card").
- def test_a_birthday_no_client_renders_survives_a_rewrite_that_drops_it
- ["BDAY:1985-04", "BDAY:1985", "BDAY:--04", "BDAY:---12"].each do |line|
+ # The four shapes no client displays go into the model and are never
+ # sent, so a card sent back without them has not deleted them.
+ def test_a_birthday_no_client_is_sent_survives_a_rewrite
+ {
+ "BDAY:1985-04" => ProTacts::Birthday.new(year: 1985, month: 4),
+ "BDAY:1985" => ProTacts::Birthday.new(year: 1985),
+ "BDAY:--04" => ProTacts::Birthday.new(month: 4),
+ "BDAY:---12" => ProTacts::Birthday.new(day: 12),
+ }.each do |line, birthday|
with_store({"aiden" => AIDEN.sub("END:VCARD\r\n", "#{line}\r\nEND:VCARD\r\n")}) do |store|
- edited = AIDEN.sub("FN:Aiden", "FN:Aiden Smith")
+ assert_equal AIDEN, store.contact("aiden").vcard.to_s, line
+ edited = AIDEN.sub("FN:Aiden", "FN:Aiden Smith")
store.put("aiden", vcard(edited))
- assert_equal edited.sub("END:VCARD\r\n", "#{line}\r\nEND:VCARD\r\n"), store.contact("aiden").vcard.to_s, line
- assert_nil birthday_row(store, "aiden"), line
+ assert_equal edited, store.contact("aiden").vcard.to_s, line
+ assert_equal birthday, birthday_row(store, "aiden"), line
+ end
+ end
+ end
+
+ # A month and day goes out as the sentinel whatever spelling it came
+ # in, including the extended one iOS misreads as a year-2 date and the
+ # parameterless 1604 iOS writes back.
+ def test_a_month_and_day_in_any_spelling_goes_out_as_the_sentinel
+ ["BDAY:--11-27", "BDAY:--1127", "BDAY;value=date:1604-11-27"].each do |line|
+ with_store({"aiden" => AIDEN.sub("END:VCARD\r\n", "#{line}\r\nEND:VCARD\r\n")}) do |store|
+ assert_equal ProTacts::Birthday.new(month: 11, day: 27), birthday_row(store, "aiden"), line
+ assert_equal AIDEN.sub("END:VCARD\r\n", "BDAY;X-APPLE-OMIT-YEAR=1604:1604-11-27\r\nEND:VCARD\r\n"),
+ store.contact("aiden").vcard.to_s, line
end
end
end
- # The divider is the shape, not the spelling: macOS renders --0412,
- # so a rewrite without it has removed a birthday the client could
- # see, and the deletion is honored. Honoring it is what keeps these
- # deletable at all.
+ # --0412 goes out as the sentinel, so a rewrite without it removed a
+ # birthday the client was sent, and the deletion is honored.
def test_a_birthday_a_client_renders_is_deleted_by_a_rewrite_without_it
with_store({"aiden" => AIDEN.sub("END:VCARD\r\n", "BDAY:--0412\r\nEND:VCARD\r\n")}) do |store|
store.put("aiden", vcard(AIDEN))
@@ -813,9 +828,8 @@ class StoreTest < Minitest::Test
end
end
- # The rewrite's half of the same rule: a shared line is not carried
- # (carrying it would carry its fellow bytes too), so dropping it is
- # reported rather than silent.
+ # A shared line never reaches the model, so it stays in the stored
+ # card, and a rewrite dropping it is reported rather than silent.
def test_a_rewrite_dropping_a_shared_bday_line_is_reported
shared = AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04\rNOTE:b\r\nEND:VCARD\r\n")
edited = AIDEN.sub("FN:Aiden", "FN:Aiden Smith")
@@ -829,9 +843,9 @@ class StoreTest < Minitest::Test
end
end
- # A submission carrying any BDAY replaces what was there, carried
- # line included: PUT is a whole-card replace, not a merge.
- def test_a_submitted_birthday_replaces_the_carried_line
+ # A submission carrying any BDAY replaces what was there, a birthday
+ # no client was sent included: PUT is a whole-card replace.
+ def test_a_submitted_birthday_replaces_one_no_client_was_sent
with_store({"aiden" => AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04\r\nEND:VCARD\r\n")}) do |store|
born = AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nEND:VCARD\r\n")
@@ -843,8 +857,8 @@ class StoreTest < Minitest::Test
end
# The loss report, the rewrite's half of the arrival one: a stored
- # BDAY no client renders and no whitelist recognizes is about to be
- # dropped, and nobody would know.
+ # BDAY the model did not take is about to be dropped, and nobody
+ # would know.
def test_a_rewrite_dropping_an_unrecognized_bday_is_reported
with_store({"aiden" => AIDEN.sub("END:VCARD\r\n", "BDAY:1985-13\r\nEND:VCARD\r\n")}) do |store|
# The seed's own arrival report is not this test's subject.
@@ -857,9 +871,7 @@ class StoreTest < Minitest::Test
end
end
- # A rewrite's quiet cases: a carried line survives (asserted above),
- # a rendered one was deleted by a user who could see it, and a card
- # with no BDAY at all has nothing to say.
+ # A rewrite over birthdays the model holds has no line to drop.
def test_a_rewrite_over_known_bdays_stays_quiet
["BDAY:--0412", "BDAY:1985-04"].each do |line|
with_store({"aiden" => AIDEN.sub("END:VCARD\r\n", "#{line}\r\nEND:VCARD\r\n")}) do |store|
@@ -870,13 +882,11 @@ class StoreTest < Minitest::Test
end
end
- # The arrival report: a submitted BDAY this server can neither model,
- # recognize as rendered, nor recognize as carried is unexpected input,
- # and storing it verbatim would be the last anyone heard of it.
- # Known forms — the modeled spellings, the reduced values macOS
- # reads, the carried shapes — stay quiet.
+ # The arrival report: a submitted BDAY the model does not take is
+ # unexpected input, and storing it verbatim would be the last anyone
+ # heard of it.
def test_an_unrecognized_bday_arriving_is_reported
- ["BDAY:1985-13", "BDAY:--0432", "BDAY:19850412", "BDAY:1985-4"].each do |line|
+ ["BDAY:1985-13", "BDAY:--0432", "BDAY:19850412", "BDAY:1985-4", "item1.BDAY:1985-04-12"].each do |line|
with_store({}) do |store|
clear_sentry_events
store.put("aiden", vcard(AIDEN.sub("END:VCARD\r\n", "#{line}\r\nEND:VCARD\r\n")))
@@ -888,7 +898,8 @@ class StoreTest < Minitest::Test
def test_a_bday_the_server_knows_arrives_quietly
["BDAY:1985-04-12", "BDAY:1985-04-12T23:10:00Z", "BDAY;X-APPLE-OMIT-YEAR=1604:1604-04-12",
- "BDAY:--0412", "BDAY:--04-12", "BDAY:1985-04", "BDAY:1985"].each do |line|
+ "BDAY:--0412", "BDAY:--04-12", "BDAY:1985-04", "BDAY:1985", "BDAY:--04", "BDAY:---12",
+ "BDAY;value=date:1985-04-12"].each do |line|
with_store({}) do |store|
store.put("aiden", vcard(AIDEN.sub("END:VCARD\r\n", "#{line}\r\nEND:VCARD\r\n")))
@@ -897,6 +908,18 @@ class StoreTest < Minitest::Test
end
end
+ # A contact has one birthday, so several BDAY lines are reported even
+ # when each would read on its own.
+ def test_several_bday_lines_are_reported_whatever_they_hold
+ with_store({}) do |store|
+ store.put("aiden", vcard(AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nBDAY:1986-04-12\r\nEND:VCARD\r\n")))
+ messages = sentry_messages
+
+ assert_equal 1, messages.length
+ assert_match "2 BDAY lines", messages.fetch(0)
+ end
+ end
+
## The change log
def test_a_stored_card_is_never_indexed_with_a_bday
with_store({"aiden" => AIDEN_BORN}) do |store|
@@ -1774,7 +1797,7 @@ class StoreTest < Minitest::Test
Sequel.connect("sqlite://#{path}") do |db|
Sequel::Migrator.run(db, ProTacts::Store::MIGRATIONS.to_s, target: 1)
db[:cards].insert(id: "aiden", vcard: AIDEN_BORN)
- db[:cards].insert(id: "znorth", vcard: ZED.sub("END:VCARD\r\n", "BDAY:--0412\r\nEND:VCARD\r\n"))
+ db[:cards].insert(id: "znorth", vcard: ZED.sub("END:VCARD\r\n", "BDAY:19850412\r\nEND:VCARD\r\n"))
db[:cards].insert(id: "xavi", vcard: shared)
end
@@ -1784,7 +1807,7 @@ class StoreTest < Minitest::Test
assert_equal AIDEN.sub("END:VCARD\r\n", "BDAY:1985-04-12\r\nEND:VCARD\r\n"), store.contact("aiden").vcard.to_s
# Unmodeled: byte-identical, no birthday row beside it.
- assert_equal ZED.sub("END:VCARD\r\n", "BDAY:--0412\r\nEND:VCARD\r\n"), store.contact("znorth").vcard.to_s
+ assert_equal ZED.sub("END:VCARD\r\n", "BDAY:19850412\r\nEND:VCARD\r\n"), store.contact("znorth").vcard.to_s
assert_nil birthday_row(store, "znorth")
# A BDAY sharing its line's bytes stays put: the line moves as
diff --git a/test/pro_tacts/test_web.rb b/test/pro_tacts/test_web.rb
index 53729e1..e33aeee 100644
--- a/test/pro_tacts/test_web.rb
+++ b/test/pro_tacts/test_web.rb
@@ -384,24 +384,26 @@ class WebTest < Minitest::Test
end
end
- # The same omission for the rewrite that carries a birthday across:
- # the stored card is the submission plus the BDAY line macOS dropped,
- # so the answer cannot claim the tag — the client refetches, and the
- # refetch is what shows the birthday survived its edit.
- def test_a_put_that_carries_a_birthday_back_goes_without_the_strong_etag
- unrendered = card("new", "New").sub("END:VCARD\r\n", "BDAY:1985-04\r\nEND:VCARD\r\n")
+ # A birthday no client is sent is not in the card a client downloads
+ # (docs/plans/2026-09-11-every-birthday-in-the-model.md), so sending
+ # that card back is the served card octet for octet and keeps the
+ # tag — and keeps the birthday.
+ def test_a_birthday_no_client_is_sent_stays_off_the_wire
+ unserved = card("new", "New").sub("END:VCARD\r\n", "BDAY:1985-04\r\nEND:VCARD\r\n")
- with_contacts({}) do
- put_request "new", unrendered, "CONTENT_TYPE" => VCARD, "HTTP_IF_NONE_MATCH" => "*"
-
- edited = card("new", "New Smith")
- put_request "new", edited, "CONTENT_TYPE" => VCARD
-
- assert_equal 204, last_response.status
+ with_contacts({}) do |store|
+ put_request "new", unserved, "CONTENT_TYPE" => VCARD, "HTTP_IF_NONE_MATCH" => "*"
assert_nil last_response["ETag"]
get "/dav/addressbook/new.vcf"
- assert_includes last_response.body, "BDAY:1985-04\r\nEND:VCARD"
+ served = last_response.body
+ refute_includes served, "BDAY"
+
+ put_request "new", served, "CONTENT_TYPE" => VCARD
+
+ assert_equal 204, last_response.status
+ assert_equal %("#{Digest::SHA256.hexdigest(served)}"), last_response["ETag"]
+ assert_equal ProTacts::Birthday.new(year: 1985, month: 4), store.contact("new")&.birthday
end
end