Created
May 24, 2023 21:14
-
-
Save btastic/41f91fc4c688d05657cfc09274eb155a to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* Magic Mirror | |
* Module: MMM-GoogleCalendar | |
* | |
* adaptation of MM default calendar module for Google Calendar events | |
* MIT Licensed. | |
*/ | |
Module.register("MMM-GoogleCalendar", { | |
// Define module defaults | |
defaults: { | |
maximumEntries: 10, // Total Maximum Entries | |
maximumNumberOfDays: 365, | |
limitDays: 0, // Limit the number of days shown, 0 = no limit | |
displaySymbol: true, | |
defaultSymbol: "calendar", // Fontawesome Symbol see https://fontawesome.com/cheatsheet?from=io | |
showLocation: false, | |
displayRepeatingCountTitle: false, | |
defaultRepeatingCountTitle: "", | |
maxTitleLength: 25, | |
maxLocationTitleLength: 25, | |
wrapEvents: false, // wrap events to multiple lines breaking at maxTitleLength | |
wrapLocationEvents: false, | |
maxTitleLines: 3, | |
maxEventTitleLines: 3, | |
fetchInterval: 5 * 60 * 1000, // Update every 5 minutes. | |
animationSpeed: 2000, | |
fade: true, | |
urgency: 7, | |
timeFormat: "relative", | |
dateFormat: "MMM Do", | |
dateEndFormat: "LT", | |
fullDayEventDateFormat: "MMM Do", | |
showEnd: false, | |
getRelative: 6, | |
fadePoint: 0.25, // Start on 1/4th of the list. | |
hidePrivate: false, | |
hideOngoing: false, | |
hideTime: false, | |
colored: false, | |
coloredSymbolOnly: false, | |
customEvents: [], // Array of {keyword: "", symbol: "", color: ""} where Keyword is a regexp and symbol/color are to be applied for matched | |
tableClass: "small", | |
pastDays: 5, | |
calendars: [ | |
{ | |
symbol: "calendar", | |
url: "https://www.calendarlabs.com/templates/ical/US-Holidays.ics" | |
} | |
], | |
titleReplace: { | |
"De verjaardag van ": "", | |
"'s birthday": "" | |
}, | |
locationTitleReplace: { | |
"street ": "" | |
}, | |
broadcastEvents: false, | |
excludedEvents: [], | |
sliceMultiDayEvents: false, | |
nextDaysRelative: false | |
}, | |
requiresVersion: "2.1.0", | |
// Define required scripts. | |
getStyles: function () { | |
return ["calendar.css", "font-awesome.css"]; | |
}, | |
// Define required scripts. | |
getScripts: function () { | |
return ["moment.js"]; | |
}, | |
// Define required translations. | |
getTranslations: function () { | |
return { | |
en: "translations/en.json" | |
}; | |
}, | |
// Override start method. | |
start: function () { | |
Log.info("Starting module: " + this.name); | |
// Set locale. | |
moment.updateLocale( | |
config.language, | |
this.getLocaleSpecification(config.timeFormat) | |
); | |
// clear data holder before start | |
this.calendarData = {}; | |
// indicate no data available yet | |
this.loaded = false; | |
// check if current URL is module's auth url | |
if (location.search.includes(this.name)) { | |
this.sendSocketNotification("MODULE_READY", { | |
queryParams: location.search | |
}); | |
} else { | |
// check user token is authenticated. | |
this.sendSocketNotification("MODULE_READY"); | |
} | |
}, | |
// Override socket notification handler. | |
socketNotificationReceived: function (notification, payload) { | |
// Authentication done before any calendar is fetched | |
if (notification === "AUTH_FAILED") { | |
let error_message = this.translate(payload.error_type); | |
this.error = this.translate("MODULE_CONFIG_ERROR", { | |
MODULE_NAME: this.name, | |
ERROR: error_message | |
}); | |
this.loaded = true; | |
this.updateDom(this.config.animationSpeed); | |
return; | |
} | |
if (notification === "AUTH_NEEDED") { | |
this.error = "ERROR_AUTH_NEEDED"; | |
if (payload.credentialType === "web") { | |
this.errorUrl = payload.url; | |
} | |
this.updateDom(this.config.animationSpeed); | |
return; | |
} else { | |
// reset error URL | |
this.errorUrl = null; | |
} | |
if (notification === "SERVICE_READY") { | |
// start fetching calendars | |
this.fetchCalendars(); | |
} | |
if (this.identifier !== payload.id) { | |
return; | |
} | |
if (notification === "CALENDAR_EVENTS") { | |
if (this.hasCalendarID(payload.calendarID)) { | |
this.calendarData[payload.calendarID] = payload.events; | |
this.error = null; | |
this.loaded = true; | |
if (this.config.broadcastEvents) { | |
this.broadcastEvents(); | |
} | |
} | |
} else if (notification === "CALENDAR_ERROR") { | |
let error_message = this.translate(payload.error_type); | |
this.error = this.translate("MODULE_CONFIG_ERROR", { | |
MODULE_NAME: this.name, | |
ERROR: error_message | |
}); | |
this.loaded = true; | |
} | |
this.updateDom(this.config.animationSpeed); | |
}, | |
// Override dom generator. | |
getDom: function () { | |
// Define second, minute, hour, and day constants | |
const oneSecond = 1000; // 1,000 milliseconds | |
const oneMinute = oneSecond * 60; | |
const oneHour = oneMinute * 60; | |
const oneDay = oneHour * 24; | |
const events = this.createEventList(); | |
const wrapper = document.createElement("table"); | |
wrapper.className = this.config.tableClass; | |
if (this.error) { | |
// web credentials will have a WEB url | |
if (this.error === "ERROR_AUTH_NEEDED" && this.errorUrl) { | |
wrapper.innerHTML = `Please <a href=${this.errorUrl}>click here</a> to authorize this module.`; | |
} else { | |
// default to generic error | |
wrapper.innerHTML = this.error; | |
wrapper.className = this.config.tableClass + " dimmed"; | |
} | |
return wrapper; | |
} | |
if (events.length === 0) { | |
wrapper.innerHTML = this.loaded | |
? this.translate("EMPTY") | |
: this.translate("LOADING"); | |
wrapper.className = this.config.tableClass + " dimmed"; | |
return wrapper; | |
} | |
let currentFadeStep = 0; | |
let startFade; | |
let fadeSteps; | |
if (this.config.fade && this.config.fadePoint < 1) { | |
if (this.config.fadePoint < 0) { | |
this.config.fadePoint = 0; | |
} | |
startFade = events.length * this.config.fadePoint; | |
fadeSteps = events.length - startFade; | |
} | |
let lastSeenDate = ""; | |
events.forEach((event, index) => { | |
const dateAsString = moment(event.startDate).format( | |
this.config.dateFormat | |
); | |
if (this.config.timeFormat === "dateheaders") { | |
if (lastSeenDate !== dateAsString) { | |
const dateRow = document.createElement("tr"); | |
dateRow.className = "normal"; | |
const dateCell = document.createElement("td"); | |
dateCell.colSpan = "3"; | |
dateCell.innerHTML = dateAsString; | |
dateCell.style.paddingTop = "10px"; | |
dateRow.appendChild(dateCell); | |
wrapper.appendChild(dateRow); | |
if (this.config.fade && index >= startFade) { | |
//fading | |
currentFadeStep = index - startFade; | |
dateRow.style.opacity = 1 - (1 / fadeSteps) * currentFadeStep; | |
} | |
lastSeenDate = dateAsString; | |
} | |
} | |
const eventWrapper = document.createElement("tr"); | |
if (this.config.colored && !this.config.coloredSymbolOnly) { | |
eventWrapper.style.cssText = | |
"color:" + this.colorForCalendar(event.calendarID); | |
} | |
eventWrapper.className = "normal event"; | |
const symbolWrapper = document.createElement("td"); | |
if (this.config.displaySymbol) { | |
if (this.config.colored && this.config.coloredSymbolOnly) { | |
symbolWrapper.style.cssText = | |
"color:" + this.colorForCalendar(event.calendarID); | |
} | |
const symbolClass = this.symbolClassForCalendar(event.calendarID); | |
symbolWrapper.className = "symbol align-right " + symbolClass; | |
const symbols = this.symbolsForEvent(event); | |
// If symbols are displayed and custom symbol is set, replace event symbol | |
if (this.config.displaySymbol && this.config.customEvents.length > 0) { | |
for (let ev in this.config.customEvents) { | |
if ( | |
typeof this.config.customEvents[ev].symbol !== "undefined" && | |
this.config.customEvents[ev].symbol !== "" | |
) { | |
let needle = new RegExp( | |
this.config.customEvents[ev].keyword, | |
"gi" | |
); | |
if (needle.test(event.title)) { | |
symbols[0] = this.config.customEvents[ev].symbol; | |
break; | |
} | |
} | |
} | |
} | |
symbols.forEach((s, index) => { | |
const symbol = document.createElement("span"); | |
symbol.className = "fa fa-fw fa-" + s; | |
if (index > 0) { | |
symbol.style.paddingLeft = "5px"; | |
} | |
symbolWrapper.appendChild(symbol); | |
}); | |
eventWrapper.appendChild(symbolWrapper); | |
} else if (this.config.timeFormat === "dateheaders") { | |
const blankCell = document.createElement("td"); | |
blankCell.innerHTML = " "; | |
eventWrapper.appendChild(blankCell); | |
} | |
const titleWrapper = document.createElement("td"); | |
let repeatingCountTitle = ""; | |
if ( | |
this.config.displayRepeatingCountTitle && | |
event.firstYear !== undefined | |
) { | |
repeatingCountTitle = this.countTitleForCalendar(event.calendarID); | |
if (repeatingCountTitle !== "") { | |
const thisYear = new Date(parseInt(event.startDate)).getFullYear(), | |
yearDiff = thisYear - event.firstYear; | |
repeatingCountTitle = ", " + yearDiff + ". " + repeatingCountTitle; | |
} | |
} | |
// Color events if custom color is specified | |
if (this.config.customEvents.length > 0) { | |
for (let ev in this.config.customEvents) { | |
if ( | |
typeof this.config.customEvents[ev].color !== "undefined" && | |
this.config.customEvents[ev].color !== "" | |
) { | |
let needle = new RegExp(this.config.customEvents[ev].keyword, "gi"); | |
if (needle.test(event.title)) { | |
// Respect parameter ColoredSymbolOnly also for custom events | |
if (!this.config.coloredSymbolOnly) { | |
eventWrapper.style.cssText = | |
"color:" + this.config.customEvents[ev].color; | |
titleWrapper.style.cssText = | |
"color:" + this.config.customEvents[ev].color; | |
} | |
if (this.config.displaySymbol) { | |
symbolWrapper.style.cssText = | |
"color:" + this.config.customEvents[ev].color; | |
} | |
break; | |
} | |
} | |
} | |
} | |
titleWrapper.innerHTML = | |
this.titleTransform( | |
event.title, | |
this.config.titleReplace, | |
this.config.wrapEvents, | |
this.config.maxTitleLength, | |
this.config.maxTitleLines | |
) + repeatingCountTitle; | |
const titleClass = this.titleClassForCalendar(event.calendarID); | |
if (!this.config.colored) { | |
titleWrapper.className = "title bright " + titleClass; | |
} else { | |
titleWrapper.className = "title " + titleClass; | |
} | |
if (this.config.timeFormat === "dateheaders") { | |
if (event.fullDayEvent) { | |
titleWrapper.colSpan = "2"; | |
titleWrapper.classList.add("align-left"); | |
} else { | |
const timeWrapper = document.createElement("td"); | |
timeWrapper.className = | |
"time light align-left " + | |
this.timeClassForCalendar(event.calendarID); | |
timeWrapper.style.paddingLeft = "2px"; | |
timeWrapper.innerHTML = moment(event.startDate).format("LT"); | |
// Add endDate to dataheaders if showEnd is enabled | |
if (this.config.showEnd) { | |
timeWrapper.innerHTML += ` - ${this.capFirst(moment(event.endDate, "x").format("LT"))}`; | |
} | |
eventWrapper.appendChild(timeWrapper); | |
titleWrapper.classList.add("align-right"); | |
} | |
eventWrapper.appendChild(titleWrapper); | |
} else { | |
const timeWrapper = document.createElement("td"); | |
eventWrapper.appendChild(titleWrapper); | |
const now = new Date(); | |
if (this.config.timeFormat === "absolute") { | |
// Use dateFormat | |
timeWrapper.innerHTML = this.capFirst( | |
moment(event.startDate).format(this.config.dateFormat) | |
); | |
// Add end time if showEnd | |
if (this.config.showEnd) { | |
timeWrapper.innerHTML += "-"; | |
timeWrapper.innerHTML += this.capFirst( | |
moment(event.endDate).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 -= oneSecond; | |
timeWrapper.innerHTML = this.capFirst( | |
moment(event.startDate).format(this.config.fullDayEventDateFormat) | |
); | |
} | |
if (this.config.getRelative > 0 && event.startDate < now) { | |
// Ongoing and getRelative is set | |
timeWrapper.innerHTML = this.capFirst( | |
this.translate("RUNNING", { | |
fallback: this.translate("RUNNING") + " {timeUntilEnd}", | |
timeUntilEnd: moment(event.endDate).fromNow(true) | |
}) | |
); | |
} else if ( | |
this.config.urgency > 0 && | |
event.startDate - now < this.config.urgency * oneDay | |
) { | |
// Within urgency days | |
timeWrapper.innerHTML = this.capFirst( | |
moment(event.startDate).fromNow() | |
); | |
} | |
if (event.fullDayEvent && this.config.nextDaysRelative) { | |
// Full days events within the next two days | |
if (event.today) { | |
timeWrapper.innerHTML = this.capFirst(this.translate("TODAY")); | |
} else if ( | |
event.startDate - now < oneDay && | |
event.startDate - now > 0 | |
) { | |
timeWrapper.innerHTML = this.capFirst(this.translate("TOMORROW")); | |
} else if ( | |
event.startDate - now < 2 * oneDay && | |
event.startDate - now > 0 | |
) { | |
if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") { | |
timeWrapper.innerHTML = this.capFirst( | |
this.translate("DAYAFTERTOMORROW") | |
); | |
} | |
} | |
} | |
} else { | |
// Show relative times | |
if (event.startDate >= now) { | |
// Use relative time | |
if (!this.config.hideTime) { | |
timeWrapper.innerHTML = this.capFirst( | |
moment(event.startDate).calendar(null, { | |
sameElse: this.config.dateFormat | |
}) | |
); | |
} else { | |
timeWrapper.innerHTML = this.capFirst( | |
moment(event.startDate).calendar(null, { | |
sameDay: "[" + this.translate("TODAY") + "]", | |
nextDay: "[" + this.translate("TOMORROW") + "]", | |
nextWeek: "dddd", | |
sameElse: this.config.dateFormat | |
}) | |
); | |
} | |
if (event.startDate - now < this.config.getRelative * oneHour) { | |
// If event is within getRelative hours, display 'in xxx' time format or moment.fromNow() | |
timeWrapper.innerHTML = this.capFirst( | |
moment(event.startDate).fromNow() | |
); | |
} | |
} else { | |
// Ongoing event | |
timeWrapper.innerHTML = this.capFirst( | |
this.translate("RUNNING", { | |
fallback: this.translate("RUNNING") + " {timeUntilEnd}", | |
timeUntilEnd: moment(event.endDate).fromNow(true) | |
}) | |
); | |
} | |
} | |
timeWrapper.className = | |
"time light " + this.timeClassForCalendar(event.calendarID); | |
eventWrapper.appendChild(timeWrapper); | |
} | |
wrapper.appendChild(eventWrapper); | |
// Create fade effect. | |
if (index >= startFade) { | |
currentFadeStep = index - startFade; | |
eventWrapper.style.opacity = 1 - (1 / fadeSteps) * currentFadeStep; | |
} | |
if (this.config.showLocation) { | |
if (event.location) { | |
const locationRow = document.createElement("tr"); | |
locationRow.className = "normal xsmall light"; | |
if (this.config.displaySymbol) { | |
const symbolCell = document.createElement("td"); | |
locationRow.appendChild(symbolCell); | |
} | |
const descCell = document.createElement("td"); | |
descCell.className = "location"; | |
descCell.colSpan = "2"; | |
descCell.innerHTML = this.titleTransform( | |
event.location, | |
this.config.locationTitleReplace, | |
this.config.wrapLocationEvents, | |
this.config.maxLocationTitleLength, | |
this.config.maxEventTitleLines | |
); | |
locationRow.appendChild(descCell); | |
wrapper.appendChild(locationRow); | |
if (index >= startFade) { | |
currentFadeStep = index - startFade; | |
locationRow.style.opacity = 1 - (1 / fadeSteps) * currentFadeStep; | |
} | |
} | |
} | |
}); | |
return wrapper; | |
}, | |
fetchCalendars: function () { | |
this.config.calendars.forEach((calendar) => { | |
if (!calendar.calendarID) { | |
Log.warn(this.name + ": Unable to fetch, no calendar ID found!"); | |
return; | |
} | |
const calendarConfig = { | |
maximumEntries: calendar.maximumEntries, | |
maximumNumberOfDays: calendar.maximumNumberOfDays | |
}; | |
if ( | |
calendar.symbolClass === "undefined" || | |
calendar.symbolClass === null | |
) { | |
calendarConfig.symbolClass = ""; | |
} | |
if (calendar.titleClass === "undefined" || calendar.titleClass === null) { | |
calendarConfig.titleClass = ""; | |
} | |
if (calendar.timeClass === "undefined" || calendar.timeClass === null) { | |
calendarConfig.timeClass = ""; | |
} | |
// tell helper to start a fetcher for this calendar | |
// fetcher till cycle | |
this.addCalendar(calendar.calendarID, calendarConfig); | |
}); | |
}, | |
/** | |
* This function accepts a number (either 12 or 24) and returns a moment.js LocaleSpecification with the | |
* corresponding timeformat to be used in the calendar display. If no number is given (or otherwise invalid input) | |
* it will a localeSpecification object with the system locale time format. | |
* | |
* @param {number} timeFormat Specifies either 12 or 24 hour time format | |
* @returns {moment.LocaleSpecification} formatted time | |
*/ | |
getLocaleSpecification: function (timeFormat) { | |
switch (timeFormat) { | |
case 12: { | |
return { longDateFormat: { LT: "h:mm A" } }; | |
} | |
case 24: { | |
return { longDateFormat: { LT: "HH:mm" } }; | |
} | |
default: { | |
return { | |
longDateFormat: { LT: moment.localeData().longDateFormat("LT") } | |
}; | |
} | |
} | |
}, | |
/** | |
* Checks if this config contains the calendar ID. | |
* | |
* @param {string} ID The calendar ID | |
* @returns {boolean} True if the calendar config contains the ID, False otherwise | |
*/ | |
hasCalendarID: function (ID) { | |
for (const calendar of this.config.calendars) { | |
if (calendar.calendarID === ID) { | |
return true; | |
} | |
} | |
return false; | |
}, | |
/** | |
* Parse google date obj | |
* @param {*} googleDate | |
* @returns timestamp | |
*/ | |
extractCalendarDate: function (googleDate) { | |
// case is "all day event" | |
if (googleDate.hasOwnProperty("date")) { | |
return moment(googleDate.date).valueOf(); | |
} | |
return moment(googleDate.dateTime).valueOf(); | |
}, | |
/** | |
* Creates the sorted list of all events. | |
* | |
* @returns {object[]} Array with events. | |
*/ | |
createEventList: function () { | |
const now = new Date(); | |
const today = moment().startOf("day"); | |
const future = moment() | |
.startOf("day") | |
.add(this.config.maximumNumberOfDays, "days") | |
.toDate(); | |
let events = []; | |
const formatStr = undefined; | |
for (const calendarID in this.calendarData) { | |
const calendar = this.calendarData[calendarID]; | |
for (const e in calendar) { | |
const event = JSON.parse(JSON.stringify(calendar[e])); // clone object | |
// added props | |
event.calendarID = calendarID; | |
event.endDate = this.extractCalendarDate(event.end); | |
event.startDate = this.extractCalendarDate(event.start); | |
if (event.endDate < now) { | |
continue; | |
} | |
if (this.config.hidePrivate) { | |
if (event.visibility === "PRIVATE") { | |
// do not add the current event, skip it | |
continue; | |
} | |
} | |
if (this.config.hideOngoing) { | |
if (event.endDate < now) { | |
continue; | |
} | |
} | |
if (this.listContainsEvent(events, event)) { | |
continue; | |
} | |
event.url = event.htmlLink; | |
event.today = | |
event.startDate >= today && | |
event.startDate < today + 24 * 60 * 60 * 1000; | |
event.title = event.summary; | |
/* 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.ceil( | |
(event.endDate - | |
1 - | |
moment(event.startDate, formatStr) | |
.endOf("day") | |
.format(formatStr)) / | |
(1000 * 60 * 60 * 24) | |
) + 1; | |
if (this.config.sliceMultiDayEvents && maxCount > 1) { | |
const splitEvents = []; | |
let midnight = moment(event.startDate, formatStr) | |
.clone() | |
.startOf("day") | |
.add(1, "day") | |
.format(formatStr); | |
let count = 1; | |
while (event.endDate > midnight) { | |
const thisEvent = JSON.parse(JSON.stringify(event)); // clone object | |
thisEvent.today = | |
thisEvent.startDate >= today && | |
thisEvent.startDate < today + 24 * 60 * 60 * 1000; | |
thisEvent.endDate = midnight; | |
thisEvent.title += " (" + count + "/" + maxCount + ")"; | |
splitEvents.push(thisEvent); | |
event.startDate = midnight; | |
count += 1; | |
midnight = moment(midnight, formatStr) | |
.add(1, "day") | |
.format(formatStr); // next day | |
} | |
// Last day | |
event.title += " (" + count + "/" + maxCount + ")"; | |
splitEvents.push(event); | |
for (let splitEvent of splitEvents) { | |
if (splitEvent.end > now && splitEvent.end <= future) { | |
events.push(splitEvent); | |
} | |
} | |
} else { | |
events.push(event); | |
} | |
} | |
} | |
events.sort(function (a, b) { | |
return a.startDate - b.startDate; | |
}); | |
// Limit the number of days displayed | |
// If limitDays is set > 0, limit display to that number of days | |
if (this.config.limitDays > 0) { | |
let newEvents = []; | |
let lastDate = today.clone().subtract(1, "days").format("YYYYMMDD"); | |
let days = 0; | |
for (const ev of events) { | |
let eventDate = moment(ev.startDate, formatStr).format("YYYYMMDD"); | |
// if date of event is later than lastdate | |
// check if we already are showing max unique days | |
if (eventDate > lastDate) { | |
// if the only entry in the first day is a full day event that day is not counted as unique | |
if ( | |
newEvents.length === 1 && | |
days === 1 && | |
newEvents[0].fullDayEvent | |
) { | |
days--; | |
} | |
days++; | |
if (days > this.config.limitDays) { | |
continue; | |
} else { | |
lastDate = eventDate; | |
} | |
} | |
newEvents.push(ev); | |
} | |
events = newEvents; | |
} | |
return events.slice(0, this.config.maximumEntries); | |
}, | |
listContainsEvent: function (eventList, event) { | |
for (const evt of eventList) { | |
if ( | |
evt.summary === event.summary && | |
parseInt(evt.startDate) === parseInt(event.startDate) | |
) { | |
return true; | |
} | |
} | |
return false; | |
}, | |
/** | |
* Requests node helper to add calendar ID | |
* | |
* @param {string} calendarID string | |
* @param {object} calendarConfig The config of the specific calendar | |
*/ | |
addCalendar: function (calendarID, calendarConfig) { | |
this.sendSocketNotification("ADD_CALENDAR", { | |
id: this.identifier, | |
calendarID, | |
excludedEvents: | |
calendarConfig.excludedEvents || this.config.excludedEvents, | |
maximumEntries: | |
calendarConfig.maximumEntries || this.config.maximumEntries, | |
maximumNumberOfDays: | |
calendarConfig.maximumNumberOfDays || this.config.maximumNumberOfDays, | |
fetchInterval: this.config.fetchInterval, | |
symbolClass: calendarConfig.symbolClass, | |
titleClass: calendarConfig.titleClass, | |
timeClass: calendarConfig.timeClass, | |
pastDays: this.config.pastDays | |
}); | |
}, | |
/** | |
* Retrieves the symbols for a specific event. | |
* | |
* @param {object} event Event to look for. | |
* @returns {string[]} The symbols | |
*/ | |
symbolsForEvent: function (event) { | |
let symbols = this.getCalendarPropertyAsArray( | |
event.calendarID, | |
"symbol", | |
this.config.defaultSymbol | |
); | |
if ( | |
event.recurringEvent === true && | |
this.hasCalendarProperty(event.calendarID, "recurringSymbol") | |
) { | |
symbols = this.mergeUnique( | |
this.getCalendarPropertyAsArray( | |
event.calendarID, | |
"recurringSymbol", | |
this.config.defaultSymbol | |
), | |
symbols | |
); | |
} | |
if ( | |
event.fullDayEvent === true && | |
this.hasCalendarProperty(event.calendarID, "fullDaySymbol") | |
) { | |
symbols = this.mergeUnique( | |
this.getCalendarPropertyAsArray( | |
event.calendarID, | |
"fullDaySymbol", | |
this.config.defaultSymbol | |
), | |
symbols | |
); | |
} | |
return symbols; | |
}, | |
mergeUnique: function (arr1, arr2) { | |
return arr1.concat( | |
arr2.filter(function (item) { | |
return arr1.indexOf(item) === -1; | |
}) | |
); | |
}, | |
/** | |
* Retrieves the symbolClass for a specific calendar ID. | |
* | |
* @param {string} calendarID The calendar ID | |
* @returns {string} The class to be used for the symbols of the calendar | |
*/ | |
symbolClassForCalendar: function (calendarID) { | |
return this.getCalendarProperty(calendarID, "symbolClass", ""); | |
}, | |
/** | |
* Retrieves the titleClass for a specific calendar ID. | |
* | |
* @param {string} calendarID The calendar ID | |
* @returns {string} The class to be used for the title of the calendar | |
*/ | |
titleClassForCalendar: function (calendarID) { | |
return this.getCalendarProperty(calendarID, "titleClass", ""); | |
}, | |
/** | |
* Retrieves the timeClass for a specific calendar ID. | |
* | |
* @param {string} calendarID The calendar ID | |
* @returns {string} The class to be used for the time of the calendar | |
*/ | |
timeClassForCalendar: function (calendarID) { | |
return this.getCalendarProperty(calendarID, "timeClass", ""); | |
}, | |
/** | |
* Retrieves the calendar name for a specific calendar ID. | |
* | |
* @param {string} calendarID The calendar ID | |
* @returns {string} The name of the calendar | |
*/ | |
calendarNameForCalendar: function (calendarID) { | |
return this.getCalendarProperty(calendarID, "name", ""); | |
}, | |
/** | |
* Retrieves the color for a specific calendar ID. | |
* | |
* @param {string} calendarID The calendar ID | |
* @returns {string} The color | |
*/ | |
colorForCalendar: function (calendarID) { | |
return this.getCalendarProperty(calendarID, "color", "#fff"); | |
}, | |
/** | |
* Retrieves the count title for a specific calendar ID. | |
* | |
* @param {string} calendarID The calendar ID | |
* @returns {string} The title | |
*/ | |
countTitleForCalendar: function (calendarID) { | |
return this.getCalendarProperty( | |
calendarID, | |
"repeatingCountTitle", | |
this.config.defaultRepeatingCountTitle | |
); | |
}, | |
/** | |
* Helper method to retrieve the property for a specific calendar ID. | |
* | |
* @param {string} calendarID The calendar ID | |
* @param {string} property The property to look for | |
* @param {string} defaultValue The value if the property is not found | |
* @returns {*} The property | |
*/ | |
getCalendarProperty: function (calendarID, property, defaultValue) { | |
for (const calendar of this.config.calendars) { | |
if ( | |
calendar.calendarID === calendarID && | |
calendar.hasOwnProperty(property) | |
) { | |
return calendar[property]; | |
} | |
} | |
return defaultValue; | |
}, | |
getCalendarPropertyAsArray: function (calendarID, property, defaultValue) { | |
let p = this.getCalendarProperty(calendarID, property, defaultValue); | |
if (!(p instanceof Array)) p = [p]; | |
return p; | |
}, | |
hasCalendarProperty: function (calendarID, property) { | |
return !!this.getCalendarProperty(calendarID, property, undefined); | |
}, | |
/** | |
* Shortens a string if it's longer than maxLength and add a ellipsis to the end | |
* | |
* @param {string} string Text string to shorten | |
* @param {number} maxLength The max length of the string | |
* @param {boolean} wrapEvents Wrap the text after the line has reached maxLength | |
* @param {number} maxTitleLines The max number of vertical lines before cutting event title | |
* @returns {string} The shortened string | |
*/ | |
shorten: function (string, maxLength, wrapEvents, maxTitleLines) { | |
if (typeof string !== "string") { | |
return ""; | |
} | |
if (wrapEvents === true) { | |
const words = string.split(" "); | |
let temp = ""; | |
let currentLine = ""; | |
let line = 0; | |
for (let i = 0; i < words.length; i++) { | |
const word = words[i]; | |
if ( | |
currentLine.length + word.length < | |
(typeof maxLength === "number" ? maxLength : 25) - 1 | |
) { | |
// max - 1 to account for a space | |
currentLine += word + " "; | |
} else { | |
line++; | |
if (line > maxTitleLines - 1) { | |
if (i < words.length) { | |
currentLine += "…"; | |
} | |
break; | |
} | |
if (currentLine.length > 0) { | |
temp += currentLine + "<br>" + word + " "; | |
} else { | |
temp += word + "<br>"; | |
} | |
currentLine = ""; | |
} | |
} | |
return (temp + currentLine).trim(); | |
} else { | |
if ( | |
maxLength && | |
typeof maxLength === "number" && | |
string.length > maxLength | |
) { | |
return string.trim().slice(0, maxLength) + "…"; | |
} else { | |
return string.trim(); | |
} | |
} | |
}, | |
/** | |
* Capitalize the first letter of a string | |
* | |
* @param {string} string The string to capitalize | |
* @returns {string} The capitalized string | |
*/ | |
capFirst: function (string) { | |
return string.charAt(0).toUpperCase() + string.slice(1); | |
}, | |
/** | |
* Transforms the title of an event for usage. | |
* Replaces parts of the text as defined in config.titleReplace. | |
* Shortens title based on config.maxTitleLength and config.wrapEvents | |
* | |
* @param {string} title The title to transform. | |
* @param {object} titleReplace Pairs of strings to be replaced in the title | |
* @param {boolean} wrapEvents Wrap the text after the line has reached maxLength | |
* @param {number} maxTitleLength The max length of the string | |
* @param {number} maxTitleLines The max number of vertical lines before cutting event title | |
* @returns {string} The transformed title. | |
*/ | |
titleTransform: function ( | |
title, | |
titleReplace, | |
wrapEvents, | |
maxTitleLength, | |
maxTitleLines | |
) { | |
for (let needle in titleReplace) { | |
const replacement = titleReplace[needle]; | |
const regParts = needle.match(/^\/(.+)\/([gim]*)$/); | |
if (regParts) { | |
// the parsed pattern is a regexp. | |
needle = new RegExp(regParts[1], regParts[2]); | |
} | |
title = title.replace(needle, replacement); | |
} | |
title = this.shorten(title, maxTitleLength, wrapEvents, maxTitleLines); | |
return title; | |
}, | |
/** | |
* Broadcasts the events to all other modules for reuse. | |
* The all events available in one array, sorted on startDate. | |
*/ | |
broadcastEvents: function () { | |
const eventList = []; | |
for (const calendarID in this.calendarData) { | |
for (const ev of this.calendarData[calendarID]) { | |
const event = Object.assign({}, ev); | |
event.calendarID = calendarID; | |
event.symbol = this.symbolsForEvent(event); | |
event.calendarName = this.calendarNameForCalendar(calendarID); | |
event.color = this.colorForCalendar(calendarID); | |
delete event.calendarID; | |
eventList.push(event); | |
} | |
} | |
eventList.sort(function (a, b) { | |
return a.startDate - b.startDate; | |
}); | |
this.sendNotification("CALENDAR_EVENTS", eventList); | |
} | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const NodeHelper = require("node_helper"); | |
const { google } = require("googleapis"); | |
const { encodeQueryData } = require("./helpers"); | |
const fs = require("fs"); | |
const Log = require("logger"); | |
const TOKEN_PATH = "/token.json"; | |
module.exports = NodeHelper.create({ | |
// Override start method. | |
start: function () { | |
Log.log("Starting node helper for: " + this.name); | |
this.fetchers = []; | |
this.isHelperActive = true; | |
this.calendarService; | |
}, | |
stop: function () { | |
this.isHelperActive = false; | |
}, | |
// Override socketNotificationReceived method. | |
socketNotificationReceived: function (notification, payload) { | |
if (notification === "MODULE_READY") { | |
if (!this.calendarService) { | |
if (payload.queryParams) { | |
// if payload is sent, user has authenticated | |
const params = new URLSearchParams(payload.queryParams); | |
this.authenticateWithQueryParams(params); | |
} else { | |
this.authenticate(); | |
} | |
} else { | |
this.sendSocketNotification("SERVICE_READY", {}); | |
} | |
} | |
if (notification === "ADD_CALENDAR") { | |
this.fetchCalendar( | |
payload.calendarID, | |
payload.fetchInterval, | |
payload.maximumEntries, | |
payload.id, | |
payload.pastDays | |
); | |
} | |
}, | |
authenticateWithQueryParams: function (params) { | |
const error = params.get("error"); | |
if (error) { | |
this.sendSocketNotification("AUTH_FAILED", { error_type: error }); | |
return; | |
} | |
var _this = this; | |
const code = params.get("code"); | |
fs.readFile(_this.path + "/credentials.json", (err, content) => { | |
if (err) { | |
_this.sendSocketNotification("AUTH_FAILED", { error_type: err }); | |
return console.log("Error loading client secret file:", err); | |
} | |
// Authorize a client with credentials, then call the Google Tasks API. | |
_this.authenticateWeb( | |
_this, | |
code, | |
JSON.parse(content), | |
_this.startCalendarService | |
); | |
}); | |
}, | |
// replaces the old authenticate method | |
authenticateWeb: function (_this, code, credentials, callback) { | |
const { client_secret, client_id, redirect_uris } = credentials.web; | |
if (!client_secret || !client_id) { | |
_this.sendSocketNotification("AUTH_FAILED", { | |
error_type: "WRONG_CREDENTIALS_FORMAT" | |
}); | |
return; | |
} | |
_this.oAuth2Client = new google.auth.OAuth2( | |
client_id, | |
client_secret, | |
redirect_uris ? redirect_uris[0] : "http://localhost:8080" | |
); | |
_this.oAuth2Client.getToken(code, (err, token) => { | |
if (err) return console.error("Error retrieving access token", err); | |
_this.oAuth2Client.setCredentials(token); | |
// Store the token to disk for later program executions | |
fs.writeFile(_this.path + TOKEN_PATH, JSON.stringify(token), (err) => { | |
if (err) return console.error(err); | |
console.log("Token stored to", _this.path + TOKEN_PATH); | |
}); | |
callback(_this.oAuth2Client, _this); | |
}); | |
}, | |
// Authenticate oAuth credentials | |
authenticate: function () { | |
var _this = this; | |
fs.readFile(_this.path + "/credentials.json", (err, content) => { | |
if (err) { | |
_this.sendSocketNotification("AUTH_FAILED", { error_type: err }); | |
return console.log("Error loading client secret file:", err); | |
} | |
// Authorize a client with credentials, then call the Google Tasks API. | |
authorize(JSON.parse(content), _this.startCalendarService); | |
}); | |
/** | |
* Create an OAuth2 client with the given credentials, and then execute the | |
* given callback function. | |
* @param {Object} credentials The authorization client credentials. | |
* @param {function} callback The callback to call with the authorized client. | |
*/ | |
function authorize(credentials, callback) { | |
var creds; | |
var credentialType; | |
// TVs and Limited Input devices credentials | |
if (credentials.installed) { | |
creds = credentials.installed; | |
credentialType = "tv"; | |
} | |
// Web credentials (fallback) | |
if (credentials.web) { | |
creds = credentials.web; | |
credentialType = "web"; | |
} | |
const { client_secret, client_id, redirect_uris } = creds; | |
if (!client_secret || !client_id) { | |
_this.sendSocketNotification("AUTH_FAILED", { | |
error_type: "WRONG_CREDENTIALS_FORMAT" | |
}); | |
return; | |
} | |
_this.oAuth2Client = new google.auth.OAuth2( | |
client_id, | |
client_secret, | |
redirect_uris ? redirect_uris[0] : "http://localhost:8080" | |
); | |
// Check if we have previously stored a token. | |
fs.readFile(_this.path + TOKEN_PATH, (err, token) => { | |
if (err) { | |
const redirect_uri = redirect_uris | |
? redirect_uris[0] | |
: `http://localhost:8080`; | |
// alert auth is needed | |
_this.sendSocketNotification("AUTH_NEEDED", { | |
url: `https://accounts.google.com/o/oauth2/v2/auth?${encodeQueryData( | |
{ | |
scope: "https://www.googleapis.com/auth/calendar.readonly", | |
access_type: "offline", | |
include_granted_scopes: true, | |
response_type: "code", | |
state: _this.name, | |
redirect_uri, | |
client_id | |
} | |
)}`, // only used for web credential | |
credentialType | |
}); | |
return console.log( | |
this.name + ": Error loading token:", | |
err, | |
"Make sure you have authorized the app." | |
); | |
} | |
_this.oAuth2Client.setCredentials(JSON.parse(token)); | |
callback(_this.oAuth2Client, _this); | |
}); | |
} | |
}, | |
startCalendarService: function (auth, _this) { | |
_this.calendarService = google.calendar({ version: "v3", auth }); | |
_this.sendSocketNotification("SERVICE_READY", {}); | |
}, | |
/** | |
* Fetch calendars | |
* | |
* @param {string} calendarID The ID of the calendar | |
* @param {number} fetchInterval How often does the calendar needs to be fetched in ms | |
* @param {number} maximumEntries The maximum number of events fetched. | |
* @param {string} identifier ID of the module | |
* @param {number} pastDays number of past days | |
*/ | |
fetchCalendar: function ( | |
calendarID, | |
fetchInterval, | |
maximumEntries, | |
identifier, | |
pastDays | |
) { | |
this.calendarService.events.list( | |
{ | |
calendarId: calendarID, | |
timeMin: new Date(new Date().setDate(new Date().getDate()-pastDays)).toISOString(), | |
maxResults: maximumEntries, | |
singleEvents: true, | |
orderBy: "startTime" | |
}, | |
(err, res) => { | |
if (err) { | |
Log.error( | |
"Calendar Error. Could not fetch calendar: ", | |
calendarID, | |
err | |
); | |
let error_type = NodeHelper.checkFetchError(err); | |
this.sendSocketNotification("CALENDAR_ERROR", { | |
id: identifier, | |
error_type | |
}); | |
return; | |
} | |
const events = res.data.items; | |
Log.info( | |
`${this.name}: ${events.length} events loaded for ${calendarID}` | |
); | |
this.broadcastEvents(events, identifier, calendarID); | |
this.scheduleNextCalendarFetch( | |
calendarID, | |
fetchInterval, | |
maximumEntries, | |
identifier, | |
pastDays | |
); | |
} | |
); | |
}, | |
scheduleNextCalendarFetch: function ( | |
calendarID, | |
fetchInterval, | |
maximumEntries, | |
identifier, | |
pastDays | |
) { | |
var _this = this; | |
if (this.isHelperActive) { | |
setTimeout(function () { | |
_this.fetchCalendar( | |
calendarID, | |
fetchInterval, | |
maximumEntries, | |
identifier, | |
pastDays | |
); | |
}, fetchInterval); | |
} | |
}, | |
broadcastEvents: function (events, identifier, calendarID) { | |
this.sendSocketNotification("CALENDAR_EVENTS", { | |
id: identifier, | |
calendarID, | |
events: events | |
}); | |
} | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment