mirror of
https://github.com/MichMich/MagicMirror.git
synced 2025-12-12 09:52:37 +00:00
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.
409 lines
14 KiB
JavaScript
409 lines
14 KiB
JavaScript
/**
|
|
* @external Moment
|
|
*/
|
|
const moment = require("moment-timezone");
|
|
|
|
const Log = require("logger");
|
|
|
|
const CalendarFetcherUtils = {
|
|
|
|
/**
|
|
* Determine based on the title of an event if it should be excluded from the list of events
|
|
* @param {object} config the global config
|
|
* @param {string} title the title of the event
|
|
* @returns {object} excluded: true if the event should be excluded, false otherwise
|
|
* until: the date until the event should be excluded.
|
|
*/
|
|
shouldEventBeExcluded (config, title) {
|
|
for (const filterConfig of config.excludedEvents) {
|
|
const match = CalendarFetcherUtils.checkEventAgainstFilter(title, filterConfig);
|
|
if (match) {
|
|
return {
|
|
excluded: !match.until,
|
|
until: match.until
|
|
};
|
|
}
|
|
}
|
|
|
|
return {
|
|
excluded: false,
|
|
until: null
|
|
};
|
|
},
|
|
|
|
/**
|
|
* Get local timezone.
|
|
* This method makes it easier to test if different timezones cause problems by changing this implementation.
|
|
* @returns {string} timezone
|
|
*/
|
|
getLocalTimezone () {
|
|
return moment.tz.guess();
|
|
},
|
|
|
|
/**
|
|
* This function returns a list of moments for a recurring event.
|
|
* @param {object} event the current event which is a recurring event
|
|
* @param {moment.Moment} pastLocalMoment The past date to search for recurring events
|
|
* @param {moment.Moment} futureLocalMoment The future date to search for recurring events
|
|
* @param {number} durationInMs the duration of the event, this is used to take into account currently running events
|
|
* @returns {moment.Moment[]} All moments for the recurring event
|
|
*/
|
|
getMomentsFromRecurringEvent (event, pastLocalMoment, futureLocalMoment, durationInMs) {
|
|
const rule = event.rrule;
|
|
const isFullDayEvent = CalendarFetcherUtils.isFullDayEvent(event);
|
|
const eventTimezone = event.start.tz || CalendarFetcherUtils.getLocalTimezone();
|
|
|
|
// rrule.js interprets years < 1900 as offsets from 1900, causing issues with some birthday calendars
|
|
if (rule.origOptions?.dtstart?.getFullYear() < 1900) {
|
|
rule.origOptions.dtstart.setFullYear(1900);
|
|
}
|
|
if (rule.options?.dtstart?.getFullYear() < 1900) {
|
|
rule.options.dtstart.setFullYear(1900);
|
|
}
|
|
|
|
// Expand search window to include ongoing events
|
|
const oneDayInMs = 24 * 60 * 60 * 1000;
|
|
const searchFromDate = pastLocalMoment.clone().subtract(Math.max(durationInMs, oneDayInMs), "milliseconds").toDate();
|
|
const searchToDate = futureLocalMoment.clone().add(1, "days").toDate();
|
|
|
|
// For all-day events, extend "until" to end of day to include the final occurrence
|
|
if (isFullDayEvent && rule.options?.until) {
|
|
rule.options.until = moment(rule.options.until).endOf("day").toDate();
|
|
}
|
|
|
|
// Clear tzid to prevent rrule.js from double-adjusting times
|
|
if (rule.options) {
|
|
rule.options.tzid = null;
|
|
}
|
|
|
|
const dates = rule.between(searchFromDate, searchToDate, true) || [];
|
|
|
|
// Convert dates to moments in the appropriate timezone
|
|
// rrule.js returns UTC dates with tzid cleared, so we interpret them in the event's original timezone
|
|
return dates.map((date) => {
|
|
if (isFullDayEvent) {
|
|
// For all-day events, anchor to calendar day in event's timezone
|
|
return moment.tz(date, eventTimezone).startOf("day");
|
|
}
|
|
// For timed events, preserve the time in the event's original timezone
|
|
return moment.tz(date, "UTC").tz(eventTimezone, true);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Filter the events from ical according to the given config
|
|
* @param {object} data the calendar data from ical
|
|
* @param {object} config The configuration object
|
|
* @returns {string[]} the filtered events
|
|
*/
|
|
filterEvents (data, config) {
|
|
const newEvents = [];
|
|
|
|
const eventDate = function (event, time) {
|
|
const startMoment = event[time].tz ? moment.tz(event[time], event[time].tz) : moment.tz(event[time], CalendarFetcherUtils.getLocalTimezone());
|
|
return CalendarFetcherUtils.isFullDayEvent(event) ? startMoment.startOf("day") : startMoment;
|
|
};
|
|
|
|
Log.debug(`There are ${Object.entries(data).length} calendar entries.`);
|
|
|
|
const now = moment();
|
|
const pastLocalMoment = config.includePastEvents ? now.clone().startOf("day").subtract(config.maximumNumberOfDays, "days") : now;
|
|
const futureLocalMoment
|
|
= now
|
|
.clone()
|
|
.startOf("day")
|
|
.add(config.maximumNumberOfDays, "days")
|
|
// Subtract 1 second so that events that start on the middle of the night will not repeat.
|
|
.subtract(1, "seconds");
|
|
|
|
Object.entries(data).forEach(([key, event]) => {
|
|
Log.debug("Processing entry...");
|
|
|
|
const title = CalendarFetcherUtils.getTitleFromEvent(event);
|
|
Log.debug(`title: ${title}`);
|
|
|
|
// Return quickly if event should be excluded.
|
|
let { excluded, eventFilterUntil } = this.shouldEventBeExcluded(config, title);
|
|
if (excluded) {
|
|
return;
|
|
}
|
|
|
|
// FIXME: Ugly fix to solve the facebook birthday issue.
|
|
// Otherwise, the recurring events only show the birthday for next year.
|
|
let isFacebookBirthday = false;
|
|
if (typeof event.uid !== "undefined") {
|
|
if (event.uid.indexOf("@facebook.com") !== -1) {
|
|
isFacebookBirthday = true;
|
|
}
|
|
}
|
|
|
|
if (event.type === "VEVENT") {
|
|
Log.debug(`Event:\n${JSON.stringify(event, null, 2)}`);
|
|
let eventStartMoment = eventDate(event, "start");
|
|
let eventEndMoment;
|
|
|
|
if (typeof event.end !== "undefined") {
|
|
eventEndMoment = eventDate(event, "end");
|
|
} else if (typeof event.duration !== "undefined") {
|
|
eventEndMoment = eventStartMoment.clone().add(moment.duration(event.duration));
|
|
} else {
|
|
if (!isFacebookBirthday) {
|
|
// make copy of start date, separate storage area
|
|
eventEndMoment = eventStartMoment.clone();
|
|
} else {
|
|
eventEndMoment = eventStartMoment.clone().add(1, "days");
|
|
}
|
|
}
|
|
|
|
Log.debug(`start: ${eventStartMoment.toDate()}`);
|
|
Log.debug(`end: ${eventEndMoment.toDate()}`);
|
|
|
|
// Calculate the duration of the event for use with recurring events.
|
|
const durationMs = eventEndMoment.valueOf() - eventStartMoment.valueOf();
|
|
Log.debug(`duration: ${durationMs}`);
|
|
|
|
const location = event.location || false;
|
|
const geo = event.geo || false;
|
|
const description = event.description || false;
|
|
|
|
let instances = [];
|
|
if (event.rrule && typeof event.rrule !== "undefined" && !isFacebookBirthday) {
|
|
instances = CalendarFetcherUtils.expandRecurringEvent(event, pastLocalMoment, futureLocalMoment, durationMs);
|
|
} else {
|
|
const fullDayEvent = isFacebookBirthday ? true : CalendarFetcherUtils.isFullDayEvent(event);
|
|
let end = eventEndMoment;
|
|
if (fullDayEvent && eventStartMoment.valueOf() === end.valueOf()) {
|
|
end = end.endOf("day");
|
|
}
|
|
|
|
instances.push({
|
|
event: event,
|
|
startMoment: eventStartMoment,
|
|
endMoment: end,
|
|
isRecurring: false
|
|
});
|
|
}
|
|
|
|
for (const instance of instances) {
|
|
const { event: instanceEvent, startMoment, endMoment, isRecurring } = instance;
|
|
|
|
// Filter logic
|
|
if (endMoment.isBefore(pastLocalMoment) || startMoment.isAfter(futureLocalMoment)) {
|
|
continue;
|
|
}
|
|
|
|
if (CalendarFetcherUtils.timeFilterApplies(now, endMoment, eventFilterUntil)) {
|
|
continue;
|
|
}
|
|
|
|
const title = CalendarFetcherUtils.getTitleFromEvent(instanceEvent);
|
|
const fullDay = isFacebookBirthday ? true : CalendarFetcherUtils.isFullDayEvent(event);
|
|
|
|
Log.debug(`saving event: ${title}`);
|
|
newEvents.push({
|
|
title: title,
|
|
startDate: startMoment.format("x"),
|
|
endDate: endMoment.format("x"),
|
|
fullDayEvent: fullDay,
|
|
recurringEvent: isRecurring,
|
|
class: event.class,
|
|
firstYear: event.start.getFullYear(),
|
|
location: instanceEvent.location || location,
|
|
geo: instanceEvent.geo || geo,
|
|
description: instanceEvent.description || description
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
newEvents.sort(function (a, b) {
|
|
return a.startDate - b.startDate;
|
|
});
|
|
|
|
return newEvents;
|
|
},
|
|
|
|
/**
|
|
* Gets the title from the event.
|
|
* @param {object} event The event object to check.
|
|
* @returns {string} The title of the event, or "Event" if no title is found.
|
|
*/
|
|
getTitleFromEvent (event) {
|
|
let title = "Event";
|
|
if (event.summary) {
|
|
title = typeof event.summary.val !== "undefined" ? event.summary.val : event.summary;
|
|
} else if (event.description) {
|
|
title = event.description;
|
|
}
|
|
|
|
return title;
|
|
},
|
|
|
|
/**
|
|
* Checks if an event is a fullday event.
|
|
* @param {object} event The event object to check.
|
|
* @returns {boolean} True if the event is a fullday event, false otherwise
|
|
*/
|
|
isFullDayEvent (event) {
|
|
if (event.start.length === 8 || event.start.dateOnly || event.datetype === "date") {
|
|
return true;
|
|
}
|
|
|
|
const start = event.start || 0;
|
|
const startDate = new Date(start);
|
|
const end = event.end || 0;
|
|
if ((end - start) % (24 * 60 * 60 * 1000) === 0 && startDate.getHours() === 0 && startDate.getMinutes() === 0) {
|
|
// Is 24 hours, and starts on the middle of the night.
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
},
|
|
|
|
/**
|
|
* Determines if the user defined time filter should apply
|
|
* @param {moment.Moment} now Date object using previously created object for consistency
|
|
* @param {moment.Moment} endDate Moment object representing the event end date
|
|
* @param {string} filter The time to subtract from the end date to determine if an event should be shown
|
|
* @returns {boolean} True if the event should be filtered out, false otherwise
|
|
*/
|
|
timeFilterApplies (now, endDate, filter) {
|
|
if (filter) {
|
|
const until = filter.split(" "),
|
|
value = parseInt(until[0]),
|
|
increment = until[1].slice(-1) === "s" ? until[1] : `${until[1]}s`, // Massage the data for moment js
|
|
filterUntil = moment(endDate.format()).subtract(value, increment);
|
|
|
|
return now < filterUntil;
|
|
}
|
|
|
|
return false;
|
|
},
|
|
|
|
/**
|
|
* Determines if the user defined title filter should apply
|
|
* @param {string} title the title of the event
|
|
* @param {string} filter the string to look for, can be a regex also
|
|
* @param {boolean} useRegex true if a regex should be used, otherwise it just looks for the filter as a string
|
|
* @param {string} regexFlags flags that should be applied to the regex
|
|
* @returns {boolean} True if the title should be filtered out, false otherwise
|
|
*/
|
|
titleFilterApplies (title, filter, useRegex, regexFlags) {
|
|
if (useRegex) {
|
|
let regexFilter = filter;
|
|
// Assume if leading slash, there is also trailing slash
|
|
if (filter[0] === "/") {
|
|
// Strip leading and trailing slashes
|
|
regexFilter = filter.substr(1).slice(0, -1);
|
|
}
|
|
return new RegExp(regexFilter, regexFlags).test(title);
|
|
} else {
|
|
return title.includes(filter);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Expands a recurring event into individual event instances.
|
|
* @param {object} event The recurring event object
|
|
* @param {moment.Moment} pastLocalMoment The past date limit
|
|
* @param {moment.Moment} futureLocalMoment The future date limit
|
|
* @param {number} durationMs The duration of the event in milliseconds
|
|
* @returns {object[]} Array of event instances
|
|
*/
|
|
expandRecurringEvent (event, pastLocalMoment, futureLocalMoment, durationMs) {
|
|
const moments = CalendarFetcherUtils.getMomentsFromRecurringEvent(event, pastLocalMoment, futureLocalMoment, durationMs);
|
|
const instances = [];
|
|
|
|
for (const startMoment of moments) {
|
|
let curEvent = event;
|
|
let showRecurrence = true;
|
|
let recurringEventStartMoment = startMoment.clone().tz(CalendarFetcherUtils.getLocalTimezone());
|
|
let recurringEventEndMoment = recurringEventStartMoment.clone().add(durationMs, "ms");
|
|
|
|
const dateKey = recurringEventStartMoment.tz("UTC").format("YYYY-MM-DD");
|
|
|
|
// Check for overrides
|
|
if (curEvent.recurrences !== undefined) {
|
|
if (curEvent.recurrences[dateKey] !== undefined) {
|
|
curEvent = curEvent.recurrences[dateKey];
|
|
// Re-calculate start/end based on override
|
|
const start = curEvent.start;
|
|
const end = curEvent.end;
|
|
const localTimezone = CalendarFetcherUtils.getLocalTimezone();
|
|
|
|
recurringEventStartMoment = (start.tz ? moment(start).tz(start.tz) : moment(start)).tz(localTimezone);
|
|
recurringEventEndMoment = (end.tz ? moment(end).tz(end.tz) : moment(end)).tz(localTimezone);
|
|
}
|
|
}
|
|
|
|
// Check for exceptions
|
|
if (curEvent.exdate !== undefined) {
|
|
if (curEvent.exdate[dateKey] !== undefined) {
|
|
showRecurrence = false;
|
|
}
|
|
}
|
|
|
|
if (recurringEventStartMoment.valueOf() === recurringEventEndMoment.valueOf()) {
|
|
recurringEventEndMoment = recurringEventEndMoment.endOf("day");
|
|
}
|
|
|
|
if (showRecurrence) {
|
|
instances.push({
|
|
event: curEvent,
|
|
startMoment: recurringEventStartMoment,
|
|
endMoment: recurringEventEndMoment,
|
|
isRecurring: true
|
|
});
|
|
}
|
|
}
|
|
return instances;
|
|
},
|
|
|
|
/**
|
|
* Checks if an event title matches a specific filter configuration.
|
|
* @param {string} title The event title to check
|
|
* @param {string|object} filterConfig The filter configuration (string or object)
|
|
* @returns {object|null} Object with {until: string|null} if matched, null otherwise
|
|
*/
|
|
checkEventAgainstFilter (title, filterConfig) {
|
|
let filter = filterConfig;
|
|
let testTitle = title.toLowerCase();
|
|
let until = null;
|
|
let useRegex = false;
|
|
let regexFlags = "g";
|
|
|
|
if (filter instanceof Object) {
|
|
if (typeof filter.until !== "undefined") {
|
|
until = filter.until;
|
|
}
|
|
|
|
if (typeof filter.regex !== "undefined") {
|
|
useRegex = filter.regex;
|
|
}
|
|
|
|
if (filter.caseSensitive) {
|
|
filter = filter.filterBy;
|
|
testTitle = title;
|
|
} else if (useRegex) {
|
|
filter = filter.filterBy;
|
|
testTitle = title;
|
|
regexFlags += "i";
|
|
} else {
|
|
filter = filter.filterBy.toLowerCase();
|
|
}
|
|
} else {
|
|
filter = filter.toLowerCase();
|
|
}
|
|
|
|
if (CalendarFetcherUtils.titleFilterApplies(testTitle, filter, useRegex, regexFlags)) {
|
|
return { until };
|
|
}
|
|
|
|
return null;
|
|
}
|
|
};
|
|
|
|
if (typeof module !== "undefined") {
|
|
module.exports = CalendarFetcherUtils;
|
|
}
|