Skip to content
Open
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
54 changes: 54 additions & 0 deletions defaultmodules/calendar/calendarfetcherutils.js
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,56 @@ 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.
*
* The test is deliberately narrow, so rules that genuinely expand are left alone:
* BYMONTHDAY must carry exactly one value, that value must equal DTSTART's day-of-month
* (i.e. it merely restates 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 rule = event.rrule;
if (!rule) {
return false;
}

const options = typeof rule.options === "function" ? rule.options() : rule.options;
// node-ical >= 0.26 reports freq as a string, earlier versions used rrule.js' numeric RRule.YEARLY.
if (!options || (options.freq !== "YEARLY" && options.freq !== 0)) {
return false;
}

const byMonthDay = options.byMonthDay ?? options.bymonthday;
if (!Array.isArray(byMonthDay) || byMonthDay.length !== 1) {
return false;
}

// Any further BYxxx part means the rule shapes the recurrence on purpose.
const shapingParts = [
options.byMonth ?? options.bymonth,
options.byDay ?? options.byweekday,
options.byYearDay ?? options.byyearday,
options.byWeekNo ?? options.byweekno,
options.bySetPos ?? options.bysetpos
];
if (shapingParts.some((part) => (Array.isArray(part) ? part.length > 0 : part !== undefined && part !== null))) {
return false;
}

return 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 +282,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 +294,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"]);
});
});
});