123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623 |
- /* Magic Mirror
- * Calendar Util Methods
- *
- * By Michael Teeuw https://michaelteeuw.nl
- * MIT Licensed.
- */
-
- /**
- * @external Moment
- */
- const moment = require("moment");
- const path = require("path");
- const zoneTable = require(path.join(__dirname, "windowsZones.json"));
- const Log = require("../../../js/logger.js");
-
- const CalendarUtils = {
- /**
- * 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 adjustement
- * @param {Date} date the date on which this event happens
- * @returns {number} the necessary adjustment in hours
- */
- calculateTimezoneAdjustment: function (event, date) {
- let adjustHours = 0;
- // if a timezone was specified
- if (!event.start.tz) {
- Log.debug(" if no tz, guess based on now");
- event.start.tz = moment.tz.guess();
- }
- Log.debug("initial tz=" + event.start.tz);
-
- // if there is a start date specified
- if (event.start.tz) {
- // if this is a windows timezone
- if (event.start.tz.includes(" ")) {
- // use the lookup table to get theIANA name as moment and date don't know MS timezones
- let tz = CalendarUtils.getIanaTZFromMS(event.start.tz);
- Log.debug("corrected TZ=" + tz);
- // watch out for unregistered windows timezone names
- // if we had a successful lookup
- if (tz) {
- // change the timezone to the IANA name
- event.start.tz = tz;
- // Log.debug("corrected timezone="+event.start.tz)
- }
- }
- Log.debug("corrected tz=" + event.start.tz);
- let current_offset = 0; // offset from TZ string or calculated
- let mm = 0; // date with tz or offset
- let start_offset = 0; // utc offset of created with tz
- // if there is still an offset, lookup failed, use it
- if (event.start.tz.startsWith("(")) {
- const regex = /[+|-]\d*:\d*/;
- const start_offsetString = event.start.tz.match(regex).toString().split(":");
- let start_offset = parseInt(start_offsetString[0]);
- start_offset *= event.start.tz[1] === "-" ? -1 : 1;
- adjustHours = start_offset;
- Log.debug("defined offset=" + start_offset + " hours");
- current_offset = start_offset;
- event.start.tz = "";
- Log.debug("ical offset=" + current_offset + " date=" + date);
- mm = moment(date);
- let x = parseInt(moment(new Date()).utcOffset());
- Log.debug("net mins=" + (current_offset * 60 - x));
-
- mm = mm.add(x - current_offset * 60, "minutes");
- adjustHours = (current_offset * 60 - x) / 60;
- event.start = mm.toDate();
- Log.debug("adjusted date=" + event.start);
- } else {
- // get the start time in that timezone
- let es = moment(event.start);
- // check for start date prior to start of daylight changing date
- if (es.format("YYYY") < 2007) {
- es.set("year", 2013); // if so, use a closer date
- }
- Log.debug("start date/time=" + es.toDate());
- start_offset = moment.tz(es, event.start.tz).utcOffset();
- Log.debug("start offset=" + start_offset);
-
- Log.debug("start date/time w tz =" + moment.tz(moment(event.start), event.start.tz).toDate());
-
- // get the specified date in that timezone
- mm = moment.tz(moment(date), event.start.tz);
- Log.debug("event date=" + mm.toDate());
- current_offset = mm.utcOffset();
- }
- Log.debug("event offset=" + current_offset + " hour=" + mm.format("H") + " event date=" + mm.toDate());
-
- // if the offset is greater than 0, east of london
- if (current_offset !== start_offset) {
- // big offset
- Log.debug("offset");
- let h = parseInt(mm.format("H"));
- // check if the event time is less than the offset
- if (h > 0 && h < Math.abs(current_offset) / 60) {
- // if so, rrule created a wrong date (utc day, oops, with utc yesterday adjusted time)
- // we need to fix that
- adjustHours = 24;
- // Log.debug("adjusting date")
- }
- //-300 > -240
- //if (Math.abs(current_offset) > Math.abs(start_offset)){
- if (current_offset > start_offset) {
- adjustHours -= 1;
- Log.debug("adjust down 1 hour dst change");
- //} else if (Math.abs(current_offset) < Math.abs(start_offset)) {
- } else if (current_offset < start_offset) {
- adjustHours += 1;
- Log.debug("adjust up 1 hour dst change");
- }
- }
- }
- Log.debug("adjustHours=" + adjustHours);
- return adjustHours;
- },
-
- /**
- * Filter the events from ical according to the given config
- *
- * @param {object} data the calendar data from ical
- * @param {object} config The configuration object
- * @returns {string[]} the filtered events
- */
- filterEvents: function (data, config) {
- const newEvents = [];
-
- // limitFunction doesn't do much limiting, see comment re: the dates
- // array in rrule section below as to why we need to do the filtering
- // ourselves
- const limitFunction = function (date, i) {
- return true;
- };
-
- const eventDate = function (event, time) {
- return CalendarUtils.isFullDayEvent(event) ? moment(event[time], "YYYYMMDD") : moment(new Date(event[time]));
- };
-
- Log.debug("There are " + Object.entries(data).length + " calendar entries.");
- Object.entries(data).forEach(([key, event]) => {
- Log.debug("Processing entry...");
- const now = new Date();
- const today = moment().startOf("day").toDate();
- const future = moment().startOf("day").add(config.maximumNumberOfDays, "days").subtract(1, "seconds").toDate(); // Subtract 1 second so that events that start on the middle of the night will not repeat.
- let past = today;
-
- if (config.includePastEvents) {
- past = moment().startOf("day").subtract(config.maximumNumberOfDays, "days").toDate();
- }
-
- // FIXME: Ugly fix to solve the facebook birthday issue.
- // Otherwise, the recurring events only show the birthday for next year.
- let isFacebookBirthday = false;
- if (typeof event.uid !== "undefined") {
- if (event.uid.indexOf("@facebook.com") !== -1) {
- isFacebookBirthday = true;
- }
- }
-
- if (event.type === "VEVENT") {
- Log.debug("\nEvent: " + JSON.stringify(event));
- let startDate = eventDate(event, "start");
- let endDate;
-
- if (typeof event.end !== "undefined") {
- endDate = eventDate(event, "end");
- } else if (typeof event.duration !== "undefined") {
- endDate = startDate.clone().add(moment.duration(event.duration));
- } else {
- if (!isFacebookBirthday) {
- // make copy of start date, separate storage area
- endDate = moment(startDate.format("x"), "x");
- } else {
- endDate = moment(startDate).add(1, "days");
- }
- }
-
- Log.debug("startDate (local): " + startDate.toDate());
- Log.debug("endDate (local): " + endDate.toDate());
-
- // Calculate the duration of the event for use with recurring events.
- let duration = parseInt(endDate.format("x")) - parseInt(startDate.format("x"));
- Log.debug("duration: " + duration);
-
- // FIXME: Since the parsed json object from node-ical comes with time information
- // this check could be removed (?)
- if (event.start.length === 8) {
- startDate = startDate.startOf("day");
- }
-
- const title = CalendarUtils.getTitleFromEvent(event);
- Log.debug("title: " + title);
-
- let excluded = false,
- dateFilter = null;
-
- for (let f in config.excludedEvents) {
- let filter = config.excludedEvents[f],
- testTitle = title.toLowerCase(),
- until = null,
- useRegex = false,
- regexFlags = "g";
-
- if (filter instanceof Object) {
- if (typeof filter.until !== "undefined") {
- until = filter.until;
- }
-
- if (typeof filter.regex !== "undefined") {
- useRegex = filter.regex;
- }
-
- // If additional advanced filtering is added in, this section
- // must remain last as we overwrite the filter object with the
- // filterBy string
- if (filter.caseSensitive) {
- filter = filter.filterBy;
- testTitle = title;
- } else if (useRegex) {
- filter = filter.filterBy;
- testTitle = title;
- regexFlags += "i";
- } else {
- filter = filter.filterBy.toLowerCase();
- }
- } else {
- filter = filter.toLowerCase();
- }
-
- if (CalendarUtils.titleFilterApplies(testTitle, filter, useRegex, regexFlags)) {
- if (until) {
- dateFilter = until;
- } else {
- excluded = true;
- }
- break;
- }
- }
-
- if (excluded) {
- return;
- }
-
- const location = event.location || false;
- const geo = event.geo || false;
- const description = event.description || false;
-
- if (typeof event.rrule !== "undefined" && event.rrule !== null && !isFacebookBirthday) {
- const rule = event.rrule;
- let addedEvents = 0;
-
- const pastMoment = moment(past);
- const futureMoment = moment(future);
-
- // can cause problems with e.g. birthdays before 1900
- if ((rule.options && rule.origOptions && rule.origOptions.dtstart && rule.origOptions.dtstart.getFullYear() < 1900) || (rule.options && rule.options.dtstart && rule.options.dtstart.getFullYear() < 1900)) {
- rule.origOptions.dtstart.setYear(1900);
- rule.options.dtstart.setYear(1900);
- }
-
- // For recurring events, get the set of start dates that fall within the range
- // of dates we're looking for.
- // kblankenship1989 - to fix issue #1798, converting all dates to locale time first, then converting back to UTC time
- let pastLocal = 0;
- let futureLocal = 0;
- if (CalendarUtils.isFullDayEvent(event)) {
- Log.debug("fullday");
- // if full day event, only use the date part of the ranges
- pastLocal = pastMoment.toDate();
- futureLocal = futureMoment.toDate();
-
- Log.debug("pastLocal: " + pastLocal);
- Log.debug("futureLocal: " + futureLocal);
- } else {
- // if we want past events
- if (config.includePastEvents) {
- // use the calculated past time for the between from
- pastLocal = pastMoment.toDate();
- } else {
- // otherwise use NOW.. cause we shouldn't use any before now
- pastLocal = moment().toDate(); //now
- }
- futureLocal = futureMoment.toDate(); // future
- }
- Log.debug("Search for recurring events between: " + pastLocal + " and " + futureLocal);
- const dates = rule.between(pastLocal, futureLocal, true, limitFunction);
- Log.debug("Title: " + event.summary + ", with dates: " + JSON.stringify(dates));
- // The "dates" array contains the set of dates within our desired date range range that are valid
- // for the recurrence rule. *However*, it's possible for us to have a specific recurrence that
- // had its date changed from outside the range to inside the range. For the time being,
- // we'll handle this by adding *all* recurrence entries into the set of dates that we check,
- // because the logic below will filter out any recurrences that don't actually belong within
- // our display range.
- // Would be great if there was a better way to handle this.
- Log.debug("event.recurrences: " + event.recurrences);
- if (event.recurrences !== undefined) {
- for (let r in event.recurrences) {
- // Only add dates that weren't already in the range we added from the rrule so that
- // we don"t double-add those events.
- if (moment(new Date(r)).isBetween(pastMoment, futureMoment) !== true) {
- dates.push(new Date(r));
- }
- }
- }
- // Loop through the set of date entries to see which recurrences should be added to our event list.
- for (let d in dates) {
- let date = dates[d];
- // Remove the time information of each date by using its substring, using the following method:
- // .toISOString().substring(0,10).
- // since the date is given as ISOString with YYYY-MM-DDTHH:MM:SS.SSSZ
- // (see https://momentjs.com/docs/#/displaying/as-iso-string/).
- const dateKey = date.toISOString().substring(0, 10);
- let curEvent = event;
- let showRecurrence = true;
-
- // Get the offset of today where we are processing
- // This will be the correction, we need to apply.
- let nowOffset = new Date().getTimezoneOffset();
- // For full day events, the time might be off from RRULE/Luxon problem
- // Get time zone offset of the rule calculated event
- let dateoffset = date.getTimezoneOffset();
-
- // Reduce the time by the following offset.
- Log.debug(" recurring date is " + date + " offset is " + dateoffset);
-
- let dh = moment(date).format("HH");
- Log.debug(" recurring date is " + date + " offset is " + dateoffset / 60 + " Hour is " + dh);
-
- if (CalendarUtils.isFullDayEvent(event)) {
- Log.debug("Fullday");
- // If the offset is negative (east of GMT), where the problem is
- if (dateoffset < 0) {
- // Remove the offset, independently of the comparison between the date hour and the offset,
- // since in the case that *date houre < offset*, the *new Date* command will handle this by
- // representing the day before.
-
- // Reduce the time by the offset:
- // Apply the correction to the date/time to get it UTC relative
- date = new Date(date.getTime() - Math.abs(nowOffset) * 60000);
- // the duration was calculated way back at the top before we could correct the start time..
- // fix it for this event entry
- //duration = 24 * 60 * 60 * 1000;
- Log.debug("new recurring date1 is " + date);
- } else {
- // if the timezones are the same, correct date if needed
- if (event.start.tz === moment.tz.guess()) {
- // if the date hour is less than the offset
- if (24 - dh < Math.abs(dateoffset / 60)) {
- // apply the correction to the date/time back to right day
- date = new Date(date.getTime() + Math.abs(24 * 60) * 60000);
- // the duration was calculated way back at the top before we could correct the start time..
- // fix it for this event entry
- //duration = 24 * 60 * 60 * 1000;
- Log.debug("new recurring date2 is " + date);
- }
- }
- }
- } else {
- // not full day, but luxon can still screw up the date on the rule processing
- // we need to correct the date to get back to the right event for
- if (dateoffset < 0) {
- // if the date hour is less than the offset
- if (dh < Math.abs(dateoffset / 60)) {
- // Reduce the time by the offset:
- // Apply the correction to the date/time to get it UTC relative
- date = new Date(date.getTime() - Math.abs(nowOffset) * 60000);
- // the duration was calculated way back at the top before we could correct the start time..
- // fix it for this event entry
- //duration = 24 * 60 * 60 * 1000;
- Log.debug("new recurring date1 is " + date);
- }
- } else {
- // if the timezones are the same, correct date if needed
- if (event.start.tz === moment.tz.guess()) {
- // if the date hour is less than the offset
- if (24 - dh < Math.abs(dateoffset / 60)) {
- // apply the correction to the date/time back to right day
- date = new Date(date.getTime() + Math.abs(24 * 60) * 60000);
- // the duration was calculated way back at the top before we could correct the start time..
- // fix it for this event entry
- //duration = 24 * 60 * 60 * 1000;
- Log.debug("new recurring date2 is " + date);
- }
- }
- }
- }
- startDate = moment(date);
- Log.debug("Corrected startDate (local): " + startDate.toDate());
-
- let adjustDays = CalendarUtils.calculateTimezoneAdjustment(event, date);
-
- // For each date that we're checking, it's possible that there is a recurrence override for that one day.
- if (curEvent.recurrences !== undefined && curEvent.recurrences[dateKey] !== undefined) {
- // We found an override, so for this recurrence, use a potentially different title, start date, and duration.
- curEvent = curEvent.recurrences[dateKey];
- startDate = moment(curEvent.start);
- duration = parseInt(moment(curEvent.end).format("x")) - parseInt(startDate.format("x"));
- }
- // If there's no recurrence override, check for an exception date. Exception dates represent exceptions to the rule.
- else if (curEvent.exdate !== undefined && curEvent.exdate[dateKey] !== undefined) {
- // This date is an exception date, which means we should skip it in the recurrence pattern.
- showRecurrence = false;
- }
- Log.debug("duration: " + duration);
-
- endDate = moment(parseInt(startDate.format("x")) + duration, "x");
- if (startDate.format("x") === endDate.format("x")) {
- endDate = endDate.endOf("day");
- }
-
- const recurrenceTitle = CalendarUtils.getTitleFromEvent(curEvent);
-
- // If this recurrence ends before the start of the date range, or starts after the end of the date range, don"t add
- // it to the event list.
- if (endDate.isBefore(past) || startDate.isAfter(future)) {
- showRecurrence = false;
- }
-
- if (CalendarUtils.timeFilterApplies(now, endDate, dateFilter)) {
- showRecurrence = false;
- }
-
- if (showRecurrence === true) {
- Log.debug("saving event: " + description);
- addedEvents++;
- newEvents.push({
- title: recurrenceTitle,
- startDate: (adjustDays ? (adjustDays > 0 ? startDate.add(adjustDays, "hours") : startDate.subtract(Math.abs(adjustDays), "hours")) : startDate).format("x"),
- endDate: (adjustDays ? (adjustDays > 0 ? endDate.add(adjustDays, "hours") : endDate.subtract(Math.abs(adjustDays), "hours")) : endDate).format("x"),
- fullDayEvent: CalendarUtils.isFullDayEvent(event),
- recurringEvent: true,
- class: event.class,
- firstYear: event.start.getFullYear(),
- location: location,
- geo: geo,
- description: description
- });
- }
- }
- // End recurring event parsing.
- } else {
- // Single event.
- const fullDayEvent = isFacebookBirthday ? true : CalendarUtils.isFullDayEvent(event);
- // Log.debug("full day event")
-
- if (config.includePastEvents) {
- // Past event is too far in the past, so skip.
- if (endDate < past) {
- return;
- }
- } else {
- // It's not a fullday event, and it is in the past, so skip.
- if (!fullDayEvent && endDate < new Date()) {
- return;
- }
-
- // It's a fullday event, and it is before today, So skip.
- if (fullDayEvent && endDate <= today) {
- return;
- }
- }
-
- // It exceeds the maximumNumberOfDays limit, so skip.
- if (startDate > future) {
- return;
- }
-
- if (CalendarUtils.timeFilterApplies(now, endDate, dateFilter)) {
- return;
- }
-
- // Adjust start date so multiple day events will be displayed as happening today even though they started some days ago already
- if (fullDayEvent && startDate <= today) {
- startDate = moment(today);
- }
- // if the start and end are the same, then make end the 'end of day' value (start is at 00:00:00)
- if (fullDayEvent && startDate.format("x") === endDate.format("x")) {
- endDate = endDate.endOf("day");
- }
- // get correction for date saving and dst change between now and then
- let adjustDays = CalendarUtils.calculateTimezoneAdjustment(event, startDate.toDate());
- // Every thing is good. Add it to the list.
- newEvents.push({
- title: title,
- startDate: (adjustDays ? (adjustDays > 0 ? startDate.add(adjustDays, "hours") : startDate.subtract(Math.abs(adjustDays), "hours")) : startDate).format("x"),
- endDate: (adjustDays ? (adjustDays > 0 ? endDate.add(adjustDays, "hours") : endDate.subtract(Math.abs(adjustDays), "hours")) : endDate).format("x"),
- fullDayEvent: fullDayEvent,
- class: event.class,
- location: location,
- geo: geo,
- description: description
- });
- }
- }
- });
-
- newEvents.sort(function (a, b) {
- return a.startDate - b.startDate;
- });
-
- // include up to maximumEntries current or upcoming events
- // If past events should be included, include all past events
- const now = moment();
- let entries = 0;
- let events = [];
- for (let ne of newEvents) {
- if (moment(ne.endDate, "x").isBefore(now)) {
- if (config.includePastEvents) events.push(ne);
- continue;
- }
- entries++;
- // If max events has been saved, skip the rest
- if (entries > config.maximumEntries) break;
- events.push(ne);
- }
-
- return events;
- },
-
- /**
- * Lookup iana tz from windows
- *
- * @param {string} msTZName the timezone name to lookup
- * @returns {string|null} the iana name or null of none is found
- */
- getIanaTZFromMS: function (msTZName) {
- // Get hash entry
- const he = zoneTable[msTZName];
- // If found return iana name, else null
- return he ? he.iana[0] : null;
- },
-
- /**
- * Gets the title from the event.
- *
- * @param {object} event The event object to check.
- * @returns {string} The title of the event, or "Event" if no title is found.
- */
- getTitleFromEvent: function (event) {
- let title = "Event";
- if (event.summary) {
- title = typeof event.summary.val !== "undefined" ? event.summary.val : event.summary;
- } else if (event.description) {
- title = event.description;
- }
-
- return title;
- },
-
- /**
- * Checks if an event is a fullday event.
- *
- * @param {object} event The event object to check.
- * @returns {boolean} True if the event is a fullday event, false otherwise
- */
- isFullDayEvent: function (event) {
- if (event.start.length === 8 || event.start.dateOnly || event.datetype === "date") {
- return true;
- }
-
- const start = event.start || 0;
- const startDate = new Date(start);
- const end = event.end || 0;
- if ((end - start) % (24 * 60 * 60 * 1000) === 0 && startDate.getHours() === 0 && startDate.getMinutes() === 0) {
- // Is 24 hours, and starts on the middle of the night.
- return true;
- }
-
- return false;
- },
-
- /**
- * Determines if the user defined time filter should apply
- *
- * @param {Date} now Date object using previously created object for consistency
- * @param {Moment} endDate Moment object representing the event end date
- * @param {string} filter The time to subtract from the end date to determine if an event should be shown
- * @returns {boolean} True if the event should be filtered out, false otherwise
- */
- timeFilterApplies: function (now, endDate, filter) {
- if (filter) {
- const until = filter.split(" "),
- value = parseInt(until[0]),
- increment = until[1].slice(-1) === "s" ? until[1] : until[1] + "s", // Massage the data for moment js
- filterUntil = moment(endDate.format()).subtract(value, increment);
-
- return now < filterUntil.format("x");
- }
-
- return false;
- },
-
- /**
- * Determines if the user defined title filter should apply
- *
- * @param {string} title the title of the event
- * @param {string} filter the string to look for, can be a regex also
- * @param {boolean} useRegex true if a regex should be used, otherwise it just looks for the filter as a string
- * @param {string} regexFlags flags that should be applied to the regex
- * @returns {boolean} True if the title should be filtered out, false otherwise
- */
- titleFilterApplies: function (title, filter, useRegex, regexFlags) {
- if (useRegex) {
- // Assume if leading slash, there is also trailing slash
- if (filter[0] === "/") {
- // Strip leading and trailing slashes
- filter = filter.substr(1).slice(0, -1);
- }
-
- filter = new RegExp(filter, regexFlags);
-
- return filter.test(title);
- } else {
- return title.includes(filter);
- }
- }
- };
-
- if (typeof module !== "undefined") {
- module.exports = CalendarUtils;
- }
|