Files
MagicMirror/modules/default/calendar/node_helper.js
Kristjan ESPERANTO c2ec6fc2b9 [calendar] fix: prevent excessive fetching on client reload and refactor calendarfetcherutils.js (#3976)
The bottom line of this PR is, that it fixes an issue and simplifies the
code by dealing with the TODOs in the code.

For review, I suggest looking at each commit individually. If there are
too many changes for a PR, let me know and I'll split it up
🙂

## 1. [fix(calendar): prevent excessive fetching with smart refresh
strategy](8892cd3d5a)

- Add lastFetch timestamp tracking to CalendarFetcher
- Add shouldRefetch() method with configurable minimum interval
(default: 3 minutes)
- When reusing existing fetcher: fetch if data is stale (>3 min),
otherwise broadcast cached events
- Prevents double broadcasts to consuming modules while maintaining
fresh data
- Balances rate limit prevention (Issue
https://github.com/MagicMirrorOrg/MagicMirror/issues/3971) with data
freshness on user reload
- Prevents excessive fetching during rapid reloads (e.g., Fully Kiosk
screensaver use case)
- Allows fresh calendar data when enough time has passed since last
fetch

## 2. [refactor(calendar): simplify event exclusion
logic](d507aba82d)

- Extract filtering logic from `shouldEventBeExcluded` into new helper
`checkEventAgainstFilter`
- Simplify the main loop in `shouldEventBeExcluded

It resolves a TODO from the comments in the code:
* `This seems like an overly complicated way to exclude events based on
the title.`

## 3. [refactor(calendar): extract recurring event expansion
logic](d510160bd2)

This change separates the expansion of recurring events from the main
filtering loop into a new helper function 'expandRecurringEvent'.

It resolves two TODOs from the comments in the code:
- `This should be a separate function`
- `This should create an event per moment so we can change anything we
want`

This improves code readability, reduces complexity in 'filterEvents',
and allows for cleaner handling of individual recurrence instances.

## 4. [refactor(calendar): simplify recurring event
handling](b04f716cc0)

- Simplify 'getMomentsFromRecurringEvent' using modern syntax
- Improve handling of full-day events across different timezones


## 5. [test(calendar): fix UNTIL date in fullday_until.ics
fixture](1d762b2ade)

The issue was with the UNTIL date being May 4th while DTSTART was May
5th. This created an invalid recurrence rule where the end date came
before the start date.

The fix only adjusts the UNTIL date from May 4th to May 5th, so it
matches the start date.
2025-12-08 10:07:04 +01:00

100 lines
3.9 KiB
JavaScript

const NodeHelper = require("node_helper");
const Log = require("logger");
const CalendarFetcher = require("./calendarfetcher");
module.exports = NodeHelper.create({
// Override start method.
start () {
Log.log(`Starting node helper for: ${this.name}`);
this.fetchers = [];
},
// Override socketNotificationReceived method.
socketNotificationReceived (notification, payload) {
if (notification === "ADD_CALENDAR") {
this.createFetcher(payload.url, payload.fetchInterval, payload.excludedEvents, payload.maximumEntries, payload.maximumNumberOfDays, payload.auth, payload.broadcastPastEvents, payload.selfSignedCert, payload.id);
} else if (notification === "FETCH_CALENDAR") {
const key = payload.id + payload.url;
if (typeof this.fetchers[key] === "undefined") {
Log.error("No fetcher exists with key: ", key);
this.sendSocketNotification("CALENDAR_ERROR", { error_type: "MODULE_ERROR_UNSPECIFIED" });
return;
}
this.fetchers[key].fetchCalendar();
}
},
/**
* Creates a fetcher for a new url if it doesn't exist yet.
* Otherwise it reuses the existing one.
* @param {string} url The url of the calendar
* @param {number} fetchInterval How often does the calendar needs to be fetched in ms
* @param {string[]} excludedEvents An array of words / phrases from event titles that will be excluded from being shown.
* @param {number} maximumEntries The maximum number of events fetched.
* @param {number} maximumNumberOfDays The maximum number of days an event should be in the future.
* @param {object} auth The object containing options for authentication against the calendar.
* @param {boolean} broadcastPastEvents If true events from the past maximumNumberOfDays will be included in event broadcasts
* @param {boolean} selfSignedCert If true, the server certificate is not verified against the list of supplied CAs.
* @param {string} identifier ID of the module
*/
createFetcher (url, fetchInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, broadcastPastEvents, selfSignedCert, identifier) {
try {
new URL(url);
} catch (error) {
Log.error("Malformed calendar url: ", url, error);
this.sendSocketNotification("CALENDAR_ERROR", { error_type: "MODULE_ERROR_MALFORMED_URL" });
return;
}
let fetcher;
let fetchIntervalCorrected;
if (typeof this.fetchers[identifier + url] === "undefined") {
if (fetchInterval < 60000) {
Log.warn(`fetchInterval for url ${url} must be >= 60000`);
fetchIntervalCorrected = 60000;
}
Log.log(`Create new calendarfetcher for url: ${url} - Interval: ${fetchIntervalCorrected || fetchInterval}`);
fetcher = new CalendarFetcher(url, fetchIntervalCorrected || fetchInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, broadcastPastEvents, selfSignedCert);
fetcher.onReceive((fetcher) => {
this.broadcastEvents(fetcher, identifier);
});
fetcher.onError((fetcher, error) => {
Log.error("Calendar Error. Could not fetch calendar: ", fetcher.url, error);
let error_type = NodeHelper.checkFetchError(error);
this.sendSocketNotification("CALENDAR_ERROR", {
id: identifier,
error_type
});
});
this.fetchers[identifier + url] = fetcher;
fetcher.fetchCalendar();
} else {
Log.log(`Use existing calendarfetcher for url: ${url}`);
fetcher = this.fetchers[identifier + url];
// Check if calendar data is stale and needs refresh
if (fetcher.shouldRefetch()) {
Log.log(`Calendar data is stale, fetching fresh data for url: ${url}`);
fetcher.fetchCalendar();
} else {
fetcher.broadcastEvents();
}
}
},
/**
*
* @param {object} fetcher the fetcher associated with the calendar
* @param {string} identifier the identifier of the calendar
*/
broadcastEvents (fetcher, identifier) {
this.sendSocketNotification("CALENDAR_EVENTS", {
id: identifier,
url: fetcher.url,
events: fetcher.events
});
}
});