diff --git a/CHANGELOG.md b/CHANGELOG.md index ebbaf01e..1f8cf3ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ _This release is scheduled to be released on 2023-07-01._ ### Added -- Added tests for severonly +- Added tests for serveronly - Set Timezone `Europe/Berlin` in unit tests (needed for new formatTime tests) ### Removed @@ -23,6 +23,7 @@ _This release is scheduled to be released on 2023-07-01._ - Update electron to v24 - Use node v19 in github workflow (replacing v14) - Refactor formatTime into common util function for default modules +- Refactor some calendar methods into own class and added tests for them ### Fixed diff --git a/modules/default/calendar/calendar.js b/modules/default/calendar/calendar.js index 23b94134..f78df757 100644 --- a/modules/default/calendar/calendar.js +++ b/modules/default/calendar/calendar.js @@ -1,4 +1,4 @@ -/* global cloneObject */ +/* global CalendarUtils, cloneObject */ /* MagicMirror² * Module: Calendar @@ -79,7 +79,7 @@ Module.register("calendar", { // Define required scripts. getScripts: function () { - return ["moment.js"]; + return ["calendarutils.js", "moment.js"]; }, // Define required translations. @@ -108,7 +108,7 @@ Module.register("calendar", { } // Set locale. - moment.updateLocale(config.language, this.getLocaleSpecification(config.timeFormat)); + moment.updateLocale(config.language, CalendarUtils.getLocaleSpecification(config.timeFormat)); // clear data holder before start this.calendarData = {}; @@ -337,7 +337,8 @@ Module.register("calendar", { } } - titleWrapper.innerHTML = this.titleTransform(event.title, this.config.titleReplace, this.config.wrapEvents, this.config.maxTitleLength, this.config.maxTitleLines) + repeatingCountTitle; + const transformedTitle = CalendarUtils.titleTransform(event.title, this.config.titleReplace); + titleWrapper.innerHTML = CalendarUtils.shorten(transformedTitle, this.config.maxTitleLength, this.config.wrapEvents, this.config.maxTitleLines) + repeatingCountTitle; const titleClass = this.titleClassForUrl(event.url); @@ -362,7 +363,7 @@ Module.register("calendar", { // Add endDate to dataheaders if showEnd is enabled if (this.config.showEnd) { - timeWrapper.innerHTML += ` - ${this.capFirst(moment(event.endDate, "x").format("LT"))}`; + timeWrapper.innerHTML += ` - ${CalendarUtils.capFirst(moment(event.endDate, "x").format("LT"))}`; } eventWrapper.appendChild(timeWrapper); @@ -378,20 +379,20 @@ Module.register("calendar", { if (this.config.timeFormat === "absolute") { // Use dateFormat - timeWrapper.innerHTML = this.capFirst(moment(event.startDate, "x").format(this.config.dateFormat)); + timeWrapper.innerHTML = CalendarUtils.capFirst(moment(event.startDate, "x").format(this.config.dateFormat)); // Add end time if showEnd if (this.config.showEnd) { timeWrapper.innerHTML += "-"; - timeWrapper.innerHTML += this.capFirst(moment(event.endDate, "x").format(this.config.dateEndFormat)); + timeWrapper.innerHTML += CalendarUtils.capFirst(moment(event.endDate, "x").format(this.config.dateEndFormat)); } // For full day events we use the fullDayEventDateFormat if (event.fullDayEvent) { //subtract one second so that fullDayEvents end at 23:59:59, and not at 0:00:00 one the next day event.endDate -= ONE_SECOND; - timeWrapper.innerHTML = this.capFirst(moment(event.startDate, "x").format(this.config.fullDayEventDateFormat)); + timeWrapper.innerHTML = CalendarUtils.capFirst(moment(event.startDate, "x").format(this.config.fullDayEventDateFormat)); } else if (this.config.getRelative > 0 && event.startDate < now) { // Ongoing and getRelative is set - timeWrapper.innerHTML = this.capFirst( + timeWrapper.innerHTML = CalendarUtils.capFirst( this.translate("RUNNING", { fallback: `${this.translate("RUNNING")} {timeUntilEnd}`, timeUntilEnd: moment(event.endDate, "x").fromNow(true) @@ -399,19 +400,19 @@ Module.register("calendar", { ); } else if (this.config.urgency > 0 && event.startDate - now < this.config.urgency * ONE_DAY) { // Within urgency days - timeWrapper.innerHTML = this.capFirst(moment(event.startDate, "x").fromNow()); + timeWrapper.innerHTML = CalendarUtils.capFirst(moment(event.startDate, "x").fromNow()); } if (event.fullDayEvent && this.config.nextDaysRelative) { // Full days events within the next two days if (event.today) { - timeWrapper.innerHTML = this.capFirst(this.translate("TODAY")); + timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TODAY")); } else if (event.yesterday) { - timeWrapper.innerHTML = this.capFirst(this.translate("YESTERDAY")); + timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("YESTERDAY")); } else if (event.startDate - now < ONE_DAY && event.startDate - now > 0) { - timeWrapper.innerHTML = this.capFirst(this.translate("TOMORROW")); + timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TOMORROW")); } else if (event.startDate - now < 2 * ONE_DAY && event.startDate - now > 0) { if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") { - timeWrapper.innerHTML = this.capFirst(this.translate("DAYAFTERTOMORROW")); + timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW")); } } } @@ -420,9 +421,9 @@ Module.register("calendar", { if (event.startDate >= now || (event.fullDayEvent && event.today)) { // Use relative time if (!this.config.hideTime && !event.fullDayEvent) { - timeWrapper.innerHTML = this.capFirst(moment(event.startDate, "x").calendar(null, { sameElse: this.config.dateFormat })); + timeWrapper.innerHTML = CalendarUtils.capFirst(moment(event.startDate, "x").calendar(null, { sameElse: this.config.dateFormat })); } else { - timeWrapper.innerHTML = this.capFirst( + timeWrapper.innerHTML = CalendarUtils.capFirst( moment(event.startDate, "x").calendar(null, { sameDay: this.config.showTimeToday ? "LT" : `[${this.translate("TODAY")}]`, nextDay: `[${this.translate("TOMORROW")}]`, @@ -434,27 +435,27 @@ Module.register("calendar", { if (event.fullDayEvent) { // Full days events within the next two days if (event.today) { - timeWrapper.innerHTML = this.capFirst(this.translate("TODAY")); + timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TODAY")); } else if (event.dayBeforeYesterday) { if (this.translate("DAYBEFOREYESTERDAY") !== "DAYBEFOREYESTERDAY") { - timeWrapper.innerHTML = this.capFirst(this.translate("DAYBEFOREYESTERDAY")); + timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYBEFOREYESTERDAY")); } } else if (event.yesterday) { - timeWrapper.innerHTML = this.capFirst(this.translate("YESTERDAY")); + timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("YESTERDAY")); } else if (event.startDate - now < ONE_DAY && event.startDate - now > 0) { - timeWrapper.innerHTML = this.capFirst(this.translate("TOMORROW")); + timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TOMORROW")); } else if (event.startDate - now < 2 * ONE_DAY && event.startDate - now > 0) { if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") { - timeWrapper.innerHTML = this.capFirst(this.translate("DAYAFTERTOMORROW")); + timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW")); } } } else if (event.startDate - now < this.config.getRelative * ONE_HOUR) { // If event is within getRelative hours, display 'in xxx' time format or moment.fromNow() - timeWrapper.innerHTML = this.capFirst(moment(event.startDate, "x").fromNow()); + timeWrapper.innerHTML = CalendarUtils.capFirst(moment(event.startDate, "x").fromNow()); } } else { // Ongoing event - timeWrapper.innerHTML = this.capFirst( + timeWrapper.innerHTML = CalendarUtils.capFirst( this.translate("RUNNING", { fallback: `${this.translate("RUNNING")} {timeUntilEnd}`, timeUntilEnd: moment(event.endDate, "x").fromNow(true) @@ -503,7 +504,9 @@ Module.register("calendar", { const descCell = document.createElement("td"); descCell.className = "location"; descCell.colSpan = "2"; - descCell.innerHTML = this.titleTransform(event.location, this.config.locationTitleReplace, this.config.wrapLocationEvents, this.config.maxLocationTitleLength, this.config.maxEventTitleLines); + + const transformedTitle = CalendarUtils.titleTransform(event.location, this.config.locationTitleReplace); + descCell.innerHTML = CalendarUtils.shorten(transformedTitle, this.config.maxLocationTitleLength, this.config.wrapLocationEvents, this.config.maxEventTitleLines); locationRow.appendChild(descCell); wrapper.appendChild(locationRow); @@ -519,28 +522,6 @@ Module.register("calendar", { return wrapper; }, - /** - * This function accepts a number (either 12 or 24) and returns a moment.js LocaleSpecification with the - * corresponding timeformat to be used in the calendar display. If no number is given (or otherwise invalid input) - * it will a localeSpecification object with the system locale time format. - * - * @param {number} timeFormat Specifies either 12 or 24 hour time format - * @returns {moment.LocaleSpecification} formatted time - */ - getLocaleSpecification: function (timeFormat) { - switch (timeFormat) { - case 12: { - return { longDateFormat: { LT: "h:mm A" } }; - } - case 24: { - return { longDateFormat: { LT: "HH:mm" } }; - } - default: { - return { longDateFormat: { LT: moment.localeData().longDateFormat("LT") } }; - } - } - }, - /** * Checks if this config contains the calendar url. * @@ -870,98 +851,6 @@ Module.register("calendar", { return !!this.getCalendarProperty(url, property, undefined); }, - /** - * Shortens a string if it's longer than maxLength and add a ellipsis to the end - * - * @param {string} string Text string to shorten - * @param {number} maxLength The max length of the string - * @param {boolean} wrapEvents Wrap the text after the line has reached maxLength - * @param {number} maxTitleLines The max number of vertical lines before cutting event title - * @returns {string} The shortened string - */ - shorten: function (string, maxLength, wrapEvents, maxTitleLines) { - if (typeof string !== "string") { - return ""; - } - - if (wrapEvents === true) { - const words = string.split(" "); - let temp = ""; - let currentLine = ""; - let line = 0; - - for (let i = 0; i < words.length; i++) { - const word = words[i]; - if (currentLine.length + word.length < (typeof maxLength === "number" ? maxLength : 25) - 1) { - // max - 1 to account for a space - currentLine += `${word} `; - } else { - line++; - if (line > maxTitleLines - 1) { - if (i < words.length) { - currentLine += "…"; - } - break; - } - - if (currentLine.length > 0) { - temp += `${currentLine}
${word} `; - } else { - temp += `${word}
`; - } - currentLine = ""; - } - } - - return (temp + currentLine).trim(); - } else { - if (maxLength && typeof maxLength === "number" && string.length > maxLength) { - return `${string.trim().slice(0, maxLength)}…`; - } else { - return string.trim(); - } - } - }, - - /** - * Capitalize the first letter of a string - * - * @param {string} string The string to capitalize - * @returns {string} The capitalized string - */ - capFirst: function (string) { - return string.charAt(0).toUpperCase() + string.slice(1); - }, - - /** - * Transforms the title of an event for usage. - * Replaces parts of the text as defined in config.titleReplace. - * Shortens title based on config.maxTitleLength and config.wrapEvents - * - * @param {string} title The title to transform. - * @param {object} titleReplace Pairs of strings to be replaced in the title - * @param {boolean} wrapEvents Wrap the text after the line has reached maxLength - * @param {number} maxTitleLength The max length of the string - * @param {number} maxTitleLines The max number of vertical lines before cutting event title - * @returns {string} The transformed title. - */ - titleTransform: function (title, titleReplace, wrapEvents, maxTitleLength, maxTitleLines) { - for (let needle in titleReplace) { - const replacement = titleReplace[needle]; - - const regParts = needle.match(/^\/(.+)\/([gim]*)$/); - if (regParts) { - // the parsed pattern is a regexp. - needle = new RegExp(regParts[1], regParts[2]); - } - - title = title.replace(needle, replacement); - } - - title = this.shorten(title, maxTitleLength, wrapEvents, maxTitleLines); - return title; - }, - /** * Broadcasts the events to all other modules for reuse. * The all events available in one array, sorted on startdate. diff --git a/modules/default/calendar/calendarfetcher.js b/modules/default/calendar/calendarfetcher.js index 00688ee2..3c7629e1 100644 --- a/modules/default/calendar/calendarfetcher.js +++ b/modules/default/calendar/calendarfetcher.js @@ -11,7 +11,7 @@ const ical = require("node-ical"); const fetch = require("fetch"); const Log = require("logger"); const NodeHelper = require("node_helper"); -const CalendarUtils = require("./calendarutils"); +const CalendarFetcherUtils = require("./calendarfetcherutils"); /** * @@ -72,7 +72,7 @@ const CalendarFetcher = function (url, reloadInterval, excludedEvents, maximumEn try { data = ical.parseICS(responseData); Log.debug(`parsed data=${JSON.stringify(data)}`); - events = CalendarUtils.filterEvents(data, { + events = CalendarFetcherUtils.filterEvents(data, { excludedEvents, includePastEvents, maximumEntries, diff --git a/modules/default/calendar/calendarfetcherutils.js b/modules/default/calendar/calendarfetcherutils.js new file mode 100644 index 00000000..e47b6bba --- /dev/null +++ b/modules/default/calendar/calendarfetcherutils.js @@ -0,0 +1,613 @@ +/* MagicMirror² + * Calendar Fetcher Util Methods + * + * By Michael Teeuw https://michaelteeuw.nl + * MIT Licensed. + */ + +/** + * @external Moment + */ +const path = require("path"); +const moment = require("moment"); +const zoneTable = require(path.join(__dirname, "windowsZones.json")); +const Log = require("../../../js/logger"); + +const CalendarFetcherUtils = { + /** + * Calculate the time correction, either dst/std or full day in cases where + * utc time is day before plus offset + * + * @param {object} event the event which needs adjustment + * @param {Date} date the date on which this event happens + * @returns {number} the necessary adjustment in hours + */ + calculateTimezoneAdjustment: function (event, date) { + let adjustHours = 0; + // if a timezone was specified + if (!event.start.tz) { + Log.debug(" if no tz, guess based on now"); + event.start.tz = moment.tz.guess(); + } + Log.debug(`initial tz=${event.start.tz}`); + + // if there is a start date specified + if (event.start.tz) { + // if this is a windows timezone + if (event.start.tz.includes(" ")) { + // use the lookup table to get theIANA name as moment and date don't know MS timezones + let tz = CalendarFetcherUtils.getIanaTZFromMS(event.start.tz); + Log.debug(`corrected TZ=${tz}`); + // watch out for unregistered windows timezone names + // if we had a successful lookup + if (tz) { + // change the timezone to the IANA name + event.start.tz = tz; + // Log.debug("corrected timezone="+event.start.tz) + } + } + Log.debug(`corrected tz=${event.start.tz}`); + let current_offset = 0; // offset from TZ string or calculated + let mm = 0; // date with tz or offset + let start_offset = 0; // utc offset of created with tz + // if there is still an offset, lookup failed, use it + if (event.start.tz.startsWith("(")) { + const regex = /[+|-]\d*:\d*/; + const start_offsetString = event.start.tz.match(regex).toString().split(":"); + let start_offset = parseInt(start_offsetString[0]); + start_offset *= event.start.tz[1] === "-" ? -1 : 1; + adjustHours = start_offset; + Log.debug(`defined offset=${start_offset} hours`); + current_offset = start_offset; + event.start.tz = ""; + Log.debug(`ical offset=${current_offset} date=${date}`); + mm = moment(date); + let x = parseInt(moment(new Date()).utcOffset()); + Log.debug(`net mins=${current_offset * 60 - x}`); + + mm = mm.add(x - current_offset * 60, "minutes"); + adjustHours = (current_offset * 60 - x) / 60; + event.start = mm.toDate(); + Log.debug(`adjusted date=${event.start}`); + } else { + // get the start time in that timezone + let es = moment(event.start); + // check for start date prior to start of daylight changing date + if (es.format("YYYY") < 2007) { + es.set("year", 2013); // if so, use a closer date + } + Log.debug(`start date/time=${es.toDate()}`); + start_offset = moment.tz(es, event.start.tz).utcOffset(); + Log.debug(`start offset=${start_offset}`); + + Log.debug(`start date/time w tz =${moment.tz(moment(event.start), event.start.tz).toDate()}`); + + // get the specified date in that timezone + mm = moment.tz(moment(date), event.start.tz); + Log.debug(`event date=${mm.toDate()}`); + current_offset = mm.utcOffset(); + } + Log.debug(`event offset=${current_offset} hour=${mm.format("H")} event date=${mm.toDate()}`); + + // if the offset is greater than 0, east of london + if (current_offset !== start_offset) { + // big offset + Log.debug("offset"); + let h = parseInt(mm.format("H")); + // check if the event time is less than the offset + if (h > 0 && h < Math.abs(current_offset) / 60) { + // if so, rrule created a wrong date (utc day, oops, with utc yesterday adjusted time) + // we need to fix that + //adjustHours = 24; + // Log.debug("adjusting date") + } + //-300 > -240 + //if (Math.abs(current_offset) > Math.abs(start_offset)){ + if (current_offset > start_offset) { + adjustHours -= 1; + Log.debug("adjust down 1 hour dst change"); + //} else if (Math.abs(current_offset) < Math.abs(start_offset)) { + } else if (current_offset < start_offset) { + adjustHours += 1; + Log.debug("adjust up 1 hour dst change"); + } + } + } + Log.debug(`adjustHours=${adjustHours}`); + return adjustHours; + }, + + /** + * 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: function (data, config) { + const newEvents = []; + + // limitFunction doesn't do much limiting, see comment re: the dates + // array in rrule section below as to why we need to do the filtering + // ourselves + const limitFunction = function (date, i) { + return true; + }; + + const eventDate = function (event, time) { + return CalendarFetcherUtils.isFullDayEvent(event) ? moment(event[time], "YYYYMMDD") : moment(new Date(event[time])); + }; + + Log.debug(`There are ${Object.entries(data).length} calendar entries.`); + Object.entries(data).forEach(([key, event]) => { + Log.debug("Processing entry..."); + const now = new Date(); + const today = moment().startOf("day").toDate(); + const future = moment().startOf("day").add(config.maximumNumberOfDays, "days").subtract(1, "seconds").toDate(); // Subtract 1 second so that events that start on the middle of the night will not repeat. + let past = today; + + if (config.includePastEvents) { + past = moment().startOf("day").subtract(config.maximumNumberOfDays, "days").toDate(); + } + + // 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)}`); + let startDate = eventDate(event, "start"); + let endDate; + + if (typeof event.end !== "undefined") { + endDate = eventDate(event, "end"); + } else if (typeof event.duration !== "undefined") { + endDate = startDate.clone().add(moment.duration(event.duration)); + } else { + if (!isFacebookBirthday) { + // make copy of start date, separate storage area + endDate = moment(startDate.format("x"), "x"); + } else { + endDate = moment(startDate).add(1, "days"); + } + } + + Log.debug(`start: ${startDate.toDate()}`); + Log.debug(`end:: ${endDate.toDate()}`); + + // Calculate the duration of the event for use with recurring events. + let duration = parseInt(endDate.format("x")) - parseInt(startDate.format("x")); + Log.debug(`duration: ${duration}`); + + // FIXME: Since the parsed json object from node-ical comes with time information + // this check could be removed (?) + if (event.start.length === 8) { + startDate = startDate.startOf("day"); + } + + const title = CalendarFetcherUtils.getTitleFromEvent(event); + Log.debug(`title: ${title}`); + + let excluded = false, + dateFilter = 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) { + dateFilter = until; + } else { + excluded = true; + } + break; + } + } + + if (excluded) { + return; + } + + const location = event.location || false; + const geo = event.geo || false; + const description = event.description || false; + + if (typeof event.rrule !== "undefined" && event.rrule !== null && !isFacebookBirthday) { + const rule = event.rrule; + let addedEvents = 0; + + const pastMoment = moment(past); + const futureMoment = moment(future); + + // 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); + } + + // For recurring events, get the set of start dates that fall within the range + // of dates we're looking for. + // kblankenship1989 - to fix issue #1798, converting all dates to locale time first, then converting back to UTC time + let pastLocal = 0; + let futureLocal = 0; + if (CalendarFetcherUtils.isFullDayEvent(event)) { + Log.debug("fullday"); + // if full day event, only use the date part of the ranges + pastLocal = pastMoment.toDate(); + futureLocal = futureMoment.toDate(); + + Log.debug(`pastLocal: ${pastLocal}`); + Log.debug(`futureLocal: ${futureLocal}`); + } else { + // if we want past events + if (config.includePastEvents) { + // use the calculated past time for the between from + pastLocal = pastMoment.toDate(); + } else { + // otherwise use NOW.. cause we shouldn't use any before now + pastLocal = moment().toDate(); //now + } + futureLocal = futureMoment.toDate(); // future + } + Log.debug(`Search for recurring events between: ${pastLocal} and ${futureLocal}`); + const dates = rule.between(pastLocal, futureLocal, true, limitFunction); + Log.debug(`Title: ${event.summary}, with dates: ${JSON.stringify(dates)}`); + // The "dates" array contains the set of dates within our desired date range range that are valid + // for the recurrence rule. *However*, it's possible for us to have a specific recurrence that + // had its date changed from outside the range to inside the range. For the time being, + // we'll handle this by adding *all* recurrence entries into the set of dates that we check, + // because the logic below will filter out any recurrences that don't actually belong within + // our display range. + // Would be great if there was a better way to handle this. + Log.debug(`event.recurrences: ${event.recurrences}`); + if (event.recurrences !== undefined) { + for (let r in event.recurrences) { + // Only add dates that weren't already in the range we added from the rrule so that + // we don"t double-add those events. + if (moment(new Date(r)).isBetween(pastMoment, futureMoment) !== true) { + dates.push(new Date(r)); + } + } + } + // Loop through the set of date entries to see which recurrences should be added to our event list. + for (let d in dates) { + let date = dates[d]; + // Remove the time information of each date by using its substring, using the following method: + // .toISOString().substring(0,10). + // since the date is given as ISOString with YYYY-MM-DDTHH:MM:SS.SSSZ + // (see https://momentjs.com/docs/#/displaying/as-iso-string/). + const dateKey = date.toISOString().substring(0, 10); + let curEvent = event; + let showRecurrence = true; + + // Get the offset of today where we are processing + // This will be the correction, we need to apply. + let nowOffset = new Date().getTimezoneOffset(); + // For full day events, the time might be off from RRULE/Luxon problem + // Get time zone offset of the rule calculated event + let dateoffset = date.getTimezoneOffset(); + + // Reduce the time by the following offset. + Log.debug(` recurring date is ${date} offset is ${dateoffset}`); + + let dh = moment(date).format("HH"); + Log.debug(` recurring date is ${date} offset is ${dateoffset / 60} Hour is ${dh}`); + + if (CalendarFetcherUtils.isFullDayEvent(event)) { + Log.debug("Fullday"); + // If the offset is negative (east of GMT), where the problem is + if (dateoffset < 0) { + if (dh < Math.abs(dateoffset / 60)) { + // if the rrule byweekday WAS explicitly set , correct it + // reduce the time by the offset + if (curEvent.rrule.origOptions.byweekday !== undefined) { + // Apply the correction to the date/time to get it UTC relative + date = new Date(date.getTime() - Math.abs(24 * 60) * 60000); + } + // the duration was calculated way back at the top before we could correct the start time.. + // fix it for this event entry + //duration = 24 * 60 * 60 * 1000; + Log.debug(`new recurring date1 fulldate is ${date}`); + } + } else { + // if the timezones are the same, correct date if needed + //if (event.start.tz === moment.tz.guess()) { + // if the date hour is less than the offset + if (24 - dh <= Math.abs(dateoffset / 60)) { + // if the rrule byweekday WAS explicitly set , correct it + if (curEvent.rrule.origOptions.byweekday !== undefined) { + // apply the correction to the date/time back to right day + date = new Date(date.getTime() + Math.abs(24 * 60) * 60000); + } + // the duration was calculated way back at the top before we could correct the start time.. + // fix it for this event entry + //duration = 24 * 60 * 60 * 1000; + Log.debug(`new recurring date2 fulldate is ${date}`); + } + //} + } + } else { + // not full day, but luxon can still screw up the date on the rule processing + // we need to correct the date to get back to the right event for + if (dateoffset < 0) { + // if the date hour is less than the offset + if (dh <= Math.abs(dateoffset / 60)) { + // if the rrule byweekday WAS explicitly set , correct it + if (curEvent.rrule.origOptions.byweekday !== undefined) { + // Reduce the time by t: + // Apply the correction to the date/time to get it UTC relative + date = new Date(date.getTime() - Math.abs(24 * 60) * 60000); + } + // the duration was calculated way back at the top before we could correct the start time.. + // fix it for this event entry + //duration = 24 * 60 * 60 * 1000; + Log.debug(`new recurring date1 is ${date}`); + } + } else { + // if the timezones are the same, correct date if needed + //if (event.start.tz === moment.tz.guess()) { + // if the date hour is less than the offset + if (24 - dh <= Math.abs(dateoffset / 60)) { + // if the rrule byweekday WAS explicitly set , correct it + if (curEvent.rrule.origOptions.byweekday !== undefined) { + // apply the correction to the date/time back to right day + date = new Date(date.getTime() + Math.abs(24 * 60) * 60000); + } + // the duration was calculated way back at the top before we could correct the start time.. + // fix it for this event entry + //duration = 24 * 60 * 60 * 1000; + Log.debug(`new recurring date2 is ${date}`); + } + //} + } + } + startDate = moment(date); + Log.debug(`Corrected startDate: ${startDate.toDate()}`); + + let adjustDays = CalendarFetcherUtils.calculateTimezoneAdjustment(event, date); + + // For each date that we're checking, it's possible that there is a recurrence override for that one day. + if (curEvent.recurrences !== undefined && curEvent.recurrences[dateKey] !== undefined) { + // We found an override, so for this recurrence, use a potentially different title, start date, and duration. + curEvent = curEvent.recurrences[dateKey]; + startDate = moment(curEvent.start); + duration = parseInt(moment(curEvent.end).format("x")) - parseInt(startDate.format("x")); + } + // If there's no recurrence override, check for an exception date. Exception dates represent exceptions to the rule. + else if (curEvent.exdate !== undefined && curEvent.exdate[dateKey] !== undefined) { + // This date is an exception date, which means we should skip it in the recurrence pattern. + showRecurrence = false; + } + Log.debug(`duration: ${duration}`); + + endDate = moment(parseInt(startDate.format("x")) + duration, "x"); + if (startDate.format("x") === endDate.format("x")) { + endDate = endDate.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 (endDate.isBefore(past) || startDate.isAfter(future)) { + showRecurrence = false; + } + + if (CalendarFetcherUtils.timeFilterApplies(now, endDate, dateFilter)) { + showRecurrence = false; + } + + if (showRecurrence === true) { + Log.debug(`saving event: ${description}`); + addedEvents++; + newEvents.push({ + title: recurrenceTitle, + startDate: (adjustDays ? (adjustDays > 0 ? startDate.add(adjustDays, "hours") : startDate.subtract(Math.abs(adjustDays), "hours")) : startDate).format("x"), + endDate: (adjustDays ? (adjustDays > 0 ? endDate.add(adjustDays, "hours") : endDate.subtract(Math.abs(adjustDays), "hours")) : endDate).format("x"), + fullDayEvent: CalendarFetcherUtils.isFullDayEvent(event), + recurringEvent: true, + class: event.class, + firstYear: event.start.getFullYear(), + location: location, + geo: geo, + description: description + }); + } + } + // End recurring event parsing. + } else { + // Single event. + const fullDayEvent = isFacebookBirthday ? true : CalendarFetcherUtils.isFullDayEvent(event); + // Log.debug("full day event") + + if (config.includePastEvents) { + // Past event is too far in the past, so skip. + if (endDate < past) { + return; + } + } else { + // It's not a fullday event, and it is in the past, so skip. + if (!fullDayEvent && endDate < new Date()) { + return; + } + + // It's a fullday event, and it is before today, So skip. + if (fullDayEvent && endDate <= today) { + return; + } + } + + // It exceeds the maximumNumberOfDays limit, so skip. + if (startDate > future) { + return; + } + + if (CalendarFetcherUtils.timeFilterApplies(now, endDate, dateFilter)) { + return; + } + + // if the start and end are the same, then make end the 'end of day' value (start is at 00:00:00) + if (fullDayEvent && startDate.format("x") === endDate.format("x")) { + endDate = endDate.endOf("day"); + } + // get correction for date saving and dst change between now and then + let adjustDays = CalendarFetcherUtils.calculateTimezoneAdjustment(event, startDate.toDate()); + // Every thing is good. Add it to the list. + newEvents.push({ + title: title, + startDate: (adjustDays ? (adjustDays > 0 ? startDate.add(adjustDays, "hours") : startDate.subtract(Math.abs(adjustDays), "hours")) : startDate).format("x"), + endDate: (adjustDays ? (adjustDays > 0 ? endDate.add(adjustDays, "hours") : endDate.subtract(Math.abs(adjustDays), "hours")) : endDate).format("x"), + fullDayEvent: fullDayEvent, + class: event.class, + location: location, + geo: geo, + description: description + }); + } + } + }); + + newEvents.sort(function (a, b) { + return a.startDate - b.startDate; + }); + + return newEvents; + }, + + /** + * Lookup iana tz from windows + * + * @param {string} msTZName the timezone name to lookup + * @returns {string|null} the iana name or null of none is found + */ + getIanaTZFromMS: function (msTZName) { + // Get hash entry + const he = zoneTable[msTZName]; + // If found return iana name, else null + return he ? he.iana[0] : null; + }, + + /** + * 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: function (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: function (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 {Date} now Date object using previously created object for consistency + * @param {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: function (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.format("x"); + } + + 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: function (title, filter, useRegex, regexFlags) { + if (useRegex) { + // Assume if leading slash, there is also trailing slash + if (filter[0] === "/") { + // Strip leading and trailing slashes + filter = filter.substr(1).slice(0, -1); + } + + filter = new RegExp(filter, regexFlags); + + return filter.test(title); + } else { + return title.includes(filter); + } + } +}; + +if (typeof module !== "undefined") { + module.exports = CalendarFetcherUtils; +} diff --git a/modules/default/calendar/calendarutils.js b/modules/default/calendar/calendarutils.js index 64a7b9f4..601ccdf0 100644 --- a/modules/default/calendar/calendarutils.js +++ b/modules/default/calendar/calendarutils.js @@ -1,610 +1,117 @@ /* MagicMirror² * Calendar Util Methods * - * By Michael Teeuw https://michaelteeuw.nl + * By Rejas * MIT Licensed. */ - -/** - * @external Moment - */ -const path = require("path"); -const moment = require("moment"); -const zoneTable = require(path.join(__dirname, "windowsZones.json")); -const Log = require("../../../js/logger"); - const CalendarUtils = { /** - * Calculate the time correction, either dst/std or full day in cases where - * utc time is day before plus offset + * Capitalize the first letter of a string * - * @param {object} event the event which needs adjustement - * @param {Date} date the date on which this event happens - * @returns {number} the necessary adjustment in hours + * @param {string} string The string to capitalize + * @returns {string} The capitalized string */ - calculateTimezoneAdjustment: function (event, date) { - let adjustHours = 0; - // if a timezone was specified - if (!event.start.tz) { - Log.debug(" if no tz, guess based on now"); - event.start.tz = moment.tz.guess(); - } - Log.debug(`initial tz=${event.start.tz}`); - - // if there is a start date specified - if (event.start.tz) { - // if this is a windows timezone - if (event.start.tz.includes(" ")) { - // use the lookup table to get theIANA name as moment and date don't know MS timezones - let tz = CalendarUtils.getIanaTZFromMS(event.start.tz); - Log.debug(`corrected TZ=${tz}`); - // watch out for unregistered windows timezone names - // if we had a successful lookup - if (tz) { - // change the timezone to the IANA name - event.start.tz = tz; - // Log.debug("corrected timezone="+event.start.tz) - } - } - Log.debug(`corrected tz=${event.start.tz}`); - let current_offset = 0; // offset from TZ string or calculated - let mm = 0; // date with tz or offset - let start_offset = 0; // utc offset of created with tz - // if there is still an offset, lookup failed, use it - if (event.start.tz.startsWith("(")) { - const regex = /[+|-]\d*:\d*/; - const start_offsetString = event.start.tz.match(regex).toString().split(":"); - let start_offset = parseInt(start_offsetString[0]); - start_offset *= event.start.tz[1] === "-" ? -1 : 1; - adjustHours = start_offset; - Log.debug(`defined offset=${start_offset} hours`); - current_offset = start_offset; - event.start.tz = ""; - Log.debug(`ical offset=${current_offset} date=${date}`); - mm = moment(date); - let x = parseInt(moment(new Date()).utcOffset()); - Log.debug(`net mins=${current_offset * 60 - x}`); - - mm = mm.add(x - current_offset * 60, "minutes"); - adjustHours = (current_offset * 60 - x) / 60; - event.start = mm.toDate(); - Log.debug(`adjusted date=${event.start}`); - } else { - // get the start time in that timezone - let es = moment(event.start); - // check for start date prior to start of daylight changing date - if (es.format("YYYY") < 2007) { - es.set("year", 2013); // if so, use a closer date - } - Log.debug(`start date/time=${es.toDate()}`); - start_offset = moment.tz(es, event.start.tz).utcOffset(); - Log.debug(`start offset=${start_offset}`); - - Log.debug(`start date/time w tz =${moment.tz(moment(event.start), event.start.tz).toDate()}`); - - // get the specified date in that timezone - mm = moment.tz(moment(date), event.start.tz); - Log.debug(`event date=${mm.toDate()}`); - current_offset = mm.utcOffset(); - } - Log.debug(`event offset=${current_offset} hour=${mm.format("H")} event date=${mm.toDate()}`); - - // if the offset is greater than 0, east of london - if (current_offset !== start_offset) { - // big offset - Log.debug("offset"); - let h = parseInt(mm.format("H")); - // check if the event time is less than the offset - if (h > 0 && h < Math.abs(current_offset) / 60) { - // if so, rrule created a wrong date (utc day, oops, with utc yesterday adjusted time) - // we need to fix that - //adjustHours = 24; - // Log.debug("adjusting date") - } - //-300 > -240 - //if (Math.abs(current_offset) > Math.abs(start_offset)){ - if (current_offset > start_offset) { - adjustHours -= 1; - Log.debug("adjust down 1 hour dst change"); - //} else if (Math.abs(current_offset) < Math.abs(start_offset)) { - } else if (current_offset < start_offset) { - adjustHours += 1; - Log.debug("adjust up 1 hour dst change"); - } - } - } - Log.debug(`adjustHours=${adjustHours}`); - return adjustHours; + capFirst: function (string) { + return string.charAt(0).toUpperCase() + string.slice(1); }, /** - * Filter the events from ical according to the given config + * This function accepts a number (either 12 or 24) and returns a moment.js LocaleSpecification with the + * corresponding time-format to be used in the calendar display. If no number is given (or otherwise invalid input) + * it will a localeSpecification object with the system locale time format. * - * @param {object} data the calendar data from ical - * @param {object} config The configuration object - * @returns {string[]} the filtered events + * @param {number} timeFormat Specifies either 12 or 24-hour time format + * @returns {moment.LocaleSpecification} formatted time */ - filterEvents: function (data, config) { - const newEvents = []; - - // limitFunction doesn't do much limiting, see comment re: the dates - // array in rrule section below as to why we need to do the filtering - // ourselves - const limitFunction = function (date, i) { - return true; - }; - - const eventDate = function (event, time) { - return CalendarUtils.isFullDayEvent(event) ? moment(event[time], "YYYYMMDD") : moment(new Date(event[time])); - }; - - Log.debug(`There are ${Object.entries(data).length} calendar entries.`); - Object.entries(data).forEach(([key, event]) => { - Log.debug("Processing entry..."); - const now = new Date(); - const today = moment().startOf("day").toDate(); - const future = moment().startOf("day").add(config.maximumNumberOfDays, "days").subtract(1, "seconds").toDate(); // Subtract 1 second so that events that start on the middle of the night will not repeat. - let past = today; - - if (config.includePastEvents) { - past = moment().startOf("day").subtract(config.maximumNumberOfDays, "days").toDate(); + getLocaleSpecification: function (timeFormat) { + switch (timeFormat) { + case 12: { + return { longDateFormat: { LT: "h:mm A" } }; } - - // 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; - } + case 24: { + return { longDateFormat: { LT: "HH:mm" } }; } + default: { + return { longDateFormat: { LT: moment.localeData().longDateFormat("LT") } }; + } + } + }, - if (event.type === "VEVENT") { - Log.debug(`Event:\n${JSON.stringify(event)}`); - let startDate = eventDate(event, "start"); - let endDate; + /** + * Shortens a string if it's longer than maxLength and add an ellipsis to the end + * + * @param {string} string Text string to shorten + * @param {number} maxLength The max length of the string + * @param {boolean} wrapEvents Wrap the text after the line has reached maxLength + * @param {number} maxTitleLines The max number of vertical lines before cutting event title + * @returns {string} The shortened string + */ + shorten: function (string, maxLength, wrapEvents, maxTitleLines) { + if (typeof string !== "string") { + return ""; + } - if (typeof event.end !== "undefined") { - endDate = eventDate(event, "end"); - } else if (typeof event.duration !== "undefined") { - endDate = startDate.clone().add(moment.duration(event.duration)); + if (wrapEvents === true) { + const words = string.split(" "); + let temp = ""; + let currentLine = ""; + let line = 0; + + for (let i = 0; i < words.length; i++) { + const word = words[i]; + if (currentLine.length + word.length < (typeof maxLength === "number" ? maxLength : 25) - 1) { + // max - 1 to account for a space + currentLine += `${word} `; } else { - if (!isFacebookBirthday) { - // make copy of start date, separate storage area - endDate = moment(startDate.format("x"), "x"); - } else { - endDate = moment(startDate).add(1, "days"); - } - } - - Log.debug(`start: ${startDate.toDate()}`); - Log.debug(`end:: ${endDate.toDate()}`); - - // Calculate the duration of the event for use with recurring events. - let duration = parseInt(endDate.format("x")) - parseInt(startDate.format("x")); - Log.debug(`duration: ${duration}`); - - // FIXME: Since the parsed json object from node-ical comes with time information - // this check could be removed (?) - if (event.start.length === 8) { - startDate = startDate.startOf("day"); - } - - const title = CalendarUtils.getTitleFromEvent(event); - Log.debug(`title: ${title}`); - - let excluded = false, - dateFilter = 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 (CalendarUtils.titleFilterApplies(testTitle, filter, useRegex, regexFlags)) { - if (until) { - dateFilter = until; - } else { - excluded = true; + line++; + if (line > maxTitleLines - 1) { + if (i < words.length) { + currentLine += "…"; } break; } - } - if (excluded) { - return; - } - - const location = event.location || false; - const geo = event.geo || false; - const description = event.description || false; - - if (typeof event.rrule !== "undefined" && event.rrule !== null && !isFacebookBirthday) { - const rule = event.rrule; - let addedEvents = 0; - - const pastMoment = moment(past); - const futureMoment = moment(future); - - // 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); - } - - // For recurring events, get the set of start dates that fall within the range - // of dates we're looking for. - // kblankenship1989 - to fix issue #1798, converting all dates to locale time first, then converting back to UTC time - let pastLocal = 0; - let futureLocal = 0; - if (CalendarUtils.isFullDayEvent(event)) { - Log.debug("fullday"); - // if full day event, only use the date part of the ranges - pastLocal = pastMoment.toDate(); - futureLocal = futureMoment.toDate(); - - Log.debug(`pastLocal: ${pastLocal}`); - Log.debug(`futureLocal: ${futureLocal}`); + if (currentLine.length > 0) { + temp += `${currentLine}
${word} `; } else { - // if we want past events - if (config.includePastEvents) { - // use the calculated past time for the between from - pastLocal = pastMoment.toDate(); - } else { - // otherwise use NOW.. cause we shouldn't use any before now - pastLocal = moment().toDate(); //now - } - futureLocal = futureMoment.toDate(); // future + temp += `${word}
`; } - Log.debug(`Search for recurring events between: ${pastLocal} and ${futureLocal}`); - const dates = rule.between(pastLocal, futureLocal, true, limitFunction); - Log.debug(`Title: ${event.summary}, with dates: ${JSON.stringify(dates)}`); - // The "dates" array contains the set of dates within our desired date range range that are valid - // for the recurrence rule. *However*, it's possible for us to have a specific recurrence that - // had its date changed from outside the range to inside the range. For the time being, - // we'll handle this by adding *all* recurrence entries into the set of dates that we check, - // because the logic below will filter out any recurrences that don't actually belong within - // our display range. - // Would be great if there was a better way to handle this. - Log.debug(`event.recurrences: ${event.recurrences}`); - if (event.recurrences !== undefined) { - for (let r in event.recurrences) { - // Only add dates that weren't already in the range we added from the rrule so that - // we don"t double-add those events. - if (moment(new Date(r)).isBetween(pastMoment, futureMoment) !== true) { - dates.push(new Date(r)); - } - } - } - // Loop through the set of date entries to see which recurrences should be added to our event list. - for (let d in dates) { - let date = dates[d]; - // Remove the time information of each date by using its substring, using the following method: - // .toISOString().substring(0,10). - // since the date is given as ISOString with YYYY-MM-DDTHH:MM:SS.SSSZ - // (see https://momentjs.com/docs/#/displaying/as-iso-string/). - const dateKey = date.toISOString().substring(0, 10); - let curEvent = event; - let showRecurrence = true; - - // Get the offset of today where we are processing - // This will be the correction, we need to apply. - let nowOffset = new Date().getTimezoneOffset(); - // For full day events, the time might be off from RRULE/Luxon problem - // Get time zone offset of the rule calculated event - let dateoffset = date.getTimezoneOffset(); - - // Reduce the time by the following offset. - Log.debug(` recurring date is ${date} offset is ${dateoffset}`); - - let dh = moment(date).format("HH"); - Log.debug(` recurring date is ${date} offset is ${dateoffset / 60} Hour is ${dh}`); - - if (CalendarUtils.isFullDayEvent(event)) { - Log.debug("Fullday"); - // If the offset is negative (east of GMT), where the problem is - if (dateoffset < 0) { - if (dh < Math.abs(dateoffset / 60)) { - // if the rrule byweekday WAS explicitly set , correct it - // reduce the time by the offset - if (curEvent.rrule.origOptions.byweekday !== undefined) { - // Apply the correction to the date/time to get it UTC relative - date = new Date(date.getTime() - Math.abs(24 * 60) * 60000); - } - // the duration was calculated way back at the top before we could correct the start time.. - // fix it for this event entry - //duration = 24 * 60 * 60 * 1000; - Log.debug(`new recurring date1 fulldate is ${date}`); - } - } else { - // if the timezones are the same, correct date if needed - //if (event.start.tz === moment.tz.guess()) { - // if the date hour is less than the offset - if (24 - dh <= Math.abs(dateoffset / 60)) { - // if the rrule byweekday WAS explicitly set , correct it - if (curEvent.rrule.origOptions.byweekday !== undefined) { - // apply the correction to the date/time back to right day - date = new Date(date.getTime() + Math.abs(24 * 60) * 60000); - } - // the duration was calculated way back at the top before we could correct the start time.. - // fix it for this event entry - //duration = 24 * 60 * 60 * 1000; - Log.debug(`new recurring date2 fulldate is ${date}`); - } - //} - } - } else { - // not full day, but luxon can still screw up the date on the rule processing - // we need to correct the date to get back to the right event for - if (dateoffset < 0) { - // if the date hour is less than the offset - if (dh <= Math.abs(dateoffset / 60)) { - // if the rrule byweekday WAS explicitly set , correct it - if (curEvent.rrule.origOptions.byweekday !== undefined) { - // Reduce the time by t: - // Apply the correction to the date/time to get it UTC relative - date = new Date(date.getTime() - Math.abs(24 * 60) * 60000); - } - // the duration was calculated way back at the top before we could correct the start time.. - // fix it for this event entry - //duration = 24 * 60 * 60 * 1000; - Log.debug(`new recurring date1 is ${date}`); - } - } else { - // if the timezones are the same, correct date if needed - //if (event.start.tz === moment.tz.guess()) { - // if the date hour is less than the offset - if (24 - dh <= Math.abs(dateoffset / 60)) { - // if the rrule byweekday WAS explicitly set , correct it - if (curEvent.rrule.origOptions.byweekday !== undefined) { - // apply the correction to the date/time back to right day - date = new Date(date.getTime() + Math.abs(24 * 60) * 60000); - } - // the duration was calculated way back at the top before we could correct the start time.. - // fix it for this event entry - //duration = 24 * 60 * 60 * 1000; - Log.debug(`new recurring date2 is ${date}`); - } - //} - } - } - startDate = moment(date); - Log.debug(`Corrected startDate: ${startDate.toDate()}`); - - let adjustDays = CalendarUtils.calculateTimezoneAdjustment(event, date); - - // For each date that we're checking, it's possible that there is a recurrence override for that one day. - if (curEvent.recurrences !== undefined && curEvent.recurrences[dateKey] !== undefined) { - // We found an override, so for this recurrence, use a potentially different title, start date, and duration. - curEvent = curEvent.recurrences[dateKey]; - startDate = moment(curEvent.start); - duration = parseInt(moment(curEvent.end).format("x")) - parseInt(startDate.format("x")); - } - // If there's no recurrence override, check for an exception date. Exception dates represent exceptions to the rule. - else if (curEvent.exdate !== undefined && curEvent.exdate[dateKey] !== undefined) { - // This date is an exception date, which means we should skip it in the recurrence pattern. - showRecurrence = false; - } - Log.debug(`duration: ${duration}`); - - endDate = moment(parseInt(startDate.format("x")) + duration, "x"); - if (startDate.format("x") === endDate.format("x")) { - endDate = endDate.endOf("day"); - } - - const recurrenceTitle = CalendarUtils.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 (endDate.isBefore(past) || startDate.isAfter(future)) { - showRecurrence = false; - } - - if (CalendarUtils.timeFilterApplies(now, endDate, dateFilter)) { - showRecurrence = false; - } - - if (showRecurrence === true) { - Log.debug(`saving event: ${description}`); - addedEvents++; - newEvents.push({ - title: recurrenceTitle, - startDate: (adjustDays ? (adjustDays > 0 ? startDate.add(adjustDays, "hours") : startDate.subtract(Math.abs(adjustDays), "hours")) : startDate).format("x"), - endDate: (adjustDays ? (adjustDays > 0 ? endDate.add(adjustDays, "hours") : endDate.subtract(Math.abs(adjustDays), "hours")) : endDate).format("x"), - fullDayEvent: CalendarUtils.isFullDayEvent(event), - recurringEvent: true, - class: event.class, - firstYear: event.start.getFullYear(), - location: location, - geo: geo, - description: description - }); - } - } - // End recurring event parsing. - } else { - // Single event. - const fullDayEvent = isFacebookBirthday ? true : CalendarUtils.isFullDayEvent(event); - // Log.debug("full day event") - - if (config.includePastEvents) { - // Past event is too far in the past, so skip. - if (endDate < past) { - return; - } - } else { - // It's not a fullday event, and it is in the past, so skip. - if (!fullDayEvent && endDate < new Date()) { - return; - } - - // It's a fullday event, and it is before today, So skip. - if (fullDayEvent && endDate <= today) { - return; - } - } - - // It exceeds the maximumNumberOfDays limit, so skip. - if (startDate > future) { - return; - } - - if (CalendarUtils.timeFilterApplies(now, endDate, dateFilter)) { - return; - } - - // if the start and end are the same, then make end the 'end of day' value (start is at 00:00:00) - if (fullDayEvent && startDate.format("x") === endDate.format("x")) { - endDate = endDate.endOf("day"); - } - // get correction for date saving and dst change between now and then - let adjustDays = CalendarUtils.calculateTimezoneAdjustment(event, startDate.toDate()); - // Every thing is good. Add it to the list. - newEvents.push({ - title: title, - startDate: (adjustDays ? (adjustDays > 0 ? startDate.add(adjustDays, "hours") : startDate.subtract(Math.abs(adjustDays), "hours")) : startDate).format("x"), - endDate: (adjustDays ? (adjustDays > 0 ? endDate.add(adjustDays, "hours") : endDate.subtract(Math.abs(adjustDays), "hours")) : endDate).format("x"), - fullDayEvent: fullDayEvent, - class: event.class, - location: location, - geo: geo, - description: description - }); + currentLine = ""; } } - }); - newEvents.sort(function (a, b) { - return a.startDate - b.startDate; - }); - - return newEvents; - }, - - /** - * Lookup iana tz from windows - * - * @param {string} msTZName the timezone name to lookup - * @returns {string|null} the iana name or null of none is found - */ - getIanaTZFromMS: function (msTZName) { - // Get hash entry - const he = zoneTable[msTZName]; - // If found return iana name, else null - return he ? he.iana[0] : null; - }, - - /** - * 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: function (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: function (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 {Date} now Date object using previously created object for consistency - * @param {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: function (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.format("x"); - } - - 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: function (title, filter, useRegex, regexFlags) { - if (useRegex) { - // Assume if leading slash, there is also trailing slash - if (filter[0] === "/") { - // Strip leading and trailing slashes - filter = filter.substr(1).slice(0, -1); - } - - filter = new RegExp(filter, regexFlags); - - return filter.test(title); + return (temp + currentLine).trim(); } else { - return title.includes(filter); + if (maxLength && typeof maxLength === "number" && string.length > maxLength) { + return `${string.trim().slice(0, maxLength)}…`; + } else { + return string.trim(); + } } + }, + + /** + * Transforms the title of an event for usage. + * Replaces parts of the text as defined in config.titleReplace. + * Shortens title based on config.maxTitleLength and config.wrapEvents + * + * @param {string} title The title to transform. + * @param {object} titleReplace Pairs of strings to be replaced in the title + * @returns {string} The transformed title. + */ + titleTransform: function (title, titleReplace) { + for (let needle in titleReplace) { + const replacement = titleReplace[needle]; + + const regParts = needle.match(/^\/(.+)\/([gim]*)$/); + if (regParts) { + // the parsed pattern is a regexp. + needle = new RegExp(regParts[1], regParts[2]); + } + + title = title.replace(needle, replacement); + } + return title; } }; diff --git a/tests/e2e/modules/compliments_spec.js b/tests/e2e/modules/compliments_spec.js index 52d232e5..c4fcf686 100644 --- a/tests/e2e/modules/compliments_spec.js +++ b/tests/e2e/modules/compliments_spec.js @@ -25,7 +25,7 @@ describe("Compliments module", () => { await helpers.getDocument(); }); - it("Show anytime because if configure empty parts of day compliments and set anytime compliments", async () => { + it("shows anytime because if configure empty parts of day compliments and set anytime compliments", async () => { await doTest(["Anytime here"]); }); }); @@ -36,7 +36,7 @@ describe("Compliments module", () => { await helpers.getDocument(); }); - it("Show anytime compliments", async () => { + it("shows anytime compliments", async () => { await doTest(["Anytime here"]); }); }); diff --git a/tests/e2e/modules_empty_spec.js b/tests/e2e/modules_empty_spec.js index ddd08e82..dfe6c081 100644 --- a/tests/e2e/modules_empty_spec.js +++ b/tests/e2e/modules_empty_spec.js @@ -9,13 +9,13 @@ describe("Check configuration without modules", () => { await helpers.stopApplication(); }); - it("Show the message MagicMirror² title", async () => { + it("shows the message MagicMirror² title", async () => { const elem = await helpers.waitForElement("#module_1_helloworld .module-content"); expect(elem).not.toBe(null); expect(elem.textContent).toContain("MagicMirror²"); }); - it("Show the url of michael's website", async () => { + it("shows the url of michael's website", async () => { const elem = await helpers.waitForElement("#module_5_helloworld .module-content"); expect(elem).not.toBe(null); expect(elem.textContent).toContain("www.michaelteeuw.nl"); diff --git a/tests/electron/modules/compliments_spec.js b/tests/electron/modules/compliments_spec.js index 3afa83de..60a8b639 100644 --- a/tests/electron/modules/compliments_spec.js +++ b/tests/electron/modules/compliments_spec.js @@ -36,7 +36,7 @@ describe("Compliments module", () => { describe("Feature date in compliments module", () => { describe("Set date and empty compliments for anytime, morning, evening and afternoon", () => { - it("Show happy new year compliment on new years day", async () => { + it("shows happy new year compliment on new years day", async () => { await helpers.startApplication("tests/configs/modules/compliments/compliments_date.js", "01 Jan 2022 10:00:00 GMT"); await doTest(["Happy new year!"]); }); diff --git a/tests/unit/functions/calendar_spec.js b/tests/unit/modules/default/calendar/calendar_utils_spec.js similarity index 52% rename from tests/unit/functions/calendar_spec.js rename to tests/unit/modules/default/calendar/calendar_utils_spec.js index 34e16dd3..7cf896c1 100644 --- a/tests/unit/functions/calendar_spec.js +++ b/tests/unit/modules/default/calendar/calendar_utils_spec.js @@ -1,18 +1,8 @@ global.moment = require("moment"); -describe("Functions into modules/default/calendar/calendar.js", () => { - // Fake for use by calendar.js - Module = {}; - Module.definitions = {}; - Module.register = (name, moduleDefinition) => { - Module.definitions[name] = moduleDefinition; - }; - - beforeAll(() => { - // load calendar.js - require("../../../modules/default/calendar/calendar"); - }); +const CalendarUtils = require("../../../../../modules/default/calendar/calendarutils"); +describe("Calendar utils tests", () => { describe("capFirst", () => { const words = { rodrigo: "Rodrigo", @@ -24,68 +14,88 @@ describe("Functions into modules/default/calendar/calendar.js", () => { Object.keys(words).forEach((word) => { it(`for '${word}' should return '${words[word]}'`, () => { - expect(Module.definitions.calendar.capFirst(word)).toBe(words[word]); + expect(CalendarUtils.capFirst(word)).toBe(words[word]); }); }); + + it("should not capitalize other letters", () => { + expect(CalendarUtils.capFirst("event")).not.toBe("EVent"); + }); }); describe("getLocaleSpecification", () => { it("should return a valid moment.LocaleSpecification for a 12-hour format", () => { - expect(Module.definitions.calendar.getLocaleSpecification(12)).toEqual({ longDateFormat: { LT: "h:mm A" } }); + expect(CalendarUtils.getLocaleSpecification(12)).toEqual({ longDateFormat: { LT: "h:mm A" } }); }); it("should return a valid moment.LocaleSpecification for a 24-hour format", () => { - expect(Module.definitions.calendar.getLocaleSpecification(24)).toEqual({ longDateFormat: { LT: "HH:mm" } }); + expect(CalendarUtils.getLocaleSpecification(24)).toEqual({ longDateFormat: { LT: "HH:mm" } }); }); it("should return the current system locale when called without timeFormat number", () => { - expect(Module.definitions.calendar.getLocaleSpecification()).toEqual({ longDateFormat: { LT: moment.localeData().longDateFormat("LT") } }); + expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: moment.localeData().longDateFormat("LT") } }); }); it("should return a 12-hour longDateFormat when using the 'en' locale", () => { const localeBackup = moment.locale(); moment.locale("en"); - expect(Module.definitions.calendar.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "h:mm A" } }); + expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "h:mm A" } }); moment.locale(localeBackup); }); it("should return a 12-hour longDateFormat when using the 'au' locale", () => { const localeBackup = moment.locale(); moment.locale("au"); - expect(Module.definitions.calendar.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "h:mm A" } }); + expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "h:mm A" } }); moment.locale(localeBackup); }); it("should return a 12-hour longDateFormat when using the 'eg' locale", () => { const localeBackup = moment.locale(); moment.locale("eg"); - expect(Module.definitions.calendar.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "h:mm A" } }); + expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "h:mm A" } }); moment.locale(localeBackup); }); it("should return a 24-hour longDateFormat when using the 'nl' locale", () => { const localeBackup = moment.locale(); moment.locale("nl"); - expect(Module.definitions.calendar.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "HH:mm" } }); + expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "HH:mm" } }); moment.locale(localeBackup); }); it("should return a 24-hour longDateFormat when using the 'fr' locale", () => { const localeBackup = moment.locale(); moment.locale("fr"); - expect(Module.definitions.calendar.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "HH:mm" } }); + expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "HH:mm" } }); moment.locale(localeBackup); }); it("should return a 24-hour longDateFormat when using the 'uk' locale", () => { const localeBackup = moment.locale(); moment.locale("uk"); - expect(Module.definitions.calendar.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "HH:mm" } }); + expect(CalendarUtils.getLocaleSpecification()).toEqual({ longDateFormat: { LT: "HH:mm" } }); moment.locale(localeBackup); }); }); describe("shorten", () => { + it("should not shorten if short enough", () => { + expect(CalendarUtils.shorten("Event 1", 10, false, 1)).toBe("Event 1"); + }); + + it("should shorten into one line", () => { + expect(CalendarUtils.shorten("Example event at 12 o clock", 10, true, 1)).toBe("Example …"); + }); + + it("should shorten into three lines", () => { + expect(CalendarUtils.shorten("Example event at 12 o clock", 10, true, 3)).toBe("Example
event at 12 o
clock"); + }); + + it("should not shorten into three lines if wrap is false", () => { + expect(CalendarUtils.shorten("Example event at 12 o clock", 10, false, 3)).toBe("Example ev…"); + }); + const strings = { " String with whitespace at the beginning that needs trimming": { length: 16, return: "String with whit…" }, "long string that needs shortening": { length: 16, return: "long string that…" }, @@ -95,34 +105,44 @@ describe("Functions into modules/default/calendar/calendar.js", () => { Object.keys(strings).forEach((string) => { it(`for '${string}' should return '${strings[string].return}'`, () => { - expect(Module.definitions.calendar.shorten(string, strings[string].length)).toBe(strings[string].return); + expect(CalendarUtils.shorten(string, strings[string].length)).toBe(strings[string].return); }); }); it("should return an empty string if shorten is called with a non-string", () => { - expect(Module.definitions.calendar.shorten(100)).toBe(""); + expect(CalendarUtils.shorten(100)).toBe(""); }); it("should not shorten the string if shorten is called with a non-number maxLength", () => { - expect(Module.definitions.calendar.shorten("This is a test string", "This is not a number")).toBe("This is a test string"); + expect(CalendarUtils.shorten("This is a test string", "This is not a number")).toBe("This is a test string"); }); it("should wrap the string instead of shorten it if shorten is called with wrapEvents = true (with maxLength defined as 20)", () => { - expect(Module.definitions.calendar.shorten("This is a wrapEvent test. Should wrap the string instead of shorten it if called with wrapEvent = true", 20, true)).toBe( + expect(CalendarUtils.shorten("This is a wrapEvent test. Should wrap the string instead of shorten it if called with wrapEvent = true", 20, true)).toBe( "This is a
wrapEvent test. Should wrap
the string instead of
shorten it if called with
wrapEvent = true" ); }); it("should wrap the string instead of shorten it if shorten is called with wrapEvents = true (without maxLength defined, default 25)", () => { - expect(Module.definitions.calendar.shorten("This is a wrapEvent test. Should wrap the string instead of shorten it if called with wrapEvent = true", undefined, true)).toBe( + expect(CalendarUtils.shorten("This is a wrapEvent test. Should wrap the string instead of shorten it if called with wrapEvent = true", undefined, true)).toBe( "This is a wrapEvent
test. Should wrap the string
instead of shorten it if called
with wrapEvent = true" ); }); it("should wrap and shorten the string in the second line if called with wrapEvents = true and maxTitleLines = 2", () => { - expect(Module.definitions.calendar.shorten("This is a wrapEvent and maxTitleLines test. Should wrap and shorten the string in the second line if called with wrapEvents = true and maxTitleLines = 2", undefined, true, 2)).toBe( + expect(CalendarUtils.shorten("This is a wrapEvent and maxTitleLines test. Should wrap and shorten the string in the second line if called with wrapEvents = true and maxTitleLines = 2", undefined, true, 2)).toBe( "This is a wrapEvent and
maxTitleLines test. Should wrap and …" ); }); }); + + describe("titleTransform and shorten combined", () => { + it("should replace the birthday and wrap nicely", () => { + const transformedTitle = CalendarUtils.titleTransform("Michael Teeuw's birthday", { + "De verjaardag van ": "", + "'s birthday": "" + }); + expect(CalendarUtils.shorten(transformedTitle, 10, true, 2)).toBe("Michael
Teeuw"); + }); + }); }); diff --git a/tests/unit/functions/weather_object_spec.js b/tests/unit/modules/default/weather/weather_object_spec.js similarity index 94% rename from tests/unit/functions/weather_object_spec.js rename to tests/unit/modules/default/weather/weather_object_spec.js index 5f9444e4..f681b357 100644 --- a/tests/unit/functions/weather_object_spec.js +++ b/tests/unit/modules/default/weather/weather_object_spec.js @@ -1,5 +1,5 @@ -const WeatherObject = require("../../../modules/default/weather/weatherobject"); -const WeatherUtils = require("../../../modules/default/weather/weatherutils"); +const WeatherObject = require("../../../../../modules/default/weather/weatherobject"); +const WeatherUtils = require("../../../../../modules/default/weather/weatherutils"); global.moment = require("moment-timezone"); global.SunCalc = require("suncalc"); diff --git a/tests/unit/modules/default/weather/weather_utils_spec.js b/tests/unit/modules/default/weather/weather_utils_spec.js index a7c8d75c..482212e0 100644 --- a/tests/unit/modules/default/weather/weather_utils_spec.js +++ b/tests/unit/modules/default/weather/weather_utils_spec.js @@ -2,7 +2,7 @@ const weather = require("../../../../../modules/default/weather/weatherutils"); describe("Weather utils tests", () => { describe("convertPrecipitationUnit tests", () => { - it("Should keep value and unit if outputUnit is undefined", () => { + it("should keep value and unit if outputUnit is undefined", () => { const values = [1, 2]; const units = ["mm", "cm"]; @@ -12,7 +12,7 @@ describe("Weather utils tests", () => { } }); - it("Should keep value and unit if outputUnit is metric", () => { + it("should keep value and unit if outputUnit is metric", () => { const values = [1, 2]; const units = ["mm", "cm"]; @@ -22,7 +22,7 @@ describe("Weather utils tests", () => { } }); - it("Should use mm unit if input unit is undefined", () => { + it("should use mm unit if input unit is undefined", () => { const values = [1, 2]; for (let i = 0; i < values.length; i++) { @@ -31,7 +31,7 @@ describe("Weather utils tests", () => { } }); - it("Should convert value and unit if outputUnit is imperial", () => { + it("should convert value and unit if outputUnit is imperial", () => { const values = [1, 2]; const units = ["mm", "cm"]; const expectedValues = [0.04, 0.79];