Skip to content

Commit 205b32a

Browse files
perf(calendar): pre-filter ICS data before parsing
1 parent b9be026 commit 205b32a

6 files changed

Lines changed: 69 additions & 1 deletion

File tree

cspell.config.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@
257257
"pubdate",
258258
"radokristof",
259259
"rajniszp",
260+
"RDATE",
260261
"rebuilded",
261262
"Reis",
262263
"rejas",

defaultmodules/calendar/calendarfetcher.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,13 @@ class CalendarFetcher {
5959
}
6060

6161
const responseData = await response.text();
62-
const parsed = await ical.async.parseICS(responseData);
62+
63+
const filteredData = await CalendarFetcherUtils.preFilterICS(responseData, {
64+
includePastEvents: this.includePastEvents,
65+
maximumNumberOfDays: this.maximumNumberOfDays
66+
});
67+
68+
const parsed = await ical.async.parseICS(filteredData);
6369

6470
Log.debug(`Parsed iCal data from ${this.url} with ${Object.keys(parsed).length} entries.`);
6571

defaultmodules/calendar/calendarfetcherutils.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,36 @@ const CalendarFetcherUtils = {
4141
return moment.tz.guess();
4242
},
4343

44+
/**
45+
* Calculate the time window of events to keep, as [start, end].
46+
* Without includePastEvents the window starts now; otherwise it also
47+
* reaches maximumNumberOfDays into the past.
48+
* @param {object} config Needs includePastEvents (boolean) and maximumNumberOfDays (number).
49+
* @returns {[Date, Date]} The start and end of the window.
50+
*/
51+
calculateFilterWindow (config) {
52+
const today = moment().startOf("day");
53+
const start = config.includePastEvents
54+
? today.clone().subtract(config.maximumNumberOfDays, "days").toDate()
55+
: new Date();
56+
const end = today.clone().add(config.maximumNumberOfDays, "days").toDate();
57+
return [start, end];
58+
},
59+
60+
/**
61+
* Drop ICS data outside the configured time window before it is parsed,
62+
* so that node-ical only has to process events we might actually show.
63+
* @param {string} rawICS The raw ICS text.
64+
* @param {object} config Needs includePastEvents (boolean) and maximumNumberOfDays (number).
65+
* @returns {Promise<string>} The filtered ICS text.
66+
*/
67+
async preFilterICS (rawICS, config) {
68+
// ics-filter is ESM-only, so we import it dynamically from this CommonJS file.
69+
const { icsFilter } = await import("ics-filter");
70+
const [start, end] = CalendarFetcherUtils.calculateFilterWindow(config);
71+
return icsFilter(rawICS, start, end);
72+
},
73+
4474
/**
4575
* Filter the events from ical according to the given config
4676
* @param {object} data the calendar data from ical

package-lock.json

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@
9393
"helmet": "^8.2.0",
9494
"html-to-text": "^10.0.0",
9595
"iconv-lite": "^0.7.2",
96+
"ics-filter": "^1.0.2",
9697
"ipaddr.js": "^2.4.0",
9798
"moment": "^2.30.1",
9899
"moment-timezone": "^0.6.2",

tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,29 @@ END:VCALENDAR`);
298298
});
299299
});
300300

301+
describe("calculateFilterWindow", () => {
302+
it("ends maximumNumberOfDays after today's midnight", () => {
303+
const [, end] = CalendarFetcherUtils.calculateFilterWindow({ includePastEvents: false, maximumNumberOfDays: 30 });
304+
305+
expect(end).toEqual(moment().startOf("day").add(30, "days").toDate());
306+
});
307+
308+
it("starts now when includePastEvents is false", () => {
309+
const before = Date.now();
310+
const [start] = CalendarFetcherUtils.calculateFilterWindow({ includePastEvents: false, maximumNumberOfDays: 30 });
311+
const after = Date.now();
312+
313+
expect(start.getTime()).toBeGreaterThanOrEqual(before);
314+
expect(start.getTime()).toBeLessThanOrEqual(after);
315+
});
316+
317+
it("starts maximumNumberOfDays before today's midnight when includePastEvents is true", () => {
318+
const [start] = CalendarFetcherUtils.calculateFilterWindow({ includePastEvents: true, maximumNumberOfDays: 30 });
319+
320+
expect(start).toEqual(moment().startOf("day").subtract(30, "days").toDate());
321+
});
322+
});
323+
301324
describe("expandRecurringEvent", () => {
302325
it("should extend end to end-of-day when event has no DTEND", () => {
303326
// node-ical sets end === start when DTEND is absent; our code extends to endOf("day")

0 commit comments

Comments
 (0)