Refactored calendarfetcherutils to fix many of the timezone and DST related issues and make debugging way easier (#3806)

Refactored calendarfetcherutils to remove as many of the date
conversions as possible and use moment tz when calculating recurring
events, this will make debugging a lot easier and fixes problems from
the past with offsets and DST not being handled properly. Also added
some tests to test the behavior of the refactored methodes to make sure
the correct event dates are returned.

Refactored calendar.js aswell to make sure the unix UTC start and end
date of events are properly converted to a local timezone and displayed
correctly for the user.

This PR relates to:
https://github.com/MagicMirrorOrg/MagicMirror/issues/3797

---------

Co-authored-by: Koen Konst <c.h.konst@avisi.nl>
Co-authored-by: Kristjan ESPERANTO <35647502+KristjanESPERANTO@users.noreply.github.com>
This commit is contained in:
Koen Konst 2025-06-07 14:13:01 +02:00 committed by GitHub
parent 052ec1ca26
commit faf15ad211
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 336 additions and 635 deletions

View File

@ -34,6 +34,10 @@ planned for 2025-07-01
- [refactor] Replace `ansis` with built-in function `util.styleText` (#3793) - [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) - [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) - [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 ### Fixed

View File

@ -52,6 +52,8 @@
"dkallen", "dkallen",
"drivelist", "drivelist",
"DTEND", "DTEND",
"DTSTAMP",
"DTSTART",
"Duffman", "Duffman",
"earlman", "earlman",
"easyas", "easyas",
@ -107,6 +109,7 @@
"jsonlint", "jsonlint",
"jupadin", "jupadin",
"kaennchenstruggle", "kaennchenstruggle",
"Kalenderwoche",
"kenzal", "kenzal",
"Keyport", "Keyport",
"khassel", "khassel",

View File

@ -77,7 +77,7 @@ Module.register("calendar", {
// Define required scripts. // Define required scripts.
getScripts () { getScripts () {
return ["calendarutils.js", "moment.js"]; return ["calendarutils.js", "moment.js", "moment-timezone.js"];
}, },
// Define required translations. // Define required translations.
@ -215,18 +215,9 @@ Module.register("calendar", {
this.updateDom(this.config.animationSpeed); this.updateDom(this.config.animationSpeed);
}, },
eventEndingWithinNextFullTimeUnit (event, ONE_DAY) {
const now = new Date();
return event.endDate - now <= ONE_DAY;
},
// Override dom generator. // Override dom generator.
getDom () { getDom () {
const ONE_SECOND = 1000; // 1,000 milliseconds 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 events = this.createEventList(true);
const wrapper = document.createElement("table"); const wrapper = document.createElement("table");
wrapper.className = this.config.tableClass; wrapper.className = this.config.tableClass;
@ -258,7 +249,9 @@ Module.register("calendar", {
let lastSeenDate = ""; let lastSeenDate = "";
events.forEach((event, index) => { 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 (this.config.timeFormat === "dateheaders") {
if (lastSeenDate !== dateAsString) { if (lastSeenDate !== dateAsString) {
const dateRow = document.createElement("tr"); const dateRow = document.createElement("tr");
@ -340,7 +333,7 @@ Module.register("calendar", {
repeatingCountTitle = this.countTitleForUrl(event.url); repeatingCountTitle = this.countTitleForUrl(event.url);
if (repeatingCountTitle !== "") { if (repeatingCountTitle !== "") {
const thisYear = new Date(parseInt(event.startDate)).getFullYear(), const thisYear = eventStartDateMoment.year(),
yearDiff = thisYear - event.firstYear; yearDiff = thisYear - event.firstYear;
repeatingCountTitle = `, ${yearDiff} ${repeatingCountTitle}`; 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.className = `time light ${this.config.flipDateHeaderTitle ? "align-right " : "align-left "}${this.timeClassForUrl(event.url)}`;
timeWrapper.style.paddingLeft = "2px"; timeWrapper.style.paddingLeft = "2px";
timeWrapper.style.textAlign = this.config.flipDateHeaderTitle ? "right" : "left"; 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 // Add endDate to dataheaders if showEnd is enabled
if (this.config.showEnd) { if (this.config.showEnd) {
if (this.config.showEndsOnlyWithDuration && event.startDate === event.endDate) { if (this.config.showEndsOnlyWithDuration && event.startDate === event.endDate) {
// no duration here, don't display end // no duration here, don't display end
} else { } 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"); const timeWrapper = document.createElement("td");
eventWrapper.appendChild(titleWrapper); eventWrapper.appendChild(titleWrapper);
const now = new Date(); const now = moment();
if (this.config.timeFormat === "absolute") { if (this.config.timeFormat === "absolute") {
// Use dateFormat // 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 // Add end time if showEnd
if (this.config.showEnd) { if (this.config.showEnd) {
// and has a duation // and has a duation
if (event.startDate !== event.endDate) { if (event.startDate !== event.endDate) {
timeWrapper.innerHTML += "-"; 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 // For full day events we use the fullDayEventDateFormat
if (event.fullDayEvent) { if (event.fullDayEvent) {
//subtract one second so that fullDayEvents end at 23:59:59, and not at 0:00:00 one the next day //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; eventEndDateMoment.subtract(1, "second");
timeWrapper.innerHTML = CalendarUtils.capFirst(moment(event.startDate, "x").format(this.config.fullDayEventDateFormat)); timeWrapper.innerHTML = CalendarUtils.capFirst(eventStartDateMoment.format(this.config.fullDayEventDateFormat));
// only show end if requested and allowed and the dates are different // 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 += "-";
timeWrapper.innerHTML += CalendarUtils.capFirst(moment(event.endDate, "x").format(this.config.fullDayEventDateFormat)); timeWrapper.innerHTML += CalendarUtils.capFirst(eventEndDateMoment.format(this.config.fullDayEventDateFormat));
} else } else if (!eventStartDateMoment.isSame(eventEndDateMoment, "d") && eventStartDateMoment.isBefore(now)) {
if ((moment(event.startDate, "x").format("YYYYMMDD") !== moment(event.endDate, "x").format("YYYYMMDD")) && (moment(event.startDate, "x") < moment(now, "x"))) { timeWrapper.innerHTML = CalendarUtils.capFirst(now.format(this.config.fullDayEventDateFormat));
timeWrapper.innerHTML = CalendarUtils.capFirst(moment(now, "x").format(this.config.fullDayEventDateFormat)); }
} } else if (this.config.getRelative > 0 && eventStartDateMoment.isBefore(now)) {
} else if (this.config.getRelative > 0 && event.startDate < now) {
// Ongoing and getRelative is set // Ongoing and getRelative is set
timeWrapper.innerHTML = CalendarUtils.capFirst( timeWrapper.innerHTML = CalendarUtils.capFirst(
this.translate("RUNNING", { this.translate("RUNNING", {
fallback: `${this.translate("RUNNING")} {timeUntilEnd}`, 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 // Within urgency days
timeWrapper.innerHTML = CalendarUtils.capFirst(moment(event.startDate, "x").fromNow()); timeWrapper.innerHTML = CalendarUtils.capFirst(eventStartDateMoment.fromNow());
} }
if (event.fullDayEvent && this.config.nextDaysRelative) { if (event.fullDayEvent && this.config.nextDaysRelative) {
// Full days events within the next two days // Full days events within the next two days
@ -460,9 +452,9 @@ Module.register("calendar", {
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TODAY")); timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TODAY"));
} else if (event.yesterday) { } else if (event.yesterday) {
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("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")); 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") { if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") {
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW")); timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW"));
} }
@ -470,15 +462,15 @@ Module.register("calendar", {
} }
} else { } else {
// Show relative times // 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 // Use relative time
if (!this.config.hideTime && !event.fullDayEvent) { if (!this.config.hideTime && !event.fullDayEvent) {
Log.debug("event not hidden and not fullday"); 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 { } else {
Log.debug("event full day or hidden"); Log.debug("event full day or hidden");
timeWrapper.innerHTML = `${CalendarUtils.capFirst( timeWrapper.innerHTML = `${CalendarUtils.capFirst(
moment(event.startDate, "x").calendar(null, { eventStartDateMoment.calendar(null, {
sameDay: this.config.showTimeToday ? "LT" : `[${this.translate("TODAY")}]`, sameDay: this.config.showTimeToday ? "LT" : `[${this.translate("TODAY")}]`,
nextDay: `[${this.translate("TOMORROW")}]`, nextDay: `[${this.translate("TOMORROW")}]`,
nextWeek: "dddd", nextWeek: "dddd",
@ -488,7 +480,7 @@ Module.register("calendar", {
} }
if (event.fullDayEvent) { if (event.fullDayEvent) {
// Full days events within the next two days // 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")); timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TODAY"));
} else if (event.dayBeforeYesterday) { } else if (event.dayBeforeYesterday) {
if (this.translate("DAYBEFOREYESTERDAY") !== "DAYBEFOREYESTERDAY") { if (this.translate("DAYBEFOREYESTERDAY") !== "DAYBEFOREYESTERDAY") {
@ -496,25 +488,25 @@ Module.register("calendar", {
} }
} else if (event.yesterday) { } else if (event.yesterday) {
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("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")); 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") { if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") {
timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW")); timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW"));
} }
} }
Log.info("event fullday"); 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"); Log.info("not full day but within getrelative size");
// If event is within getRelative hours, display 'in xxx' time format or moment.fromNow() // 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 { } else {
// Ongoing event // Ongoing event
timeWrapper.innerHTML = CalendarUtils.capFirst( timeWrapper.innerHTML = CalendarUtils.capFirst(
this.translate("RUNNING", { this.translate("RUNNING", {
fallback: `${this.translate("RUNNING")} {timeUntilEnd}`, 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; 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. * Creates the sorted list of all events.
* @param {boolean} limitNumberOfEntries Whether to filter returned events for display. * @param {boolean} limitNumberOfEntries Whether to filter returned events for display.
* @returns {object[]} Array with events. * @returns {object[]} Array with events.
*/ */
createEventList (limitNumberOfEntries) { createEventList (limitNumberOfEntries) {
const ONE_SECOND = 1000; // 1,000 milliseconds let now = moment();
const ONE_MINUTE = ONE_SECOND * 60; let today = now.clone().startOf("day");
const ONE_HOUR = ONE_MINUTE * 60; let future = now.clone().startOf("day").add(this.config.maximumNumberOfDays, "days");
const ONE_DAY = ONE_HOUR * 24;
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 = []; let events = [];
for (const calendarUrl in this.calendarData) { for (const calendarUrl in this.calendarData) {
const calendar = this.calendarData[calendarUrl]; const calendar = this.calendarData[calendarUrl];
let remainingEntries = this.maximumEntriesForUrl(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 = []; let by_url_calevents = [];
for (const e in calendar) { for (const e in calendar) {
const event = JSON.parse(JSON.stringify(calendar[e])); // clone object 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") { if (this.config.hidePrivate && event.class === "PRIVATE") {
// do not add the current event, skip it // do not add the current event, skip it
continue; continue;
} }
if (limitNumberOfEntries) { if (limitNumberOfEntries) {
if (event.endDate < maxPastDaysCompare) { if (eventEndDateMoment.isBefore(maxPastDaysCompare)) {
continue; continue;
} }
if (this.config.hideOngoing && event.startDate < now) { if (this.config.hideOngoing && eventStartDateMoment.isBefore(now)) {
continue; continue;
} }
if (this.config.hideDuplicates && this.listContainsEvent(events, event)) { if (this.config.hideDuplicates && this.listContainsEvent(events, event)) {
@ -641,47 +633,46 @@ Module.register("calendar", {
} }
event.url = calendarUrl; event.url = calendarUrl;
event.today = event.startDate >= today && event.startDate < today + ONE_DAY; event.today = eventStartDateMoment.isSame(now, "d");
event.dayBeforeYesterday = event.startDate >= today - ONE_DAY * 2 && event.startDate < today - ONE_DAY; event.dayBeforeYesterday = eventStartDateMoment.isSame(now.clone().subtract(2, "days"), "d");
event.yesterday = event.startDate >= today - ONE_DAY && event.startDate < today; event.yesterday = eventStartDateMoment.isSame(now.clone().subtract(1, "days"), "d");
event.tomorrow = !event.today && event.startDate >= today + ONE_DAY && event.startDate < today + 2 * ONE_DAY; event.tomorrow = eventStartDateMoment.isSame(now.clone().add(1, "days"), "d");
event.dayAfterTomorrow = !event.tomorrow && event.startDate >= today + ONE_DAY * 2 && event.startDate < today + 3 * ONE_DAY; 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, * 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. * 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) { if (this.config.sliceMultiDayEvents && maxCount > 1) {
const splitEvents = []; const splitEvents = [];
let midnight let midnight
= moment(event.startDate, "x") = eventStartDateMoment
.clone() .clone()
.startOf("day") .startOf("day")
.add(1, "day") .add(1, "day")
.endOf("day") .endOf("day");
.format("x");
let count = 1; let count = 1;
while (event.endDate > midnight) { while (eventEndDateMoment.isAfter(midnight)) {
const thisEvent = JSON.parse(JSON.stringify(event)); // clone object const thisEvent = JSON.parse(JSON.stringify(event)); // clone object
thisEvent.today = thisEvent.startDate >= today && thisEvent.startDate < today + ONE_DAY; thisEvent.today = this.timestampToMoment(thisEvent.startDate).isSame(now, "d");
thisEvent.tomorrow = !thisEvent.today && thisEvent.startDate >= today + ONE_DAY && thisEvent.startDate < today + 2 * ONE_DAY; thisEvent.tomorrow = this.timestampToMoment(thisEvent.startDate).isSame(now.clone().add(1, "days"), "d");
thisEvent.endDate = moment(midnight, "x").clone().subtract(1, "day").format("x"); thisEvent.endDate = midnight.clone().subtract(1, "day").format("x");
thisEvent.title += ` (${count}/${maxCount})`; thisEvent.title += ` (${count}/${maxCount})`;
splitEvents.push(thisEvent); splitEvents.push(thisEvent);
event.startDate = midnight; event.startDate = midnight.format("x");
count += 1; 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 // Last day
event.title += ` (${count}/${maxCount})`; event.title += ` (${count}/${maxCount})`;
event.today += event.startDate >= today && event.startDate < today + ONE_DAY; event.today += this.timestampToMoment(event.startDate).isSame(now, "d");
event.tomorrow = !event.today && event.startDate >= today + ONE_DAY && event.startDate < today + 2 * ONE_DAY; event.tomorrow = this.timestampToMoment(event.startDate).isSame(now.clone().add(1, "days"), "d");
splitEvents.push(event); splitEvents.push(event);
for (let splitEvent of splitEvents) { 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); by_url_calevents.push(splitEvent);
} }
} }
@ -716,16 +707,16 @@ Module.register("calendar", {
*/ */
if (this.config.limitDays > 0) { if (this.config.limitDays > 0) {
let newEvents = []; let newEvents = [];
let lastDate = today.clone().subtract(1, "days").format("YYYYMMDD"); let lastDate = today.clone().subtract(1, "days");
let days = 0; let days = 0;
for (const ev of events) { 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 * if date of event is later than lastdate
* check if we already are showing max unique days * 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 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) { if (!this.config.limitDaysNeverSkip && newEvents.length === 1 && days === 1 && newEvents[0].fullDayEvent) {
days--; days--;

View File

@ -1,114 +1,130 @@
/** /**
* @external Moment * @external Moment
*/ */
const path = require("node:path"); const moment = require("moment-timezone");
const moment = require("moment");
const zoneTable = require(path.join(__dirname, "windowsZones.json"));
const Log = require("../../../js/logger"); const Log = require("../../../js/logger");
const CalendarFetcherUtils = { const CalendarFetcherUtils = {
/** /**
* Calculate the time correction, either dst/std or full day in cases where * Determine based on the title of an event if it should be excluded from the list of events
* utc time is day before plus offset * TODO This seems like an overly complicated way to exclude events based on the title.
* @param {object} event the event which needs adjustment * @param {object} config the global config
* @param {Date} date the date on which this event happens * @param {string} title the title of the event
* @returns {number} the necessary adjustment in hours * @returns {object} excluded: true if the event should be excluded, false otherwise
* until: the date until the event should be excluded.
*/ */
calculateTimezoneAdjustment (event, date) { shouldEventBeExcluded (config, title) {
let adjustHours = 0; let filter = {
// if a timezone was specified excluded: false,
if (!event.start.tz) { until: null
Log.debug(" if no tz, guess based on now"); };
event.start.tz = moment.tz.guess(); for (let f in config.excludedEvents) {
} let filter = config.excludedEvents[f],
Log.debug(`initial tz=${event.start.tz}`); testTitle = title.toLowerCase(),
until = null,
useRegex = false,
regexFlags = "g";
// if there is a start date specified if (filter instanceof Object) {
if (event.start.tz) { if (typeof filter.until !== "undefined") {
// if this is a windows timezone until = filter.until;
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 = moment(new Date()).utcOffset();
Log.debug(`net mins=${current_offset * 60 - x}`);
mm = mm.add(x - current_offset * 60, "minutes"); if (typeof filter.regex !== "undefined") {
adjustHours = (current_offset * 60 - x) / 60; useRegex = filter.regex;
event.start = mm.toDate(); }
Log.debug(`adjusted date=${event.start}`);
// 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 { } else {
// get the start time in that timezone filter = filter.toLowerCase();
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 (CalendarFetcherUtils.titleFilterApplies(testTitle, filter, useRegex, regexFlags)) {
if (current_offset !== start_offset) { if (until) {
// big offset filter.until = until;
Log.debug("offset"); } else {
let h = parseInt(mm.format("H")); filter.excluded = true;
// 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");
} }
break;
} }
} }
Log.debug(`adjustHours=${adjustHours}`); return filter;
return adjustHours; },
/**
* 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) { filterEvents (data, config) {
const newEvents = []; 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) { 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.`); Log.debug(`There are ${Object.entries(data).length} calendar entries.`);
const now = new Date(Date.now()); const now = moment();
const todayLocal = moment(now).startOf("day").toDate(); const pastLocalMoment = config.includePastEvents ? now.clone().startOf("day").subtract(config.maximumNumberOfDays, "days") : now;
const futureLocalDate const futureLocalMoment
= moment(now) = now
.clone()
.startOf("day") .startOf("day")
.add(config.maximumNumberOfDays, "days") .add(config.maximumNumberOfDays, "days")
.subtract(1, "seconds") // Subtract 1 second so that events that start on the middle of the night will not repeat. // Subtract 1 second so that events that start on the middle of the night will not repeat.
.toDate(); .subtract(1, "seconds");
Object.entries(data).forEach(([key, event]) => { Object.entries(data).forEach(([key, event]) => {
Log.debug("Processing entry..."); Log.debug("Processing entry...");
let pastLocalDate = todayLocal;
if (config.includePastEvents) { const title = CalendarFetcherUtils.getTitleFromEvent(event);
pastLocalDate = moment(now).startOf("day").subtract(config.maximumNumberOfDays, "days").toDate(); 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. // FIXME: Ugly fix to solve the facebook birthday issue.
@ -161,218 +176,47 @@ const CalendarFetcherUtils = {
if (event.type === "VEVENT") { if (event.type === "VEVENT") {
Log.debug(`Event:\n${JSON.stringify(event, null, 2)}`); Log.debug(`Event:\n${JSON.stringify(event, null, 2)}`);
let startMoment = eventDate(event, "start"); let eventStartMoment = eventDate(event, "start");
let endMoment; let eventEndMoment;
if (typeof event.end !== "undefined") { if (typeof event.end !== "undefined") {
endMoment = eventDate(event, "end"); eventEndMoment = eventDate(event, "end");
} else if (typeof event.duration !== "undefined") { } else if (typeof event.duration !== "undefined") {
endMoment = startMoment.clone().add(moment.duration(event.duration)); eventEndMoment = eventStartMoment.clone().add(moment.duration(event.duration));
} else { } else {
if (!isFacebookBirthday) { if (!isFacebookBirthday) {
// make copy of start date, separate storage area // make copy of start date, separate storage area
endMoment = moment(startMoment.valueOf()); eventEndMoment = eventStartMoment.clone();
} else { } else {
endMoment = moment(startMoment).add(1, "days"); eventEndMoment = eventStartMoment.clone().add(1, "days");
} }
} }
Log.debug(`start: ${startMoment.toDate()}`); Log.debug(`start: ${eventStartMoment.toDate()}`);
Log.debug(`end:: ${endMoment.toDate()}`); Log.debug(`end:: ${eventEndMoment.toDate()}`);
// Calculate the duration of the event for use with recurring events. // 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}`); 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 location = event.location || false;
const geo = event.geo || false; const geo = event.geo || false;
const description = event.description || 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) { 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); // Loop through the set of moment entries to see which recurrences should be added to our event list.
const futureMoment = moment(futureLocalDate); // TODO This should create an event per moment so we can change anything we want.
for (let m in moments) {
// 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];
let curEvent = event; let curEvent = event;
let curDurationMs = durationMs;
let showRecurrence = true; let showRecurrence = true;
let recurringEventStartMoment = moments[m].tz(CalendarFetcherUtils.getLocalTimezone()).clone();
let recurringEventEndMoment = recurringEventStartMoment.clone().add(durationMs, "ms");
let startMoment = moment(date); let dateKey = recurringEventStartMoment.tz("UTC").format("YYYY-MM-DD");
let dateKey = CalendarFetcherUtils.getDateKeyFromDate(date);
Log.debug("event date dateKey=", dateKey); 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. // 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); 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. // We found an override, so for this recurrence, use a potentially different title, start date, and duration.
curEvent = curEvent.recurrences[dateKey]; curEvent = curEvent.recurrences[dateKey];
curEvent.start = new Date(new Date(curEvent.start.valueOf()).getTime()); // Some event start/end dates don't have timezones
curEvent.end = new Date(new Date(curEvent.end.valueOf()).getTime()); if (curEvent.start.tz) {
startMoment = CalendarFetcherUtils.getAdjustedStartMoment(curEvent.start, event); recurringEventStartMoment = moment(curEvent.start).tz(curEvent.start.tz).tz(CalendarFetcherUtils.getLocalTimezone());
endMoment = CalendarFetcherUtils.getAdjustedStartMoment(curEvent.end, event); } else {
date = curEvent.start; recurringEventStartMoment = moment(curEvent.start).tz(CalendarFetcherUtils.getLocalTimezone());
curDurationMs = new Date(endMoment).valueOf() - startMoment.valueOf(); }
if (curEvent.end.tz) {
recurringEventEndMoment = moment(curEvent.end).tz(curEvent.end.tz).tz(CalendarFetcherUtils.getLocalTimezone());
} else {
recurringEventEndMoment = moment(curEvent.end).tz(CalendarFetcherUtils.getLocalTimezone());
}
} else { } else {
Log.debug("recurrence key ", dateKey, " doesn't match"); Log.debug("recurrence key ", dateKey, " doesn't match");
} }
@ -400,25 +249,20 @@ const CalendarFetcherUtils = {
showRecurrence = false; showRecurrence = false;
} }
} }
Log.debug(`duration: ${curDurationMs}`);
startMoment = CalendarFetcherUtils.getAdjustedStartMoment(date, event); if (recurringEventStartMoment.valueOf() === recurringEventEndMoment.valueOf()) {
recurringEventEndMoment = recurringEventEndMoment.endOf("day");
endMoment = moment(startMoment.valueOf() + curDurationMs);
if (startMoment.valueOf() === endMoment.valueOf()) {
endMoment = endMoment.endOf("day");
} }
const recurrenceTitle = CalendarFetcherUtils.getTitleFromEvent(curEvent); 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 // 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. // it to the event list.
if (endMoment.isBefore(pastLocal) || startMoment.isAfter(futureLocal)) { if (recurringEventEndMoment.isBefore(pastLocalMoment) || recurringEventStartMoment.isAfter(futureLocalMoment)) {
showRecurrence = false; showRecurrence = false;
} }
if (CalendarFetcherUtils.timeFilterApplies(now, endMoment, dateFilter)) { if (CalendarFetcherUtils.timeFilterApplies(now, recurringEventEndMoment, eventFilterUntil)) {
showRecurrence = false; showRecurrence = false;
} }
@ -426,8 +270,8 @@ const CalendarFetcherUtils = {
Log.debug(`saving event: ${recurrenceTitle}`); Log.debug(`saving event: ${recurrenceTitle}`);
newEvents.push({ newEvents.push({
title: recurrenceTitle, title: recurrenceTitle,
startDate: startMoment.format("x"), startDate: recurringEventStartMoment.format("x"),
endDate: endMoment.format("x"), endDate: recurringEventEndMoment.format("x"),
fullDayEvent: CalendarFetcherUtils.isFullDayEvent(event), fullDayEvent: CalendarFetcherUtils.isFullDayEvent(event),
recurringEvent: true, recurringEvent: true,
class: event.class, class: event.class,
@ -437,7 +281,7 @@ const CalendarFetcherUtils = {
description: description description: description
}); });
} else { } else {
Log.debug("not saving event ", recurrenceTitle, new Date(startMoment)); Log.debug("not saving event ", recurrenceTitle, eventStartMoment);
} }
Log.debug(" "); Log.debug(" ");
} }
@ -448,47 +292,41 @@ const CalendarFetcherUtils = {
// Log.debug("full day event") // Log.debug("full day event")
// if the start and end are the same, then make end the 'end of day' value (start is at 00:00:00) // if 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()) { if (fullDayEvent && eventStartMoment.valueOf() === eventEndMoment.valueOf()) {
endMoment = endMoment.endOf("day"); eventEndMoment = eventEndMoment.endOf("day");
} }
if (config.includePastEvents) { if (config.includePastEvents) {
// Past event is too far in the past, so skip. // Past event is too far in the past, so skip.
if (endMoment < pastLocalDate) { if (eventEndMoment < pastLocalMoment) {
return; return;
} }
} else { } else {
// It's not a fullday event, and it is in the past, so skip. // It's not a fullday event, and it is in the past, so skip.
if (!fullDayEvent && endMoment < now) { if (!fullDayEvent && eventEndMoment < now) {
return; return;
} }
// It's a fullday event, and it is before today, So skip. // It's a fullday event, and it is before today, So skip.
if (fullDayEvent && endMoment <= todayLocal) { if (fullDayEvent && eventEndMoment <= now.startOf("day")) {
return; return;
} }
} }
// It exceeds the maximumNumberOfDays limit, so skip. // It exceeds the maximumNumberOfDays limit, so skip.
if (startMoment > futureLocalDate) { if (eventStartMoment > futureLocalMoment) {
return; return;
} }
if (CalendarFetcherUtils.timeFilterApplies(now, endMoment, dateFilter)) { if (CalendarFetcherUtils.timeFilterApplies(now, eventEndMoment, eventFilterUntil)) {
return; 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. // Every thing is good. Add it to the list.
newEvents.push({ newEvents.push({
title: title, title: title,
startDate: startMoment.add(adjustHours, "hours").format("x"), startDate: eventStartMoment.format("x"),
endDate: endMoment.add(adjustHours, "hours").format("x"), endDate: eventEndMoment.format("x"),
fullDayEvent: fullDayEvent, fullDayEvent: fullDayEvent,
recurringEvent: false, recurringEvent: false,
class: event.class, class: event.class,
@ -508,213 +346,6 @@ const CalendarFetcherUtils = {
return newEvents; 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. * Gets the title from the event.
* @param {object} event The event object to check. * @param {object} event The event object to check.
@ -754,8 +385,8 @@ const CalendarFetcherUtils = {
/** /**
* Determines if the user defined time filter should apply * Determines if the user defined time filter should apply
* @param {Date} now Date object using previously created object for consistency * @param {moment.Moment} now Date object using previously created object for consistency
* @param {Moment} endDate Moment object representing the event end date * @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 * @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 * @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 increment = until[1].slice(-1) === "s" ? until[1] : `${until[1]}s`, // Massage the data for moment js
filterUntil = moment(endDate.format()).subtract(value, increment); filterUntil = moment(endDate.format()).subtract(value, increment);
return now < filterUntil.toDate(); return now < filterUntil;
} }
return false; return false;

View File

@ -22,6 +22,19 @@ describe("Calendar module", () => {
return await loc.count(); return await loc.count();
}; };
/**
* Use this for debugging broken tests, it will console log the text of the calendar module
* @returns {Promise<void>}
*/
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 first = 0;
const second = 1; const second = 1;
const third = 2; const third = 2;
@ -153,19 +166,6 @@ describe("Calendar module", () => {
* RRULE TESTS: * RRULE TESTS:
* Add any tests that check rrule functionality here. * 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", () => { describe("sliceMultiDayEvents direct count", () => {
it("Issue #3452 split multiday in Europe", async () => { 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"); 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", () => { describe("berlin late in day event moved, viewed from berlin", () => {
it("Issue #unknown rrule ETC+2 close to timezone edge", async () => { 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 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", () => { describe("berlin late in day event moved, viewed from sydney", () => {
it("Issue #unknown rrule ETC+2 close to timezone edge", async () => { 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 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", () => { describe("berlin late in day event moved, viewed from chicago", () => {
it("Issue #unknown rrule ETC+2 close to timezone edge", async () => { 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 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);
}); });
}); });

View File

@ -1,5 +1,8 @@
global.moment = require("moment-timezone"); 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"); const CalendarFetcherUtils = require("../../../../../modules/default/calendar/calendarfetcherutils");
describe("Calendar fetcher utils test", () => { describe("Calendar fetcher utils test", () => {
@ -49,5 +52,65 @@ describe("Calendar fetcher utils test", () => {
expect(filteredEvents[0].title).toBe("ongoingEvent"); expect(filteredEvents[0].title).toBe("ongoingEvent");
expect(filteredEvents[1].title).toBe("upcomingEvent"); 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");
});
}); });
}); });