diff --git a/CHANGELOG.md b/CHANGELOG.md index e7a75093..ac1ffbb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,10 @@ planned for 2025-07-01 - [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 ### Fixed diff --git a/cspell.config.json b/cspell.config.json index e681059f..54a78a93 100644 --- a/cspell.config.json +++ b/cspell.config.json @@ -52,6 +52,8 @@ "dkallen", "drivelist", "DTEND", + "DTSTAMP", + "DTSTART", "Duffman", "earlman", "easyas", @@ -107,6 +109,7 @@ "jsonlint", "jupadin", "kaennchenstruggle", + "Kalenderwoche", "kenzal", "Keyport", "khassel", diff --git a/modules/default/calendar/calendar.js b/modules/default/calendar/calendar.js index e2f921db..a7aad9b3 100644 --- a/modules/default/calendar/calendar.js +++ b/modules/default/calendar/calendar.js @@ -77,7 +77,7 @@ Module.register("calendar", { // Define required scripts. getScripts () { - return ["calendarutils.js", "moment.js"]; + return ["calendarutils.js", "moment.js", "moment-timezone.js"]; }, // Define required translations. @@ -215,18 +215,9 @@ Module.register("calendar", { this.updateDom(this.config.animationSpeed); }, - eventEndingWithinNextFullTimeUnit (event, ONE_DAY) { - const now = new Date(); - return event.endDate - now <= ONE_DAY; - }, - // Override dom generator. getDom () { const ONE_SECOND = 1000; // 1,000 milliseconds - const ONE_MINUTE = ONE_SECOND * 60; - const ONE_HOUR = ONE_MINUTE * 60; - const ONE_DAY = ONE_HOUR * 24; - const events = this.createEventList(true); const wrapper = document.createElement("table"); wrapper.className = this.config.tableClass; @@ -258,7 +249,9 @@ Module.register("calendar", { let lastSeenDate = ""; events.forEach((event, index) => { - const dateAsString = moment(event.startDate, "x").format(this.config.dateFormat); + const eventStartDateMoment = this.timestampToMoment(event.startDate); + const eventEndDateMoment = this.timestampToMoment(event.endDate); + const dateAsString = eventStartDateMoment.format(this.config.dateFormat); if (this.config.timeFormat === "dateheaders") { if (lastSeenDate !== dateAsString) { const dateRow = document.createElement("tr"); @@ -340,7 +333,7 @@ Module.register("calendar", { repeatingCountTitle = this.countTitleForUrl(event.url); if (repeatingCountTitle !== "") { - const thisYear = new Date(parseInt(event.startDate)).getFullYear(), + const thisYear = eventStartDateMoment.year(), yearDiff = thisYear - event.firstYear; repeatingCountTitle = `, ${yearDiff} ${repeatingCountTitle}`; @@ -395,14 +388,14 @@ Module.register("calendar", { timeWrapper.className = `time light ${this.config.flipDateHeaderTitle ? "align-right " : "align-left "}${this.timeClassForUrl(event.url)}`; timeWrapper.style.paddingLeft = "2px"; timeWrapper.style.textAlign = this.config.flipDateHeaderTitle ? "right" : "left"; - timeWrapper.innerHTML = moment(event.startDate, "x").format("LT"); + timeWrapper.innerHTML = eventStartDateMoment.format("LT"); // Add endDate to dataheaders if showEnd is enabled if (this.config.showEnd) { if (this.config.showEndsOnlyWithDuration && event.startDate === event.endDate) { // no duration here, don't display end } else { - timeWrapper.innerHTML += ` - ${CalendarUtils.capFirst(moment(event.endDate, "x").format("LT"))}`; + timeWrapper.innerHTML += ` - ${CalendarUtils.capFirst(eventEndDateMoment.format("LT"))}`; } } @@ -415,44 +408,43 @@ Module.register("calendar", { const timeWrapper = document.createElement("td"); eventWrapper.appendChild(titleWrapper); - const now = new Date(); + const now = moment(); if (this.config.timeFormat === "absolute") { // Use dateFormat - timeWrapper.innerHTML = CalendarUtils.capFirst(moment(event.startDate, "x").format(this.config.dateFormat)); + timeWrapper.innerHTML = CalendarUtils.capFirst(eventStartDateMoment.format(this.config.dateFormat)); // Add end time if showEnd if (this.config.showEnd) { // and has a duation if (event.startDate !== event.endDate) { timeWrapper.innerHTML += "-"; - timeWrapper.innerHTML += CalendarUtils.capFirst(moment(event.endDate, "x").format(this.config.dateEndFormat)); + timeWrapper.innerHTML += CalendarUtils.capFirst(eventEndDateMoment.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 = CalendarUtils.capFirst(moment(event.startDate, "x").format(this.config.fullDayEventDateFormat)); + eventEndDateMoment.subtract(1, "second"); + timeWrapper.innerHTML = CalendarUtils.capFirst(eventStartDateMoment.format(this.config.fullDayEventDateFormat)); // only show end if requested and allowed and the dates are different - if (this.config.showEnd && !this.config.showEndsOnlyWithDuration && moment(event.startDate, "x").format("YYYYMMDD") !== moment(event.endDate, "x").format("YYYYMMDD")) { + if (this.config.showEnd && !this.config.showEndsOnlyWithDuration && !eventStartDateMoment.isSame(eventEndDateMoment, "d")) { timeWrapper.innerHTML += "-"; - timeWrapper.innerHTML += CalendarUtils.capFirst(moment(event.endDate, "x").format(this.config.fullDayEventDateFormat)); - } else - if ((moment(event.startDate, "x").format("YYYYMMDD") !== moment(event.endDate, "x").format("YYYYMMDD")) && (moment(event.startDate, "x") < moment(now, "x"))) { - timeWrapper.innerHTML = CalendarUtils.capFirst(moment(now, "x").format(this.config.fullDayEventDateFormat)); - } - } else if (this.config.getRelative > 0 && event.startDate < now) { + timeWrapper.innerHTML += CalendarUtils.capFirst(eventEndDateMoment.format(this.config.fullDayEventDateFormat)); + } else if (!eventStartDateMoment.isSame(eventEndDateMoment, "d") && eventStartDateMoment.isBefore(now)) { + timeWrapper.innerHTML = CalendarUtils.capFirst(now.format(this.config.fullDayEventDateFormat)); + } + } else if (this.config.getRelative > 0 && eventStartDateMoment.isBefore(now)) { // Ongoing and getRelative is set timeWrapper.innerHTML = CalendarUtils.capFirst( this.translate("RUNNING", { fallback: `${this.translate("RUNNING")} {timeUntilEnd}`, - timeUntilEnd: moment(event.endDate, "x").fromNow(true) + timeUntilEnd: eventEndDateMoment.fromNow(true) }) ); - } else if (this.config.urgency > 0 && event.startDate - now < this.config.urgency * ONE_DAY) { + } else if (this.config.urgency > 0 && eventStartDateMoment.diff(now, "d") < this.config.urgency) { // Within urgency days - timeWrapper.innerHTML = CalendarUtils.capFirst(moment(event.startDate, "x").fromNow()); + timeWrapper.innerHTML = CalendarUtils.capFirst(eventStartDateMoment.fromNow()); } if (event.fullDayEvent && this.config.nextDaysRelative) { // Full days events within the next two days @@ -460,9 +452,9 @@ Module.register("calendar", { timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TODAY")); } else if (event.yesterday) { timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("YESTERDAY")); - } else if (event.startDate - now < ONE_DAY && event.startDate - now > 0) { + } else if (event.tomorrow) { timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TOMORROW")); - } else if (event.startDate - now < 2 * ONE_DAY && event.startDate - now > 0) { + } else if (event.dayAfterTomorrow) { if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") { timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW")); } @@ -470,15 +462,15 @@ Module.register("calendar", { } } else { // Show relative times - if (event.startDate >= now || (event.fullDayEvent && this.eventEndingWithinNextFullTimeUnit(event, ONE_DAY))) { + if (eventStartDateMoment.isSameOrAfter(now) || (event.fullDayEvent && eventEndDateMoment.diff(now, "days") === 0)) { // Use relative time if (!this.config.hideTime && !event.fullDayEvent) { Log.debug("event not hidden and not fullday"); - timeWrapper.innerHTML = `${CalendarUtils.capFirst(moment(event.startDate, "x").calendar(null, { sameElse: this.config.dateFormat }))}`; + timeWrapper.innerHTML = `${CalendarUtils.capFirst(eventStartDateMoment.calendar(null, { sameElse: this.config.dateFormat }))}`; } else { Log.debug("event full day or hidden"); timeWrapper.innerHTML = `${CalendarUtils.capFirst( - moment(event.startDate, "x").calendar(null, { + eventStartDateMoment.calendar(null, { sameDay: this.config.showTimeToday ? "LT" : `[${this.translate("TODAY")}]`, nextDay: `[${this.translate("TOMORROW")}]`, nextWeek: "dddd", @@ -488,7 +480,7 @@ Module.register("calendar", { } if (event.fullDayEvent) { // Full days events within the next two days - if (event.today || (event.fullDayEvent && this.eventEndingWithinNextFullTimeUnit(event, ONE_DAY))) { + if (event.today || (event.fullDayEvent && eventEndDateMoment.diff(now, "days") === 0)) { timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TODAY")); } else if (event.dayBeforeYesterday) { if (this.translate("DAYBEFOREYESTERDAY") !== "DAYBEFOREYESTERDAY") { @@ -496,25 +488,25 @@ Module.register("calendar", { } } else if (event.yesterday) { timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("YESTERDAY")); - } else if (event.startDate - now < ONE_DAY && event.startDate - now > 0) { + } else if (event.tomorrow) { timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TOMORROW")); - } else if (event.startDate - now < 2 * ONE_DAY && event.startDate - now > 0) { + } else if (event.dayAfterTomorrow) { if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") { timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW")); } } Log.info("event fullday"); - } else if (event.startDate - now < this.config.getRelative * ONE_HOUR) { + } else if (eventStartDateMoment.diff(now, "h") < this.config.getRelative) { Log.info("not full day but within getrelative size"); // If event is within getRelative hours, display 'in xxx' time format or moment.fromNow() - timeWrapper.innerHTML = `${CalendarUtils.capFirst(moment(event.startDate, "x").fromNow())}`; + timeWrapper.innerHTML = `${CalendarUtils.capFirst(eventStartDateMoment.fromNow())}`; } } else { // Ongoing event timeWrapper.innerHTML = CalendarUtils.capFirst( this.translate("RUNNING", { fallback: `${this.translate("RUNNING")} {timeUntilEnd}`, - timeUntilEnd: moment(event.endDate, "x").fromNow(true) + timeUntilEnd: eventEndDateMoment.fromNow(true) }) ); } @@ -593,46 +585,46 @@ Module.register("calendar", { return false; }, + /** + * converts the given timestamp to a moment with a timezone + * @param {number} timestamp timestamp from an event + * @returns {moment.Moment} moment with a timezone + */ + timestampToMoment (timestamp) { + return moment(timestamp, "x").tz(moment.tz.guess()); + }, + /** * Creates the sorted list of all events. * @param {boolean} limitNumberOfEntries Whether to filter returned events for display. * @returns {object[]} Array with events. */ createEventList (limitNumberOfEntries) { - const ONE_SECOND = 1000; // 1,000 milliseconds - const ONE_MINUTE = ONE_SECOND * 60; - const ONE_HOUR = ONE_MINUTE * 60; - const ONE_DAY = ONE_HOUR * 24; + let now = moment(); + let today = now.clone().startOf("day"); + let future = now.clone().startOf("day").add(this.config.maximumNumberOfDays, "days"); - let now, today, future; - if (this.config.forceUseCurrentTime || this.defaults.forceUseCurrentTime) { - now = new Date(); - today = moment().startOf("day"); - future = moment().startOf("day").add(this.config.maximumNumberOfDays, "days").toDate(); - } else { - now = new Date(Date.now()); // Can use overridden time - today = moment(now).startOf("day"); - future = moment(now).startOf("day").add(this.config.maximumNumberOfDays, "days").toDate(); - } let events = []; for (const calendarUrl in this.calendarData) { const calendar = this.calendarData[calendarUrl]; let remainingEntries = this.maximumEntriesForUrl(calendarUrl); - let maxPastDaysCompare = now - this.maximumPastDaysForUrl(calendarUrl) * ONE_DAY; + let maxPastDaysCompare = now.clone().subtract(this.maximumPastDaysForUrl(calendarUrl), "days"); let by_url_calevents = []; for (const e in calendar) { const event = JSON.parse(JSON.stringify(calendar[e])); // clone object + const eventStartDateMoment = this.timestampToMoment(event.startDate); + const eventEndDateMoment = this.timestampToMoment(event.endDate); if (this.config.hidePrivate && event.class === "PRIVATE") { // do not add the current event, skip it continue; } if (limitNumberOfEntries) { - if (event.endDate < maxPastDaysCompare) { + if (eventEndDateMoment.isBefore(maxPastDaysCompare)) { continue; } - if (this.config.hideOngoing && event.startDate < now) { + if (this.config.hideOngoing && eventStartDateMoment.isBefore(now)) { continue; } if (this.config.hideDuplicates && this.listContainsEvent(events, event)) { @@ -641,47 +633,46 @@ Module.register("calendar", { } event.url = calendarUrl; - event.today = event.startDate >= today && event.startDate < today + ONE_DAY; - event.dayBeforeYesterday = event.startDate >= today - ONE_DAY * 2 && event.startDate < today - ONE_DAY; - event.yesterday = event.startDate >= today - ONE_DAY && event.startDate < today; - event.tomorrow = !event.today && event.startDate >= today + ONE_DAY && event.startDate < today + 2 * ONE_DAY; - event.dayAfterTomorrow = !event.tomorrow && event.startDate >= today + ONE_DAY * 2 && event.startDate < today + 3 * ONE_DAY; + event.today = eventStartDateMoment.isSame(now, "d"); + event.dayBeforeYesterday = eventStartDateMoment.isSame(now.clone().subtract(2, "days"), "d"); + event.yesterday = eventStartDateMoment.isSame(now.clone().subtract(1, "days"), "d"); + event.tomorrow = eventStartDateMoment.isSame(now.clone().add(1, "days"), "d"); + event.dayAfterTomorrow = eventStartDateMoment.isSame(now.clone().add(2, "days"), "d"); /* * if sliceMultiDayEvents is set to true, multiday events (events exceeding at least one midnight) are sliced into days, * otherwise, esp. in dateheaders mode it is not clear how long these events are. */ - const maxCount = Math.round((event.endDate - 1 - moment(event.startDate, "x").endOf("day").format("x")) / ONE_DAY) + 1; + const maxCount = eventEndDateMoment.diff(eventStartDateMoment, "days"); if (this.config.sliceMultiDayEvents && maxCount > 1) { const splitEvents = []; let midnight - = moment(event.startDate, "x") + = eventStartDateMoment .clone() .startOf("day") .add(1, "day") - .endOf("day") - .format("x"); + .endOf("day"); let count = 1; - while (event.endDate > midnight) { + while (eventEndDateMoment.isAfter(midnight)) { const thisEvent = JSON.parse(JSON.stringify(event)); // clone object - thisEvent.today = thisEvent.startDate >= today && thisEvent.startDate < today + ONE_DAY; - thisEvent.tomorrow = !thisEvent.today && thisEvent.startDate >= today + ONE_DAY && thisEvent.startDate < today + 2 * ONE_DAY; - thisEvent.endDate = moment(midnight, "x").clone().subtract(1, "day").format("x"); + thisEvent.today = this.timestampToMoment(thisEvent.startDate).isSame(now, "d"); + thisEvent.tomorrow = this.timestampToMoment(thisEvent.startDate).isSame(now.clone().add(1, "days"), "d"); + thisEvent.endDate = midnight.clone().subtract(1, "day").format("x"); thisEvent.title += ` (${count}/${maxCount})`; splitEvents.push(thisEvent); - event.startDate = midnight; + event.startDate = midnight.format("x"); count += 1; - midnight = moment(midnight, "x").add(1, "day").endOf("day").format("x"); // next day + midnight = midnight.clone().add(1, "day").endOf("day"); // next day } // Last day event.title += ` (${count}/${maxCount})`; - event.today += event.startDate >= today && event.startDate < today + ONE_DAY; - event.tomorrow = !event.today && event.startDate >= today + ONE_DAY && event.startDate < today + 2 * ONE_DAY; + event.today += this.timestampToMoment(event.startDate).isSame(now, "d"); + event.tomorrow = this.timestampToMoment(event.startDate).isSame(now.clone().add(1, "days"), "d"); splitEvents.push(event); for (let splitEvent of splitEvents) { - if (splitEvent.endDate > now && splitEvent.endDate <= future) { + if (this.timestampToMoment(splitEvent.endDate).isAfter(now) && this.timestampToMoment(splitEvent.endDate).isSameOrBefore(future)) { by_url_calevents.push(splitEvent); } } @@ -716,16 +707,16 @@ Module.register("calendar", { */ if (this.config.limitDays > 0) { let newEvents = []; - let lastDate = today.clone().subtract(1, "days").format("YYYYMMDD"); + let lastDate = today.clone().subtract(1, "days"); let days = 0; for (const ev of events) { - let eventDate = moment(ev.startDate, "x").format("YYYYMMDD"); + let eventDate = this.timestampToMoment(ev.startDate); /* * if date of event is later than lastdate * check if we already are showing max unique days */ - if (eventDate > lastDate) { + if (eventDate.isAfter(lastDate)) { // if the only entry in the first day is a full day event that day is not counted as unique if (!this.config.limitDaysNeverSkip && newEvents.length === 1 && days === 1 && newEvents[0].fullDayEvent) { days--; diff --git a/modules/default/calendar/calendarfetcherutils.js b/modules/default/calendar/calendarfetcherutils.js index 3b037f13..880e68b5 100644 --- a/modules/default/calendar/calendarfetcherutils.js +++ b/modules/default/calendar/calendarfetcherutils.js @@ -1,114 +1,130 @@ /** * @external Moment */ -const path = require("node:path"); -const moment = require("moment"); +const moment = require("moment-timezone"); -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 + * 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. */ - calculateTimezoneAdjustment (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}`); + 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 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) + if (filter instanceof Object) { + if (typeof filter.until !== "undefined") { + until = filter.until; } - } - 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 = 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}`); + 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 { - // 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(); + filter = filter.toLowerCase(); } - 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"); + if (CalendarFetcherUtils.titleFilterApplies(testTitle, filter, useRegex, regexFlags)) { + if (until) { + filter.until = until; + } else { + filter.excluded = true; } + break; } } - Log.debug(`adjustHours=${adjustHours}`); - return adjustHours; + 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))); }, /** @@ -120,34 +136,33 @@ const CalendarFetcherUtils = { filterEvents (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]).startOf("day") : moment(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 = new Date(Date.now()); - const todayLocal = moment(now).startOf("day").toDate(); - const futureLocalDate - = moment(now) + 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, "seconds") // Subtract 1 second so that events that start on the middle of the night will not repeat. - .toDate(); + // 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..."); - let pastLocalDate = todayLocal; - if (config.includePastEvents) { - pastLocalDate = moment(now).startOf("day").subtract(config.maximumNumberOfDays, "days").toDate(); + 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. @@ -161,218 +176,47 @@ const CalendarFetcherUtils = { if (event.type === "VEVENT") { Log.debug(`Event:\n${JSON.stringify(event, null, 2)}`); - let startMoment = eventDate(event, "start"); - let endMoment; + let eventStartMoment = eventDate(event, "start"); + let eventEndMoment; if (typeof event.end !== "undefined") { - endMoment = eventDate(event, "end"); + eventEndMoment = eventDate(event, "end"); } else if (typeof event.duration !== "undefined") { - endMoment = startMoment.clone().add(moment.duration(event.duration)); + eventEndMoment = eventStartMoment.clone().add(moment.duration(event.duration)); } else { if (!isFacebookBirthday) { // make copy of start date, separate storage area - endMoment = moment(startMoment.valueOf()); + eventEndMoment = eventStartMoment.clone(); } else { - endMoment = moment(startMoment).add(1, "days"); + eventEndMoment = eventStartMoment.clone().add(1, "days"); } } - Log.debug(`start: ${startMoment.toDate()}`); - Log.debug(`end:: ${endMoment.toDate()}`); + Log.debug(`start: ${eventStartMoment.toDate()}`); + Log.debug(`end:: ${eventEndMoment.toDate()}`); // Calculate the duration of the event for use with recurring events. - const durationMs = endMoment.valueOf() - startMoment.valueOf(); + const durationMs = eventEndMoment.valueOf() - eventStartMoment.valueOf(); Log.debug(`duration: ${durationMs}`); - // FIXME: Since the parsed json object from node-ical comes with time information - // this check could be removed (?) - if (event.start.length === 8) { - startMoment = startMoment.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; - let d1; - let d2; + // TODO This should be a seperate function. if (event.rrule && typeof event.rrule !== "undefined" && !isFacebookBirthday) { - const rule = event.rrule; + // Recurring event. + let moments = CalendarFetcherUtils.getMomentsFromRecurringEvent(event, pastLocalMoment, futureLocalMoment, durationMs); - const pastMoment = moment(pastLocalDate); - const futureMoment = moment(futureLocalDate); - - // 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. - - let pastLocal; - let futureLocal; - - 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(now).toDate(); //now - } - futureLocal = futureMoment.toDate(); // future - } - const oneDayInMs = 24 * 60 * 60 * 1000; - d1 = new Date(new Date(pastLocal.valueOf() - oneDayInMs).getTime()); - d2 = new Date(new Date(futureLocal.valueOf() + oneDayInMs).getTime()); - Log.debug(`Search for recurring events between: ${d1} and ${d2}`); - - event.start = rule.options.dtstart; - - // 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 = new Date(new Date(moment(rule.options.until).startOf("day").add(1, "day")).getTime()); - } - - Log.debug("fix rrule start=", rule.options.dtstart); - Log.debug("event before rrule.between=", JSON.stringify(event, null, 2), "exdates=", event.exdate); - // fixup the exdate and recurrence date to local time too for post between() handling - CalendarFetcherUtils.fixEventtoLocal(event); - - Log.debug(`RRule: ${rule.toString()}`); - rule.options.tzid = null; // RRule gets *very* confused with timezones - - let dates = rule.between(d1, d2, 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) => { - if (JSON.stringify(d) === "null") return false; - else return true; - }); - - // go thru all the rrule.between() dates and put back the tz offset removed so rrule.between would work - let datesLocal = []; - let offset = d1.getTimezoneOffset(); - Log.debug("offset =", offset); - dates.forEach((d) => { - let dtext = d.toISOString().slice(0, -5); - Log.debug(" date text form without tz=", dtext); - let dLocal = new Date(d.valueOf() + (offset * 60000)); - let offset2 = dLocal.getTimezoneOffset(); - Log.debug("date after offset applied=", dLocal); - if (offset !== offset2) { - // woops, dst/std switch - let delta = offset - offset2; - Log.debug("offset delta=", delta); - dLocal = new Date(d.valueOf() + ((offset - delta) * 60000)); - Log.debug("corrected normalized date=", dLocal); - } else Log.debug(" neutralized date=", dLocal); - datesLocal.push(dLocal); - }); - dates = datesLocal; - - - // 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. - // - // i don't think we will ever see this anymore (oct 2024) due to code fixes for rrule.between() - // - Log.debug("event.recurrences:", event.recurrences); - if (event.recurrences !== undefined) { - for (let dateKey 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. - let d = new Date(dateKey); - if (!moment(d).isBetween(d1, d2)) { - Log.debug("adding recurring event not found in between list =", d, " should not happen now using local dates oct 17,24"); - dates.push(d); - } - } - } - - // 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]; + // 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 curDurationMs = durationMs; let showRecurrence = true; + let recurringEventStartMoment = moments[m].tz(CalendarFetcherUtils.getLocalTimezone()).clone(); + let recurringEventEndMoment = recurringEventStartMoment.clone().add(durationMs, "ms"); - let startMoment = moment(date); - - let dateKey = CalendarFetcherUtils.getDateKeyFromDate(date); + 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. @@ -382,12 +226,17 @@ const CalendarFetcherUtils = { 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]; - curEvent.start = new Date(new Date(curEvent.start.valueOf()).getTime()); - curEvent.end = new Date(new Date(curEvent.end.valueOf()).getTime()); - startMoment = CalendarFetcherUtils.getAdjustedStartMoment(curEvent.start, event); - endMoment = CalendarFetcherUtils.getAdjustedStartMoment(curEvent.end, event); - date = curEvent.start; - curDurationMs = new Date(endMoment).valueOf() - startMoment.valueOf(); + // 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"); } @@ -400,25 +249,20 @@ const CalendarFetcherUtils = { showRecurrence = false; } } - Log.debug(`duration: ${curDurationMs}`); - startMoment = CalendarFetcherUtils.getAdjustedStartMoment(date, event); - - endMoment = moment(startMoment.valueOf() + curDurationMs); - - if (startMoment.valueOf() === endMoment.valueOf()) { - endMoment = endMoment.endOf("day"); + 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 (endMoment.isBefore(pastLocal) || startMoment.isAfter(futureLocal)) { + if (recurringEventEndMoment.isBefore(pastLocalMoment) || recurringEventStartMoment.isAfter(futureLocalMoment)) { showRecurrence = false; } - if (CalendarFetcherUtils.timeFilterApplies(now, endMoment, dateFilter)) { + if (CalendarFetcherUtils.timeFilterApplies(now, recurringEventEndMoment, eventFilterUntil)) { showRecurrence = false; } @@ -426,8 +270,8 @@ const CalendarFetcherUtils = { Log.debug(`saving event: ${recurrenceTitle}`); newEvents.push({ title: recurrenceTitle, - startDate: startMoment.format("x"), - endDate: endMoment.format("x"), + startDate: recurringEventStartMoment.format("x"), + endDate: recurringEventEndMoment.format("x"), fullDayEvent: CalendarFetcherUtils.isFullDayEvent(event), recurringEvent: true, class: event.class, @@ -437,7 +281,7 @@ const CalendarFetcherUtils = { description: description }); } else { - Log.debug("not saving event ", recurrenceTitle, new Date(startMoment)); + Log.debug("not saving event ", recurrenceTitle, eventStartMoment); } Log.debug(" "); } @@ -448,47 +292,41 @@ const CalendarFetcherUtils = { // 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 && startMoment.valueOf() === endMoment.valueOf()) { - endMoment = endMoment.endOf("day"); + if (fullDayEvent && eventStartMoment.valueOf() === eventEndMoment.valueOf()) { + eventEndMoment = eventEndMoment.endOf("day"); } if (config.includePastEvents) { // Past event is too far in the past, so skip. - if (endMoment < pastLocalDate) { + if (eventEndMoment < pastLocalMoment) { return; } } else { // It's not a fullday event, and it is in the past, so skip. - if (!fullDayEvent && endMoment < now) { + if (!fullDayEvent && eventEndMoment < now) { return; } // It's a fullday event, and it is before today, So skip. - if (fullDayEvent && endMoment <= todayLocal) { + if (fullDayEvent && eventEndMoment <= now.startOf("day")) { return; } } // It exceeds the maximumNumberOfDays limit, so skip. - if (startMoment > futureLocalDate) { + if (eventStartMoment > futureLocalMoment) { return; } - if (CalendarFetcherUtils.timeFilterApplies(now, endMoment, dateFilter)) { + if (CalendarFetcherUtils.timeFilterApplies(now, eventEndMoment, eventFilterUntil)) { return; } - // get correction for date saving and dst change between now and then - let adjustHours = CalendarFetcherUtils.calculateTimezoneAdjustment(event, startMoment.toDate()); - // This shouldn't happen - if (adjustHours) { - Log.warn(`Unexpected timezone adjustment of ${adjustHours} hours on non-recurring event`); - } // Every thing is good. Add it to the list. newEvents.push({ title: title, - startDate: startMoment.add(adjustHours, "hours").format("x"), - endDate: endMoment.add(adjustHours, "hours").format("x"), + startDate: eventStartMoment.format("x"), + endDate: eventEndMoment.format("x"), fullDayEvent: fullDayEvent, recurringEvent: false, class: event.class, @@ -508,213 +346,6 @@ const CalendarFetcherUtils = { return newEvents; }, - /** - * Fixes the event fields that have dates to use local time - * before calling rrule.between. - * @param {object} event - The event being processed. - * @returns {void} - */ - fixEventtoLocal (event) { - // if there are excluded dates, their date is incorrect and possibly key as well. - if (event.exdate !== undefined) { - Object.keys(event.exdate).forEach((dateKey) => { - // get the date - let exdate = event.exdate[dateKey]; - Log.debug("exdate w key=", exdate); - //exdate=CalendarFetcherUtils.convertDateToLocalTime(exdate, event.end.tz) - exdate = new Date(new Date(exdate.valueOf() - ((120 * 60 * 1000))).getTime()); - Log.debug("new exDate item=", exdate, " with old key=", dateKey); - let newkey = exdate.toISOString().slice(0, 10); - if (newkey !== dateKey) { - Log.debug("new exDate item=", exdate, ` key=${newkey}`); - event.exdate[newkey] = exdate; - //delete event.exdate[dateKey] - } - }); - Log.debug("updated exdate list=", event.exdate); - } - if (event.recurrences) { - Object.keys(event.recurrences).forEach((dateKey) => { - let exdate = event.recurrences[dateKey]; - //exdate=new Date(new Date(exdate.valueOf()-(60*60*1000)).getTime()) - Log.debug("new recurrence item=", exdate, " with old key=", dateKey); - exdate.start = CalendarFetcherUtils.convertDateToLocalTime(exdate.start, exdate.start.tz); - exdate.end = CalendarFetcherUtils.convertDateToLocalTime(exdate.end, exdate.end.tz); - Log.debug("adjusted recurringEvent start=", exdate.start, " end=", exdate.end); - }); - } - Log.debug("modified recurrences before rrule.between", event.recurrences); - }, - - /** - * convert a UTC date to local time - * BEFORE calling rrule.between - * @param {Date} date The date to convert - * @param {string} tz The timezone string to convert the date to. - * @returns {Date} updated date object - */ - convertDateToLocalTime (date, tz) { - let delta_tz_offset = 0; - let now_offset = CalendarFetcherUtils.getTimezoneOffsetFromTimezone(moment.tz.guess()); - let event_offset = CalendarFetcherUtils.getTimezoneOffsetFromTimezone(tz); - Log.debug("date to convert=", date); - if (Math.sign(now_offset) !== Math.sign(event_offset)) { - delta_tz_offset = Math.abs(now_offset) + Math.abs(event_offset); - } else { - // signs are the same - // if negative - if (Math.sign(now_offset) === -1) { - // la looking at chicago - if (now_offset < event_offset) { // 5 -7 - delta_tz_offset = now_offset - event_offset; - } - else { //7 -5 , chicago looking at LA - delta_tz_offset = event_offset - now_offset; - } - } - else { - // berlin looking at sydney - if (now_offset < event_offset) { // 5 -7 - delta_tz_offset = event_offset - now_offset; - Log.debug("less delta=", delta_tz_offset); - } - else { // 11 - 2, sydney looking at berlin - delta_tz_offset = -(now_offset - event_offset); - Log.debug("more delta=", delta_tz_offset); - } - } - } - const newdate = new Date(new Date(date.valueOf() + (delta_tz_offset * 60 * 1000)).getTime()); - Log.debug("modified date =", newdate); - return newdate; - }, - - /** - * get the exdate/recurrence hash key from the date object - * BEFORE calling rrule.between - * @param {Date} date The date of the event - * @returns {string} date key in the format YYYY-MM-DD - */ - getDateKeyFromDate (date) { - // get our runtime timezone offset - const nowDiff = CalendarFetcherUtils.getTimezoneOffsetFromTimezone(moment.tz.guess()); - let startday = date.getDate(); - let adjustment = 0; - Log.debug(" day of month=", (`0${startday}`).slice(-2), " nowDiff=", nowDiff, ` start time=${date.toString().split(" ")[4].slice(0, 2)}`); - Log.debug("date string= ", date.toString()); - Log.debug("date iso string ", date.toISOString()); - // if the dates are different - if (date.toString().slice(8, 10) < date.toISOString().slice(8, 10)) { - startday = date.toString().slice(8, 10); - Log.debug("< ", startday); - } else { // tostring is more - if (date.toString().slice(8, 10) > date.toISOString().slice(8, 10)) { - startday = date.toISOString().slice(8, 10); - Log.debug("> ", startday); - } - } - return date.toISOString().substring(0, 8) + (`0${startday}`).slice(-2); - }, - - /** - * get the timezone offset from the timezone string - * @param {string} timeZone The timezone string - * @returns {number} The numerical offset in minutes from UTC. - */ - getTimezoneOffsetFromTimezone (timeZone) { - const str = new Date().toLocaleString("en", { timeZone, timeZoneName: "longOffset" }); - Log.debug("tz offset=", str); - const [_, h, m] = str.match(/([+-]\d+):(\d+)$/) || ["", "+00", "00"]; - return h * 60 + (h > 0 ? +m : -m); - }, - - /** - * fixup the date start moment after rrule.between returns date array - * @param {Date} date object from rrule.between results - * the event object it came from - * @param {object} event - The event object it came from. - * @returns {Moment} moment object - */ - getAdjustedStartMoment (date, event) { - - let startMoment = moment(date); - - Log.debug("startMoment pre=", startMoment); - // get our runtime timezone offset - const nowDiff = CalendarFetcherUtils.getTimezoneOffsetFromTimezone(moment.tz.guess()); // 10/18 16:49, 300 - let eventDiff = CalendarFetcherUtils.getTimezoneOffsetFromTimezone(event.end.tz); // watch out, start tz is cleared to handle rrule 120 23:49 - - Log.debug("tz diff event=", eventDiff, " local=", nowDiff, " end event timezone=", event.end.tz); - - // if the diffs are different (not same tz for processing as event) - if (nowDiff !== eventDiff) { - // if signs are different - if (Math.sign(nowDiff) !== Math.sign(eventDiff)) { - // its the accumulated total - Log.debug("diff signs, accumulate"); - eventDiff = Math.abs(eventDiff) + Math.abs(nowDiff); - // sign of diff depends on where you are looking at which event. - // australia looking at US, add to get same time - Log.debug("new different event diff=", eventDiff); - if (Math.sign(nowDiff) === -1) { - eventDiff *= -1; - // US looking at australia event have to subtract - Log.debug("new diff, same sign, total event diff=", eventDiff); - } - } - else { - // signs are the same, all east of UTC or all west of UTC - // if the signs are negative (west of UTC) - Log.debug("signs are the same"); - if (Math.sign(eventDiff) === -1) { - //if west, looking at more west - // -350 <-300 - if (nowDiff < eventDiff) { - //-600 -420 - //300 -300 -360 +300 - eventDiff = nowDiff - eventDiff; //-180 - Log.debug("now looking back east delta diff=", eventDiff); - } - else { - Log.debug("now looking more west"); - eventDiff = Math.abs(eventDiff - nowDiff); - } - } else { - Log.debug("signs are both positive"); - // signs are positive (east of UTC) - // berlin < sydney - if (nowDiff < eventDiff) { - // germany vs australia - eventDiff = -(eventDiff - nowDiff); - } - else { - // australia vs germany - //eventDiff = eventDiff; //- nowDiff - } - } - } - startMoment = moment.tz(new Date(date.valueOf() + (eventDiff * (60 * 1000))), event.end.tz); - } else { - Log.debug("same tz event and display"); - eventDiff = 0; - startMoment = moment.tz(new Date(date.valueOf() - (eventDiff * (60 * 1000))), event.end.tz); - } - Log.debug("startMoment post=", startMoment); - return startMoment; - }, - - /** - * 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 (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. @@ -754,8 +385,8 @@ const CalendarFetcherUtils = { /** * 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 {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 */ @@ -766,7 +397,7 @@ const CalendarFetcherUtils = { 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.toDate(); + return now < filterUntil; } return false; diff --git a/tests/electron/modules/calendar_spec.js b/tests/electron/modules/calendar_spec.js index b819d2dc..2a9845ae 100644 --- a/tests/electron/modules/calendar_spec.js +++ b/tests/electron/modules/calendar_spec.js @@ -22,6 +22,19 @@ describe("Calendar module", () => { return await loc.count(); }; + /** + * Use this for debugging broken tests, it will console log the text of the calendar module + * @returns {Promise} + */ + const logAllText = async () => { + expect(global.page).not.toBeNull(); + const loc = await global.page.locator(".calendar .event"); + const elem = loc.first(); + await elem.waitFor(); + expect(elem).not.toBeNull(); + console.log(await loc.allInnerTexts()); + }; + const first = 0; const second = 1; const third = 2; @@ -153,19 +166,6 @@ describe("Calendar module", () => { * RRULE TESTS: * Add any tests that check rrule functionality here. */ - describe("sliceMultiDayEvents", () => { - it("Issue #3452 split multiday in Europe", async () => { - await helpers.startApplication("tests/configs/modules/calendar/sliceMultiDayEvents.js", "01 Sept 2024 10:38:00 GMT+02:00", [], "Europe/Berlin"); - expect(global.page).not.toBeNull(); - const loc = await global.page.locator(".calendar .event"); - const elem = loc.first(); - await elem.waitFor(); - expect(elem).not.toBeNull(); - const cnt = await loc.count(); - expect(cnt).toBe(6); - }); - }); - describe("sliceMultiDayEvents direct count", () => { it("Issue #3452 split multiday in Europe", async () => { await helpers.startApplication("tests/configs/modules/calendar/sliceMultiDayEvents.js", "01 Sept 2024 10:38:00 GMT+02:00", [], "Europe/Berlin"); @@ -197,21 +197,30 @@ describe("Calendar module", () => { describe("berlin late in day event moved, viewed from berlin", () => { it("Issue #unknown rrule ETC+2 close to timezone edge", async () => { await helpers.startApplication("tests/configs/modules/calendar/end_of_day_berlin_moved.js", "08 Oct 2024 12:30:00 GMT+02:00", [], "Europe/Berlin"); - await expect(doTestTableContent(".calendar .event", ".time", "24th.Oct, 23:00-00:00", last)).resolves.toBe(true); + await expect(doTestCount()).resolves.toBe(3); + await expect(doTestTableContent(".calendar .event", ".time", "22nd.Oct, 23:00-00:00", first)).resolves.toBe(true); + await expect(doTestTableContent(".calendar .event", ".time", "23rd.Oct, 23:00-00:00", second)).resolves.toBe(true); + await expect(doTestTableContent(".calendar .event", ".time", "24th.Oct, 23:00-00:00", third)).resolves.toBe(true); }); }); describe("berlin late in day event moved, viewed from sydney", () => { it("Issue #unknown rrule ETC+2 close to timezone edge", async () => { await helpers.startApplication("tests/configs/modules/calendar/end_of_day_berlin_moved.js", "08 Oct 2024 12:30:00 GMT+02:00", [], "Australia/Sydney"); - await expect(doTestTableContent(".calendar .event", ".time", "25th.Oct, 01:00-02:00", last)).resolves.toBe(true); + await expect(doTestCount()).resolves.toBe(3); + await expect(doTestTableContent(".calendar .event", ".time", "23rd.Oct, 08:00-09:00", first)).resolves.toBe(true); + await expect(doTestTableContent(".calendar .event", ".time", "24th.Oct, 08:00-09:00", second)).resolves.toBe(true); + await expect(doTestTableContent(".calendar .event", ".time", "25th.Oct, 08:00-09:00", third)).resolves.toBe(true); }); }); describe("berlin late in day event moved, viewed from chicago", () => { it("Issue #unknown rrule ETC+2 close to timezone edge", async () => { await helpers.startApplication("tests/configs/modules/calendar/end_of_day_berlin_moved.js", "08 Oct 2024 12:30:00 GMT+02:00", [], "America/Chicago"); - await expect(doTestTableContent(".calendar .event", ".time", "24th.Oct, 16:00-17:00", last)).resolves.toBe(true); + await expect(doTestCount()).resolves.toBe(3); + await expect(doTestTableContent(".calendar .event", ".time", "22nd.Oct, 16:00-17:00", first)).resolves.toBe(true); + await expect(doTestTableContent(".calendar .event", ".time", "23rd.Oct, 16:00-17:00", second)).resolves.toBe(true); + await expect(doTestTableContent(".calendar .event", ".time", "24th.Oct, 16:00-17:00", third)).resolves.toBe(true); }); }); diff --git a/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js b/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js index 344e11d5..db886180 100644 --- a/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js +++ b/tests/unit/modules/default/calendar/calendar_fetcher_utils_spec.js @@ -1,5 +1,8 @@ global.moment = require("moment-timezone"); +const ical = require("node-ical"); +const { expect } = require("playwright/test"); +const moment = require("moment-timezone"); const CalendarFetcherUtils = require("../../../../../modules/default/calendar/calendarfetcherutils"); describe("Calendar fetcher utils test", () => { @@ -49,5 +52,65 @@ describe("Calendar fetcher utils test", () => { expect(filteredEvents[0].title).toBe("ongoingEvent"); expect(filteredEvents[1].title).toBe("upcomingEvent"); }); + + it("should return the correct times when recurring events pass through daylight saving time", () => { + const data = ical.parseICS(`BEGIN:VEVENT +DTSTART;TZID=Europe/Amsterdam:20250311T090000 +DTEND;TZID=Europe/Amsterdam:20250311T091500 +RRULE:FREQ=WEEKLY;BYDAY=FR,MO,TH,TU,WE,SA,SU +DTSTAMP:20250531T091103Z +ORGANIZER;CN=test:mailto:test@test.com +UID:67e65a1d-b889-4451-8cab-5518cecb9c66 +CREATED:20230111T114612Z +DESCRIPTION:Test +LAST-MODIFIED:20250528T071312Z +SEQUENCE:1 +STATUS:CONFIRMED +SUMMARY:Test +TRANSP:OPAQUE +END:VEVENT`); + + const filteredEvents = CalendarFetcherUtils.filterEvents(data, defaultConfig); + + const januaryFirst = filteredEvents.filter((event) => moment(event.startDate, "x").format("MM-DD") === "01-01"); + const julyFirst = filteredEvents.filter((event) => moment(event.startDate, "x").format("MM-DD") === "07-01"); + + let januaryMoment = moment(`${moment(januaryFirst[0].startDate, "x").format("YYYY")}-01-01T09:00:00`) + .tz("Europe/Amsterdam", true) // Convert to Europe/Amsterdam timezone (see event ical) but keep 9 o'clock + .tz(moment.tz.guess()); // Convert to guessed timezone as that is used in the filterEvents + + let julyMoment = moment(`${moment(julyFirst[0].startDate, "x").format("YYYY")}-07-01T09:00:00`) + .tz("Europe/Amsterdam", true) // Convert to Europe/Amsterdam timezone (see event ical) but keep 9 o'clock + .tz(moment.tz.guess()); // Convert to guessed timezone as that is used in the filterEvents + + expect(januaryFirst[0].startDate).toEqual(januaryMoment.format("x")); + expect(julyFirst[0].startDate).toEqual(julyMoment.format("x")); + }); + + it("should return the correct moments based on the timezone given", () => { + const data = ical.parseICS(`BEGIN:VEVENT +DTSTART;TZID=Europe/Amsterdam:20250311T090000 +DTEND;TZID=Europe/Amsterdam:20250311T091500 +RRULE:FREQ=WEEKLY;BYDAY=FR,MO,TH,TU,WE,SA,SU +DTSTAMP:20250531T091103Z +ORGANIZER;CN=test:mailto:test@test.com +UID:67e65a1d-b889-4451-8cab-5518cecb9c66 +CREATED:20230111T114612Z +DESCRIPTION:Test +LAST-MODIFIED:20250528T071312Z +SEQUENCE:1 +STATUS:CONFIRMED +SUMMARY:Test +TRANSP:OPAQUE +END:VEVENT`); + + const moments = CalendarFetcherUtils.getMomentsFromRecurringEvent(data["67e65a1d-b889-4451-8cab-5518cecb9c66"], moment(), moment().add(365, "days")); + + const januaryFirst = moments.filter((m) => m.format("MM-DD") === "01-01"); + const julyFirst = moments.filter((m) => m.format("MM-DD") === "07-01"); + + expect(januaryFirst[0].toISOString(true)).toContain("09:00:00.000+01:00"); + expect(julyFirst[0].toISOString(true)).toContain("09:00:00.000+02:00"); + }); }); });