Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions defaultmodules/calendar/calendarfetcherutils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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, {
Expand All @@ -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) {
Expand Down
79 changes: 79 additions & 0 deletions tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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=<day of DTSTART> 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"]);
});
});
});