mirror of
https://github.com/MichMich/MagicMirror.git
synced 2025-07-02 22:08:42 +00:00
## [2.32.0] - 2025-07-01 Thanks to: @bughaver, @bugsounet, @khassel, @KristjanESPERANTO, @plebcity, @rejas, @sdetweil. > ⚠️ This release needs nodejs version `v22.14.0 or higher` ### Added - [config] Allow to change module order for final renderer (or dynamically with CSS): Feature `order` in config (#3762) - [clock] Added option 'disableNextEvent' to hide next sun event (#3769) - [clock] Implement short syntax for clock week (#3775) ### Changed - [refactor] Simplify module loading process (#3766) - Use `node --run` instead of `npm run` (#3764) and adapt `start:dev` script (#3773) - [workflow] Run linter and spellcheck with LTS node version (#3767) - [workflow] Split "Run test" step into two steps for more clarity (#3767) - [linter] Review linter setup (#3783) - Fix command to lint markdown in `CONTRIBUTING.md` - Re-activate JSDoc linting and fix linting issues - Refactor ESLint config to use `defineConfig` and `globalIgnores` - Replace `eslint-plugin-import` with `eslint-plugin-import-x` - Switch Stylelint config to flat format and simplify Stylelint scripts - [workflow] Replace Node.js version v23 with v24 (#3770) - [refactor] Replace deprecated constants `fs.F_OK` and `fs.R_OK` (#3789) - [refactor] Replace `ansis` with built-in function `util.styleText` (#3793) - [core] Integrate stuff from `vendor` and `fonts` folders into main `package.json`, simplifies install and maintaining dependencies (#3795, #3805) - [l10n] Complete translations (with the help of translation tools) (#3794) - [refactor] Refactored `calendarfetcherutils` in Calendar module to handle timezones better (#3806) - Removed as many of the date conversions as possible - Use `moment-timezone` when calculating recurring events, this will fix problems from the past with offsets and DST not being handled properly - Added some tests to test the behavior of the refactored methods to make sure the correct event dates are returned - [linter] Enable ESLint rule `no-console` and replace `console` with `Log` in some files (#3810) - [tests] Review and refactor translation tests (#3792) ### Fixed - [fix] Handle spellcheck issues (#3783) - [calendar] fix fullday event rrule until with timezone offset (#3781) - [feat] Add rule `no-undef` in config file validation to fix #3785 (#3786) - [fonts] Fix `roboto.css` to avoid error message `Unknown descriptor 'var(' in @font-face rule.` in firefox console (#3787) - [tests] Fix and refactor e2e test `Same keys` in `translations_spec.js` (#3809) - [tests] Fix e2e tests newsfeed and calendar to exit without open handles (#3817) ### Updated - [core] Update dependencies including electron to v36 (#3774, #3788, #3811, #3804, #3815, #3823) - [core] Update package type to `commonjs` - [logger] Review factory code part: use `switch/case` instead of `if/else if` (#3812) --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Michael Teeuw <michael@xonaymedia.nl> Co-authored-by: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Ross Younger <crazyscot@gmail.com> Co-authored-by: Veeck <github@veeck.de> Co-authored-by: Bugsounet - Cédric <github@bugsounet.fr> Co-authored-by: jkriegshauser <joshuakr@nvidia.com> Co-authored-by: illimarkangur <116028111+illimarkangur@users.noreply.github.com> Co-authored-by: sam detweiler <sdetweil@gmail.com> Co-authored-by: vppencilsharpener <tim.pray@gmail.com> Co-authored-by: veeck <michael.veeck@nebenan.de> Co-authored-by: Paranoid93 <6515818+Paranoid93@users.noreply.github.com> Co-authored-by: Brian O'Connor <btoconnor@users.noreply.github.com> Co-authored-by: WallysWellies <59727507+WallysWellies@users.noreply.github.com> Co-authored-by: Jason Stieber <jrstieber@gmail.com> Co-authored-by: jargordon <50050429+jargordon@users.noreply.github.com> Co-authored-by: Daniel <32464403+dkallen78@users.noreply.github.com> Co-authored-by: Ryan Williams <65094007+ryan-d-williams@users.noreply.github.com> Co-authored-by: Panagiotis Skias <panagiotis.skias@gmail.com> Co-authored-by: Marc Landis <dirk.rettschlag@gmail.com> Co-authored-by: HeikoGr <20295490+HeikoGr@users.noreply.github.com> Co-authored-by: Pedro Lamas <pedrolamas@gmail.com> Co-authored-by: veeck <gitkraken@veeck.de> Co-authored-by: Magnus <34011212+MagMar94@users.noreply.github.com> Co-authored-by: Ikko Eltociear Ashimine <eltociear@gmail.com> Co-authored-by: DevIncomin <56730075+Developer-Incoming@users.noreply.github.com> Co-authored-by: Nathan <n8nyoung@gmail.com> Co-authored-by: mixasgr <mixasgr@users.noreply.github.com> Co-authored-by: Savvas Adamtziloglou <savvas-gr@greeklug.gr> Co-authored-by: Konstantinos <geraki@gmail.com> Co-authored-by: OWL4C <124401812+OWL4C@users.noreply.github.com> Co-authored-by: BugHaver <43462320+bughaver@users.noreply.github.com> Co-authored-by: BugHaver <43462320+lsaadeh@users.noreply.github.com> Co-authored-by: Koen Konst <koenspero@gmail.com> Co-authored-by: Koen Konst <c.h.konst@avisi.nl>
432 lines
16 KiB
JavaScript
432 lines
16 KiB
JavaScript
/**
|
|
* @external Moment
|
|
*/
|
|
const moment = require("moment-timezone");
|
|
|
|
const Log = require("../../../js/logger");
|
|
|
|
const CalendarFetcherUtils = {
|
|
|
|
/**
|
|
* Determine based on the title of an event if it should be excluded from the list of events
|
|
* TODO This seems like an overly complicated way to exclude events based on the title.
|
|
* @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) {
|
|
let filter = {
|
|
excluded: false,
|
|
until: null
|
|
};
|
|
for (let f in config.excludedEvents) {
|
|
let filter = config.excludedEvents[f],
|
|
testTitle = title.toLowerCase(),
|
|
until = null,
|
|
useRegex = false,
|
|
regexFlags = "g";
|
|
|
|
if (filter instanceof Object) {
|
|
if (typeof filter.until !== "undefined") {
|
|
until = filter.until;
|
|
}
|
|
|
|
if (typeof filter.regex !== "undefined") {
|
|
useRegex = filter.regex;
|
|
}
|
|
|
|
// If additional advanced filtering is added in, this section
|
|
// must remain last as we overwrite the filter object with the
|
|
// filterBy string
|
|
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)) {
|
|
if (until) {
|
|
filter.until = until;
|
|
} else {
|
|
filter.excluded = true;
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
return filter;
|
|
},
|
|
|
|
/**
|
|
* 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;
|
|
|
|
// can cause problems with e.g. birthdays before 1900
|
|
if ((rule.options && rule.origOptions && rule.origOptions.dtstart && rule.origOptions.dtstart.getFullYear() < 1900) || (rule.options && rule.options.dtstart && rule.options.dtstart.getFullYear() < 1900)) {
|
|
rule.origOptions.dtstart.setYear(1900);
|
|
rule.options.dtstart.setYear(1900);
|
|
}
|
|
|
|
// subtract the max of the duration of this event or 1 day to find events in the past that are currently still running and should therefor be displayed.
|
|
const oneDayInMs = 24 * 60 * 60000;
|
|
let searchFromDate = pastLocalMoment.clone().subtract(Math.max(durationInMs, oneDayInMs), "milliseconds").toDate();
|
|
let searchToDate = futureLocalMoment.clone().add(1, "days").toDate();
|
|
Log.debug(`Search for recurring events between: ${searchFromDate} and ${searchToDate}`);
|
|
|
|
// if until is set, and its a full day event, force the time to midnight. rrule gets confused with non-00 offset
|
|
// looks like MS Outlook sets the until time incorrectly for fullday events
|
|
if ((rule.options.until !== undefined) && CalendarFetcherUtils.isFullDayEvent(event)) {
|
|
Log.debug("fixup rrule until");
|
|
rule.options.until = moment(rule.options.until).clone().startOf("day").add(1, "day")
|
|
.toDate();
|
|
}
|
|
|
|
Log.debug("fix rrule start=", rule.options.dtstart);
|
|
Log.debug("event before rrule.between=", JSON.stringify(event, null, 2), "exdates=", event.exdate);
|
|
|
|
Log.debug(`RRule: ${rule.toString()}`);
|
|
rule.options.tzid = null; // RRule gets *very* confused with timezones
|
|
|
|
let dates = rule.between(searchFromDate, searchToDate, true, () => {
|
|
return true;
|
|
});
|
|
|
|
Log.debug(`Title: ${event.summary}, with dates: \n\n${JSON.stringify(dates)}\n`);
|
|
|
|
// shouldn't need this anymore, as RRULE not passed junk
|
|
dates = dates.filter((d) => {
|
|
return JSON.stringify(d) !== "null";
|
|
});
|
|
|
|
// Dates are returned in UTC timezone but with localdatetime because tzid is null.
|
|
// So we map the date to a moment using the original timezone of the event.
|
|
return dates.map((d) => (event.start.tz ? moment.tz(d, "UTC").tz(event.start.tz, true) : moment.tz(d, "UTC").tz(CalendarFetcherUtils.getLocalTimezone(), 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;
|
|
|
|
// TODO This should be a seperate function.
|
|
if (event.rrule && typeof event.rrule !== "undefined" && !isFacebookBirthday) {
|
|
// Recurring event.
|
|
let moments = CalendarFetcherUtils.getMomentsFromRecurringEvent(event, pastLocalMoment, futureLocalMoment, durationMs);
|
|
|
|
// Loop through the set of moment entries to see which recurrences should be added to our event list.
|
|
// TODO This should create an event per moment so we can change anything we want.
|
|
for (let m in moments) {
|
|
let curEvent = event;
|
|
let showRecurrence = true;
|
|
let recurringEventStartMoment = moments[m].tz(CalendarFetcherUtils.getLocalTimezone()).clone();
|
|
let recurringEventEndMoment = recurringEventStartMoment.clone().add(durationMs, "ms");
|
|
|
|
let dateKey = recurringEventStartMoment.tz("UTC").format("YYYY-MM-DD");
|
|
|
|
Log.debug("event date dateKey=", dateKey);
|
|
// For each date that we're checking, it's possible that there is a recurrence override for that one day.
|
|
if (curEvent.recurrences !== undefined) {
|
|
Log.debug("have recurrences=", curEvent.recurrences);
|
|
if (curEvent.recurrences[dateKey] !== undefined) {
|
|
Log.debug("have a recurrence match for dateKey=", dateKey);
|
|
// We found an override, so for this recurrence, use a potentially different title, start date, and duration.
|
|
curEvent = curEvent.recurrences[dateKey];
|
|
// Some event start/end dates don't have timezones
|
|
if (curEvent.start.tz) {
|
|
recurringEventStartMoment = moment(curEvent.start).tz(curEvent.start.tz).tz(CalendarFetcherUtils.getLocalTimezone());
|
|
} else {
|
|
recurringEventStartMoment = moment(curEvent.start).tz(CalendarFetcherUtils.getLocalTimezone());
|
|
}
|
|
if (curEvent.end.tz) {
|
|
recurringEventEndMoment = moment(curEvent.end).tz(curEvent.end.tz).tz(CalendarFetcherUtils.getLocalTimezone());
|
|
} else {
|
|
recurringEventEndMoment = moment(curEvent.end).tz(CalendarFetcherUtils.getLocalTimezone());
|
|
}
|
|
} else {
|
|
Log.debug("recurrence key ", dateKey, " doesn't match");
|
|
}
|
|
}
|
|
// If there's no recurrence override, check for an exception date. Exception dates represent exceptions to the rule.
|
|
if (curEvent.exdate !== undefined) {
|
|
Log.debug("have datekey=", dateKey, " exdates=", curEvent.exdate);
|
|
if (curEvent.exdate[dateKey] !== undefined) {
|
|
// This date is an exception date, which means we should skip it in the recurrence pattern.
|
|
showRecurrence = false;
|
|
}
|
|
}
|
|
|
|
if (recurringEventStartMoment.valueOf() === recurringEventEndMoment.valueOf()) {
|
|
recurringEventEndMoment = recurringEventEndMoment.endOf("day");
|
|
}
|
|
|
|
const recurrenceTitle = CalendarFetcherUtils.getTitleFromEvent(curEvent);
|
|
|
|
// If this recurrence ends before the start of the date range, or starts after the end of the date range, don"t add
|
|
// it to the event list.
|
|
if (recurringEventEndMoment.isBefore(pastLocalMoment) || recurringEventStartMoment.isAfter(futureLocalMoment)) {
|
|
showRecurrence = false;
|
|
}
|
|
|
|
if (CalendarFetcherUtils.timeFilterApplies(now, recurringEventEndMoment, eventFilterUntil)) {
|
|
showRecurrence = false;
|
|
}
|
|
|
|
if (showRecurrence === true) {
|
|
Log.debug(`saving event: ${recurrenceTitle}`);
|
|
newEvents.push({
|
|
title: recurrenceTitle,
|
|
startDate: recurringEventStartMoment.format("x"),
|
|
endDate: recurringEventEndMoment.format("x"),
|
|
fullDayEvent: CalendarFetcherUtils.isFullDayEvent(event),
|
|
recurringEvent: true,
|
|
class: event.class,
|
|
firstYear: event.start.getFullYear(),
|
|
location: location,
|
|
geo: geo,
|
|
description: description
|
|
});
|
|
} else {
|
|
Log.debug("not saving event ", recurrenceTitle, eventStartMoment);
|
|
}
|
|
Log.debug(" ");
|
|
}
|
|
// End recurring event parsing.
|
|
} else {
|
|
// Single event.
|
|
const fullDayEvent = isFacebookBirthday ? true : CalendarFetcherUtils.isFullDayEvent(event);
|
|
// Log.debug("full day event")
|
|
|
|
// if the start and end are the same, then make end the 'end of day' value (start is at 00:00:00)
|
|
if (fullDayEvent && eventStartMoment.valueOf() === eventEndMoment.valueOf()) {
|
|
eventEndMoment = eventEndMoment.endOf("day");
|
|
}
|
|
|
|
if (config.includePastEvents) {
|
|
// Past event is too far in the past, so skip.
|
|
if (eventEndMoment < pastLocalMoment) {
|
|
return;
|
|
}
|
|
} else {
|
|
// It's not a fullday event, and it is in the past, so skip.
|
|
if (!fullDayEvent && eventEndMoment < now) {
|
|
return;
|
|
}
|
|
|
|
// It's a fullday event, and it is before today, So skip.
|
|
if (fullDayEvent && eventEndMoment <= now.startOf("day")) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// It exceeds the maximumNumberOfDays limit, so skip.
|
|
if (eventStartMoment > futureLocalMoment) {
|
|
return;
|
|
}
|
|
|
|
if (CalendarFetcherUtils.timeFilterApplies(now, eventEndMoment, eventFilterUntil)) {
|
|
return;
|
|
}
|
|
|
|
// Every thing is good. Add it to the list.
|
|
newEvents.push({
|
|
title: title,
|
|
startDate: eventStartMoment.format("x"),
|
|
endDate: eventEndMoment.format("x"),
|
|
fullDayEvent: fullDayEvent,
|
|
recurringEvent: false,
|
|
class: event.class,
|
|
firstYear: event.start.getFullYear(),
|
|
location: location,
|
|
geo: geo,
|
|
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);
|
|
}
|
|
}
|
|
};
|
|
|
|
if (typeof module !== "undefined") {
|
|
module.exports = CalendarFetcherUtils;
|
|
}
|