diff --git a/defaultmodules/calendar/calendarfetcherutils.js b/defaultmodules/calendar/calendarfetcherutils.js index 59d0823520..eb0eae69bd 100644 --- a/defaultmodules/calendar/calendarfetcherutils.js +++ b/defaultmodules/calendar/calendarfetcherutils.js @@ -222,6 +222,42 @@ const CalendarFetcherUtils = { } }, + /** + * Detects yearly rules that restate DTSTART's day-of-month in BYMONTHDAY but omit BYMONTH, + * as exported by several calendar clients (typically for birthdays), e.g. + * + * DTSTART;VALUE=DATE:20231002 + * RRULE:FREQ=YEARLY;WKST=MO;INTERVAL=1;BYMONTHDAY=2 + * + * RFC 5545 makes BYMONTHDAY an *expanding* rule part for FREQ=YEARLY, so a conforming + * expander returns the 2nd of every month - the event shows up twelve times a year + * instead of once. The clients that emit this, and the web UIs that render it, all show + * it once a year on DTSTART's date, so we follow the author's evident intent. + * + * Detection reads rrule.origOptions, which carries only the parts the author actually + * wrote (not the defaults node-ical fills in), so a missing BYMONTH is unambiguous. It + * stays deliberately narrow, leaving rules that genuinely expand alone: BYMONTHDAY must + * hold exactly one value equal to DTSTART's day-of-month (merely restating DTSTART), and + * no other BYxxx part may shape the recurrence. + * @param {object} event The recurring event object + * @returns {boolean} True if the rule should be confined to DTSTART's month + */ + isYearlyRuleMissingByMonth (event) { + const options = event.rrule?.origOptions; + if (!options || options.freq !== "YEARLY") { + return false; + } + + const isSingleMonthDay = Array.isArray(options.byMonthDay) && options.byMonthDay.length === 1; + // Any other BYxxx part means the rule shapes the recurrence on purpose. + const hasShapingPart = Boolean(options.byMonth || options.byDay || options.byYearDay || options.byWeekNo || options.bySetPos); + if (!isSingleMonthDay || hasShapingPart) { + return false; + } + + return options.byMonthDay[0] === event.start.getDate(); + }, + /** * Expands a recurring event into individual event instances using node-ical. * Handles RRULE expansion, EXDATE filtering, RECURRENCE-ID overrides, and ongoing events. @@ -232,6 +268,9 @@ const CalendarFetcherUtils = { */ expandRecurringEvent (event, pastLocalMoment, futureLocalMoment) { const localTimezone = CalendarFetcherUtils.getLocalTimezone(); + // Drop the eleven spurious months produced by a yearly rule that is missing BYMONTH. + const confineToStartMonth = CalendarFetcherUtils.isYearlyRuleMissingByMonth(event); + const startMonth = event.start.getMonth(); return ical .expandRecurringEvent(event, { @@ -241,6 +280,7 @@ const CalendarFetcherUtils = { excludeExdates: true, expandOngoing: true }) + .filter((inst) => !confineToStartMonth || inst.start.getMonth() === startMonth) .map((inst) => { let startMoment, endMoment; if (inst.isFullDay) { diff --git a/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js b/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js index 89af3a6392..657c52eee0 100644 --- a/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js +++ b/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js @@ -517,4 +517,83 @@ END:VCALENDAR`); expect(filteredEvents[0].location).toBe("Berlin"); }); }); + + describe("yearly events that restate DTSTART's day in BYMONTHDAY but omit BYMONTH", () => { + // See GitHub issues #2547 and #3047: several calendar clients export a yearly + // event (typically a birthday) as FREQ=YEARLY;BYMONTHDAY= without + // a BYMONTH part. RFC 5545 makes BYMONTHDAY an *expanding* rule part for YEARLY, + // so a conforming expander returns that day in every month - the event then shows + // up twelve times a year instead of once. + + const yearConfig = { ...defaultConfig, maximumNumberOfDays: 365 }; + + const buildEvent = function (rrule, dtstart = "20231002", dtend = "20231003") { + return ical.parseICS(`BEGIN:VCALENDAR +BEGIN:VEVENT +DTSTART;VALUE=DATE:${dtstart} +DTEND;VALUE=DATE:${dtend} +RRULE:${rrule} +DTSTAMP:20230425T111027Z +UID:yearly-bymonthday@example.com +SUMMARY:Ted Birthday +END:VEVENT +END:VCALENDAR`); + }; + + const monthDaysOf = (events) => events.map((event) => moment(event.startDate, "x").format("MM-DD")); + + it("should occur only in DTSTART's month when BYMONTH is missing", () => { + const data = buildEvent("FREQ=YEARLY;WKST=MO;INTERVAL=1;BYMONTHDAY=2"); + + const monthDays = monthDaysOf(CalendarFetcherUtils.filterEvents(data, yearConfig)); + + expect(monthDays.length).toBeGreaterThan(0); + expect(monthDays).toEqual(monthDays.map(() => "10-02")); + }); + + it("should still expand a rule that lists several days of the month", () => { + // FREQ=YEARLY;BYMONTHDAY=1,3 legitimately expands across the whole year. + const data = buildEvent("FREQ=YEARLY;BYMONTHDAY=1,3"); + + const months = new Set(monthDaysOf(CalendarFetcherUtils.filterEvents(data, yearConfig)).map((md) => md.slice(0, 2))); + + expect(months.size).toBe(12); + }); + + it("should still expand a rule that also constrains the weekday", () => { + // "Every Friday the 13th" - BYDAY shapes the recurrence, so it must expand. + const data = buildEvent("FREQ=YEARLY;BYMONTHDAY=13;BYDAY=FR"); + + const monthDays = monthDaysOf(CalendarFetcherUtils.filterEvents(data, yearConfig)); + + expect(monthDays.length).toBeGreaterThan(0); + expect(monthDays.every((md) => md.endsWith("-13"))).toBe(true); + expect(monthDays.some((md) => !md.startsWith("10"))).toBe(true); + }); + + it("should still expand when BYMONTHDAY does not match DTSTART's day", () => { + // The day was not simply restated from DTSTART, so the rule means something else. + const data = buildEvent("FREQ=YEARLY;WKST=MO;INTERVAL=1;BYMONTHDAY=7"); + + const months = new Set(monthDaysOf(CalendarFetcherUtils.filterEvents(data, yearConfig)).map((md) => md.slice(0, 2))); + + expect(months.size).toBe(12); + }); + + it("should keep a well-formed yearly rule with BYMONTH on its single date", () => { + const data = buildEvent("FREQ=YEARLY;WKST=MO;INTERVAL=1;BYMONTHDAY=2;BYMONTH=10"); + + const monthDays = monthDaysOf(CalendarFetcherUtils.filterEvents(data, yearConfig)); + + expect(monthDays).toEqual(["10-02"]); + }); + + it("should keep a plain yearly rule on its single date", () => { + const data = buildEvent("FREQ=YEARLY"); + + const monthDays = monthDaysOf(CalendarFetcherUtils.filterEvents(data, yearConfig)); + + expect(monthDays).toEqual(["10-02"]); + }); + }); });