From b6874f94110161ac077db60aa6a2837364168df5 Mon Sep 17 00:00:00 2001 From: ddongchul Date: Sun, 30 Aug 2026 08:08:27 -0400 Subject: [PATCH 1/2] Add custom weather styling, calendar backup module, and newsfeed tweaks --- modules/default/calendar-backup/README.md | 6 + modules/default/calendar-backup/calendar.css | 55 + modules/default/calendar-backup/calendar.js | 1084 +++++++++++++++++ .../calendar-backup/calendarfetcher.js | 131 ++ .../calendar-backup/calendarfetcherutils.js | 431 +++++++ .../default/calendar-backup/calendarutils.js | 128 ++ modules/default/calendar-backup/debug.js | 42 + .../default/calendar-backup/node_helper.js | 94 ++ .../default/calendar-backup/windowsZones.json | 237 ++++ modules/default/newsfeed/newsfeed.js | 2 + modules/default/weather/current.njk.wip | 104 ++ .../default/weather/custom-weather-icons.css | 381 ++++++ .../weather/custom-weather-icons.css.wip | 370 ++++++ modules/default/weather/forecast.njk.wip | 46 + modules/default/weather/weather.js.wip | 421 +++++++ 15 files changed, 3532 insertions(+) create mode 100644 modules/default/calendar-backup/README.md create mode 100644 modules/default/calendar-backup/calendar.css create mode 100644 modules/default/calendar-backup/calendar.js create mode 100644 modules/default/calendar-backup/calendarfetcher.js create mode 100644 modules/default/calendar-backup/calendarfetcherutils.js create mode 100644 modules/default/calendar-backup/calendarutils.js create mode 100644 modules/default/calendar-backup/debug.js create mode 100644 modules/default/calendar-backup/node_helper.js create mode 100644 modules/default/calendar-backup/windowsZones.json create mode 100644 modules/default/weather/current.njk.wip create mode 100644 modules/default/weather/custom-weather-icons.css create mode 100644 modules/default/weather/custom-weather-icons.css.wip create mode 100644 modules/default/weather/forecast.njk.wip create mode 100644 modules/default/weather/weather.js.wip diff --git a/modules/default/calendar-backup/README.md b/modules/default/calendar-backup/README.md new file mode 100644 index 0000000000..1527595828 --- /dev/null +++ b/modules/default/calendar-backup/README.md @@ -0,0 +1,6 @@ +# Module: Calendar + +The `calendar` module is one of the default modules of the MagicMirror². +This module displays events from a public .ical calendar. It can combine multiple calendars. + +For configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/calendar.html). diff --git a/modules/default/calendar-backup/calendar.css b/modules/default/calendar-backup/calendar.css new file mode 100644 index 0000000000..e7d6403879 --- /dev/null +++ b/modules/default/calendar-backup/calendar.css @@ -0,0 +1,55 @@ +.calendar .symbol { + display: flex; + flex-direction: row; + justify-content: flex-end; + gap: 5px; +} + +.calendar .title { + padding: 0 10px; +} + +.calendar .time { + padding-left: 20px; + text-align: right; +} + +/* ============================== + MagicMirror Calendar: Two-row layout, full-width title + ============================== */ + +/* Calendar symbol and info wrapper */ +td.symbol { + vertical-align: top; /* align with first line */ + white-space: nowrap; /* keep name/date on one line */ +} + +/* Calendar name inline with date/time */ +td.symbol span.calendar-name { + display: inline-block; /* inline, so date can sit next to it */ + font-weight: bold; + margin-right: 8px; /* space between name and date/time */ +} + +/* Date/Time in the same cell as calendar name */ +td.time.light { + display: inline-block; /* sit next to name */ + font-weight: normal; + color: #aaa; /* optional styling */ +} + +/* Force the event title on a new line below, full width */ +td.title { + display: block; /* starts a new line */ + margin-left: 0; /* remove any indentation */ + padding-left: 0; /* remove padding */ + color: #fff; /* event title color */ + white-space: normal; /* allow wrapping */ + width: 100%; /* span full width of row */ + box-sizing: border-box; /* ensure width includes padding */ +} + +/* Optional: spacing between events */ +table.small tr.event-wrapper { + padding-bottom: 4px; +} diff --git a/modules/default/calendar-backup/calendar.js b/modules/default/calendar-backup/calendar.js new file mode 100644 index 0000000000..e8a3933f30 --- /dev/null +++ b/modules/default/calendar-backup/calendar.js @@ -0,0 +1,1084 @@ +/* global CalendarUtils */ + +Module.register("calendar", { + // Define module defaults + defaults: { + maximumEntries: 10, // Total Maximum Entries + maximumNumberOfDays: 365, + limitDays: 0, // Limit the number of days shown, 0 = no limit + pastDaysCount: 0, + displaySymbol: true, + defaultSymbol: "calendar-days", // Fontawesome Symbol see https://fontawesome.com/search?ic=free&o=r + defaultSymbolClassName: "fas fa-fw fa-", + 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: 60 * 60 * 1000, // Update every hour + animationSpeed: 2000, + fade: true, + fadePoint: 0.25, // Start on 1/4th of the list. + urgency: 7, + timeFormat: "relative", + dateFormat: "MMM Do", + dateEndFormat: "LT", + fullDayEventDateFormat: "MMM Do", + showEnd: false, + showEndsOnlyWithDuration: false, + getRelative: 6, + hidePrivate: false, + hideOngoing: false, + hideTime: false, + hideDuplicates: true, + showTimeToday: false, + colored: false, + forceUseCurrentTime: false, + tableClass: "small", + calendars: [ + { + symbol: "calendar-alt", + url: "https://www.calendarlabs.com/templates/ical/US-Holidays.ics" + } + ], + customEvents: [ + // Array of {keyword: "", symbol: "", color: "", eventClass: ""} where Keyword is a regexp and symbol/color/eventClass are to be applied for matched + { keyword: ".*", transform: { search: "De verjaardag van ", replace: "" } }, + { keyword: ".*", transform: { search: "'s birthday", replace: "" } } + ], + locationTitleReplace: { + "street ": "" + }, + broadcastEvents: true, + excludedEvents: [], + sliceMultiDayEvents: false, + broadcastPastEvents: false, + nextDaysRelative: false, + selfSignedCert: false, + coloredText: false, + coloredBorder: false, + coloredSymbol: false, + coloredBackground: false, + limitDaysNeverSkip: false, + flipDateHeaderTitle: false, + updateOnFetch: true + }, + + requiresVersion: "2.1.0", + + // Define required scripts. + getStyles () { + return ["calendar.css", "font-awesome.css"]; + }, + + // Define required scripts. + getScripts () { + return ["calendarutils.js", "moment.js", "moment-timezone.js"]; + }, + + // Define required translations. + getTranslations () { + + /* + * The translations for the default modules are defined in the core translation files. + * Therefore we can just return false. Otherwise we should have returned a dictionary. + * If you're trying to build your own module including translations, check out the documentation. + */ + return false; + }, + + // Override start method. + start () { + Log.info(`Starting module: ${this.name}`); + + if (this.config.colored) { + Log.warn("Your are using the deprecated config values 'colored'. Please switch to 'coloredSymbol' & 'coloredText'!"); + this.config.coloredText = true; + this.config.coloredSymbol = true; + } + if (this.config.coloredSymbolOnly) { + Log.warn("Your are using the deprecated config values 'coloredSymbolOnly'. Please switch to 'coloredSymbol' & 'coloredText'!"); + this.config.coloredText = false; + this.config.coloredSymbol = true; + } + + // Set locale. + moment.updateLocale(config.language, CalendarUtils.getLocaleSpecification(config.timeFormat)); + + // clear data holder before start + this.calendarData = {}; + + // indicate no data available yet + this.loaded = false; + + // data holder of calendar url. Avoid fade out/in on updateDom (one for each calendar update) + this.calendarDisplayer = {}; + + this.config.calendars.forEach((calendar) => { + calendar.url = calendar.url.replace("webcal://", "http://"); + + const calendarConfig = { + maximumEntries: calendar.maximumEntries, + maximumNumberOfDays: calendar.maximumNumberOfDays, + pastDaysCount: calendar.pastDaysCount, + broadcastPastEvents: calendar.broadcastPastEvents, + selfSignedCert: calendar.selfSignedCert, + excludedEvents: calendar.excludedEvents, + fetchInterval: calendar.fetchInterval + }; + + if (typeof calendar.symbolClass === "undefined" || calendar.symbolClass === null) { + calendarConfig.symbolClass = ""; + } + if (typeof calendar.titleClass === "undefined" || calendar.titleClass === null) { + calendarConfig.titleClass = ""; + } + if (typeof calendar.timeClass === "undefined" || calendar.timeClass === null) { + calendarConfig.timeClass = ""; + } + + // we check user and password here for backwards compatibility with old configs + if (calendar.user && calendar.pass) { + Log.warn("Deprecation warning: Please update your calendar authentication configuration."); + Log.warn("https://docs.magicmirror.builders/modules/calendar.html#configuration-options"); + calendar.auth = { + user: calendar.user, + pass: calendar.pass + }; + } + + /* + * tell helper to start a fetcher for this calendar + * fetcher till cycle + */ + this.addCalendar(calendar.url, calendar.auth, calendarConfig); + }); + + // for backward compatibility titleReplace + if (typeof this.config.titleReplace !== "undefined") { + Log.warn("Deprecation warning: Please consider upgrading your calendar titleReplace configuration to customEvents."); + for (const [titlesearchstr, titlereplacestr] of Object.entries(this.config.titleReplace)) { + this.config.customEvents.push({ keyword: ".*", transform: { search: titlesearchstr, replace: titlereplacestr } }); + } + } + + this.selfUpdate(); + }, + + notificationReceived (notification, payload, sender) { + if (notification === "FETCH_CALENDAR") { + if (this.hasCalendarURL(payload.url)) { + this.sendSocketNotification(notification, { url: payload.url, id: this.identifier }); + } + } + }, + + /*sortEventsByCalendarOrder(events) { + const order = ["Holidays", "Birthdays", "Family", "Kevin", "Fabienne", "Mackenzie"]; + const orderMap = {}; + order.forEach((name, idx) => (orderMap[name.toLowerCase()] = idx)); + + return events.sort((a, b) => { + const aIdx = orderMap[a.calendarName?.toLowerCase()] ?? 999; + const bIdx = orderMap[b.calendarName?.toLowerCase()] ?? 999; + return aIdx - bIdx; + }); + },*/ + + // Override socket notification handler. + socketNotificationReceived (notification, payload) { + + if (this.identifier !== payload.id) { + return; + } + + if (notification === "CALENDAR_EVENTS") { + if (this.hasCalendarURL(payload.url)) { + this.calendarData[payload.url] = payload.events; + this.error = null; + this.loaded = true; + + /*let allEvents = []; + Object.values(this.calendarData).forEach(list => { + if (Array.isArray(list)) { + allEvents = allEvents.concat(list); + } + }); + this.events = this.sortEventsByCalendarOrder(allEvents);*/ + + if (this.config.broadcastEvents) { + this.broadcastEvents(); + } + + if (!this.config.updateOnFetch) { + if (this.calendarDisplayer[payload.url] === undefined) { + // calendar will never displayed, so display it + this.updateDom(this.config.animationSpeed); + // set this calendar as displayed + this.calendarDisplayer[payload.url] = true; + } else { + Log.debug("[Calendar] DOM not updated waiting self update()"); + } + return; + } + } + } 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 () { + const events = this.createEventList(true); + const wrapper = document.createElement("table"); + wrapper.className = this.config.tableClass; + wrapper.style.width = "100%"; + wrapper.style.tableLayout = "fixed"; + + if (this.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 eventStartDateMoment = this.timestampToMoment(event.startDate); + const eventEndDateMoment = this.timestampToMoment(event.endDate); + const dateAsString = eventStartDateMoment.format(this.config.dateFormat); + if (this.config.timeFormat === "dateheaders") { + if (lastSeenDate !== dateAsString) { + const dateRow = document.createElement("tr"); + dateRow.className = "dateheader normal"; + if (event.today) dateRow.className += " today"; + else if (event.dayBeforeYesterday) dateRow.className += " dayBeforeYesterday"; + else if (event.yesterday) dateRow.className += " yesterday"; + else if (event.tomorrow) dateRow.className += " tomorrow"; + else if (event.dayAfterTomorrow) dateRow.className += " dayAfterTomorrow"; + + 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 nameDateRow = document.createElement("tr"); + //const nameSpan = document.createElement("td"); + //nameSpan.className = "calendar-name"; + //const dateSpan = document.createElement("td"); + //dateSpan.className = "event-time"; + const titleRow = document.createElement("tr"); + //const titleCell = document.createElement("td"); + //titleCell.colspan = 2; + //titleCell.className = "event-title"; + + + const eventWrapper = document.createElement("tr"); + + if (this.config.coloredText) { + eventWrapper.style.cssText = `color:${this.colorForUrl(event.url, false)}`; + } + + if (this.config.coloredBackground) { + eventWrapper.style.backgroundColor = this.colorForUrl(event.url, true); + } + + if (this.config.coloredBorder) { + eventWrapper.style.borderColor = this.colorForUrl(event.url, false); + } + + eventWrapper.className = "event-wrapper normal event"; + if (event.today) eventWrapper.className += " today"; + else if (event.dayBeforeYesterday) eventWrapper.className += " dayBeforeYesterday"; + else if (event.yesterday) eventWrapper.className += " yesterday"; + else if (event.tomorrow) eventWrapper.className += " tomorrow"; + else if (event.dayAfterTomorrow) eventWrapper.className += " dayAfterTomorrow"; + + /* const symbolWrapper = document.createElement("td"); + + if (this.config.displaySymbol) { + if (this.config.coloredSymbol) { + symbolWrapper.style.cssText = `color:${this.colorForUrl(event.url, false)}`; + } + + const symbolClass = this.symbolClassForUrl(event.url); + symbolWrapper.className = `symbol ${symbolClass}`; + + const symbols = this.symbolsForEvent(event); + symbols.forEach((s) => { + const symbol = document.createElement("span"); + symbol.className = s; + symbolWrapper.appendChild(symbol); + }); + eventWrapper.appendChild(symbolWrapper); + } else if (this.config.timeFormat === "dateheaders") { + const blankCell = document.createElement("td"); + blankCell.innerHTML = "   "; + eventWrapper.appendChild(blankCell); + }*/ + + //const symbolWrapper = document.createElement("tr"); + const nameCell = document.createElement("td"); + nameCell.className = "calendar-name"; + nameCell.style.textAlign = "left"; + const timeCell = document.createElement("td"); + timeCell.className = "event-time"; + timeCell.style.textAlign = "right"; + timeCell.style.whiteSpace = "nowrap"; + const titleCell = document.createElement("td"); + titleCell.colSpan = 2; + titleCell.style.padding = "0"; + titleCell.style.margin = "0"; + //nameDateRow.appendChild(nameCell); + //nameDateRow.appendChild(timeCell); + + + if (this.config.displaySymbol) { + //const nameCell = document.createElement("td"); + //nameCell.className = "calendar-name"; + nameCell.innerText = event.calendarName || ""; + //nameCell.style.textAlign = "left"; + //nameCell.style.color = this.config.customEvents[ev].color; + //symbolWrapper.appendChild(nameCell); + + + //const symbolClass = this.symbolClassForUrl(event.url); + //symbolWrapper.className = `symbol ${symbolClass}`; + + // Optional: color like the old symbol + if (this.config.coloredSymbol) { + //symbolWrapper.style.cssText = `color:${this.colorForUrl(event.url, false)}`; + nameCell.style.cssText += `color:${this.colorForUrl(event.url, false)}`; + //nameCell.style.color = this.config.customEvents[ev].color; + } + + //eventWrapper.appendChild(symbolWrapper); + //nameDateCell.appendChild(symbolWrapper); + nameDateRow.appendChild(nameCell); + } else if (this.config.timeFormat === "dateheaders") { + const blankCell = document.createElement("td"); + blankCell.innerHTML = "   "; + //eventWrapper.appendChild(blankCell); + nameDateRow.appendChild(blankCell); + } + + const titleWrapper = document.createElement("div"); + titleWrapper.colSpan = 2; + titleWrapper.style.whiteSpace = "nowrap"; + titleWrapper.style.overflow = "hidden"; + titleWrapper.style.textOverflow = "ellipsis"; + titleWrapper.style.width = "100%"; + titleWrapper.style.padding = "0"; + titleWrapper.style.paddingBottom = "10px"; + titleWrapper.style.margin = "0"; + let repeatingCountTitle = ""; + + if (this.config.displayRepeatingCountTitle && event.firstYear !== undefined) { + repeatingCountTitle = this.countTitleForUrl(event.url); + + if (repeatingCountTitle !== "") { + const thisYear = eventStartDateMoment.year(), + yearDiff = thisYear - event.firstYear; + + repeatingCountTitle = `, ${yearDiff} ${repeatingCountTitle}`; + } + } + + var transformedTitle = event.title; + + // Color events if custom color or eventClass are specified, transform title if required + if (this.config.customEvents.length > 0) { + for (let ev in this.config.customEvents) { + let needle = new RegExp(this.config.customEvents[ev].keyword, "gi"); + if (needle.test(event.title)) { + if (typeof this.config.customEvents[ev].transform === "object") { + transformedTitle = CalendarUtils.titleTransform(transformedTitle, [this.config.customEvents[ev].transform]); + } + if (typeof this.config.customEvents[ev].color !== "undefined" && this.config.customEvents[ev].color !== "") { + // Respect parameter ColoredSymbolOnly also for custom events + if (this.config.coloredText) { + eventWrapper.style.cssText = `color:${this.config.customEvents[ev].color}`; + titleWrapper.style.cssText = `color:${this.config.customEvents[ev].color}`; + } + if (this.config.displaySymbol && this.config.coloredSymbol) { + //symbolWrapper.style.cssText = `color:${this.config.customEvents[ev].color}`; + nameCell.style.cssText = `color:${this.config.customEvents[ev].color}`; + } + } + if (typeof this.config.customEvents[ev].eventClass !== "undefined" && this.config.customEvents[ev].eventClass !== "") { + eventWrapper.className += ` ${this.config.customEvents[ev].eventClass}`; + } + } + //nameCell.style.color = this.config.customEvents[ev].color; + } + } + + titleWrapper.innerHTML = CalendarUtils.shorten(transformedTitle, this.config.maxTitleLength, this.config.wrapEvents, this.config.maxTitleLines) + repeatingCountTitle; + + const titleClass = this.titleClassForUrl(event.url); + + if (!this.config.coloredText) { + titleWrapper.className = `title bright ${titleClass}`; + } else { + titleWrapper.className = `title ${titleClass}`; + } + + if (this.config.timeFormat === "dateheaders") { + //if (this.config.flipDateHeaderTitle) eventWrapper.appendChild(titleWrapper); + if (this.config.flipDateHeaderTitle) titleCell.appendChild(titleWrapper); + + if (event.fullDayEvent) { + titleWrapper.colSpan = "2"; + titleWrapper.classList.add("align-left"); + } else { + const timeWrapper = document.createElement("td"); + timeWrapper.className = `time light ${this.config.flipDateHeaderTitle ? "align-right " : "align-left "}${this.timeClassForUrl(event.url)}`; + timeWrapper.colSpan = "2"; + //timeWrapper.style.paddingLeft = "2px"; + timeWrapper.style.textAlign = this.config.flipDateHeaderTitle ? "right" : "left"; + timeWrapper.innerHTML = eventStartDateMoment.format("LT"); + + // Add endDate to dataheaders if showEnd is enabled + if (this.config.showEnd) { + if (this.config.showEndsOnlyWithDuration && event.startDate === event.endDate) { + // no duration here, don't display end + } else { + timeWrapper.innerHTML += ` - ${CalendarUtils.capFirst(eventEndDateMoment.format("LT"))}`; + } + } + + //eventWrapper.appendChild(timeWrapper); + nameDateRow.appendChild(timeWrapper); + + if (!this.config.flipDateHeaderTitle) titleWrapper.classList.add("align-right"); + } + if (!this.config.flipDateHeaderTitle) eventWrapper.appendChild(titleWrapper); + } else { + const timeWrapper = document.createElement("td"); + + //eventWrapper.appendChild(titleWrapper); + titleCell.appendChild(titleWrapper); + + const now = moment(); + + if (this.config.timeFormat === "absolute") { + // Use dateFormat + timeWrapper.innerHTML = CalendarUtils.capFirst(eventStartDateMoment.format(this.config.dateFormat)); + // Add end time if showEnd + if (this.config.showEnd) { + // and has a duation + if (event.startDate !== event.endDate) { + timeWrapper.innerHTML += "-"; + timeWrapper.innerHTML += CalendarUtils.capFirst(eventEndDateMoment.format(this.config.dateEndFormat)); + } + } + + // For full day events we use the fullDayEventDateFormat + if (event.fullDayEvent) { + //subtract one second so that fullDayEvents end at 23:59:59, and not at 0:00:00 one the next day + eventEndDateMoment.subtract(1, "second"); + timeWrapper.innerHTML = CalendarUtils.capFirst(eventStartDateMoment.format(this.config.fullDayEventDateFormat)); + // only show end if requested and allowed and the dates are different + if (this.config.showEnd && !this.config.showEndsOnlyWithDuration && !eventStartDateMoment.isSame(eventEndDateMoment, "d")) { + timeWrapper.innerHTML += "-"; + timeWrapper.innerHTML += CalendarUtils.capFirst(eventEndDateMoment.format(this.config.fullDayEventDateFormat)); + } else if (!eventStartDateMoment.isSame(eventEndDateMoment, "d") && eventStartDateMoment.isBefore(now)) { + timeWrapper.innerHTML = CalendarUtils.capFirst(now.format(this.config.fullDayEventDateFormat)); + } + } else if (this.config.getRelative > 0 && eventStartDateMoment.isBefore(now)) { + // Ongoing and getRelative is set + timeWrapper.innerHTML = CalendarUtils.capFirst( + this.translate("RUNNING", { + fallback: `${this.translate("RUNNING")} {timeUntilEnd}`, + timeUntilEnd: eventEndDateMoment.fromNow(true) + }) + ); + } else if (this.config.urgency > 0 && eventStartDateMoment.diff(now, "d") < this.config.urgency) { + // Within urgency days + timeWrapper.innerHTML = CalendarUtils.capFirst(eventStartDateMoment.fromNow()); + } + if (event.fullDayEvent && this.config.nextDaysRelative) { + // Full days events within the next two days + if (event.today) { + timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TODAY")); + } else if (event.yesterday) { + timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("YESTERDAY")); + } else if (event.tomorrow) { + timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TOMORROW")); + } else if (event.dayAfterTomorrow) { + if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") { + timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW")); + } + } + } + } else { + // Show relative times + if (eventStartDateMoment.isSameOrAfter(now) || (event.fullDayEvent && eventEndDateMoment.diff(now, "days") === 0)) { + // Use relative time + if (!this.config.hideTime && !event.fullDayEvent) { + Log.debug("event not hidden and not fullday"); + //timeWrapper.innerHTML = `${CalendarUtils.capFirst(eventStartDateMoment.calendar(null, { sameElse: this.config.dateFormat }))}`; + timeWrapper.innerHTML = CalendarUtils.capFirst( + eventStartDateMoment.calendar(null, { + sameDay: this.config.showTimeToday ? "@ h:mm a" : `[${this.translate("TODAY")}]`, + nextDay: `[${this.translate("Tmr")} @] h:mm a`, + nextWeek: "ddd @ h:mm a", // abbreviated weekday + time + sameElse: "ddd @ h:mm a" // fallback for anything else + }) + ); + } else { + Log.debug("event full day or hidden"); + timeWrapper.innerHTML = `${CalendarUtils.capFirst( + eventStartDateMoment.calendar(null, { + sameDay: this.config.showTimeToday ? "h:mm a" : `[${this.translate("TODAY")}]`, + nextDay: `[${this.translate("TOMORROW")}]`, + nextWeek: "dddd", + sameElse: event.fullDayEvent ? this.config.fullDayEventDateFormat : this.config.dateFormat + }) + )}`; + } + if (event.fullDayEvent) { + // Full days events within the next two days + if (event.today || (event.fullDayEvent && eventEndDateMoment.diff(now, "days") === 0)) { + timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TODAY")); + } else if (event.dayBeforeYesterday) { + if (this.translate("DAYBEFOREYESTERDAY") !== "DAYBEFOREYESTERDAY") { + timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYBEFOREYESTERDAY")); + } + } else if (event.yesterday) { + timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("YESTERDAY")); + } else if (event.tomorrow) { + timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TOMORROW")); + } else if (event.dayAfterTomorrow) { + if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") { + timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW")); + } + } + Log.info("event fullday"); + } else if (eventStartDateMoment.diff(now, "h") < this.config.getRelative) { + Log.info("not full day but within getrelative size"); + // If event is within getRelative hours, display 'in xxx' time format or moment.fromNow() + timeWrapper.innerHTML = `${CalendarUtils.capFirst(eventStartDateMoment.fromNow())}`; + } + } else { + // Ongoing event + timeWrapper.innerHTML = CalendarUtils.capFirst( + this.translate("RUNNING", { + fallback: `${this.translate("RUNNING")} {timeUntilEnd}`, + timeUntilEnd: eventEndDateMoment.fromNow(true) + }) + ); + } + } + timeWrapper.style.whiteSpace = "nowrap"; + timeWrapper.className = `time light ${this.timeClassForUrl(event.url)}`; + //eventWrapper.appendChild(timeWrapper); + timeCell.appendChild(timeWrapper); + titleRow.appendChild(titleCell); + nameDateRow.appendChild(timeCell); + } + + // Create fade effect. + if (index >= startFade) { + currentFadeStep = index - startFade; + eventWrapper.style.opacity = 1 - (1 / fadeSteps) * currentFadeStep; + } + //wrapper.appendChild(eventWrapper); + wrapper.appendChild(nameDateRow); + wrapper.appendChild(titleRow); + + if (this.config.showLocation) { + if (event.location !== false) { + const locationRow = document.createElement("tr"); + locationRow.className = "event-wrapper-location normal xsmall light"; + if (event.today) locationRow.className += " today"; + else if (event.dayBeforeYesterday) locationRow.className += " dayBeforeYesterday"; + else if (event.yesterday) locationRow.className += " yesterday"; + else if (event.tomorrow) locationRow.className += " tomorrow"; + else if (event.dayAfterTomorrow) locationRow.className += " dayAfterTomorrow"; + + if (this.config.displaySymbol) { + const symbolCell = document.createElement("td"); + locationRow.appendChild(symbolCell); + } + + if (this.config.coloredText) { + locationRow.style.cssText = `color:${this.colorForUrl(event.url, false)}`; + } + + if (this.config.coloredBackground) { + locationRow.style.backgroundColor = this.colorForUrl(event.url, true); + } + + if (this.config.coloredBorder) { + locationRow.style.borderColor = this.colorForUrl(event.url, false); + } + + const descCell = document.createElement("td"); + descCell.className = "location"; + descCell.colSpan = "2"; + + const transformedTitle = CalendarUtils.titleTransform(event.location, this.config.locationTitleReplace); + descCell.innerHTML = CalendarUtils.shorten(transformedTitle, this.config.maxLocationTitleLength, this.config.wrapLocationEvents, this.config.maxEventTitleLines); + locationRow.appendChild(descCell); + + wrapper.appendChild(locationRow); + + if (index >= startFade) { + currentFadeStep = index - startFade; + locationRow.style.opacity = 1 - (1 / fadeSteps) * currentFadeStep; + } + } + } + }); + + return wrapper; + }, + + /** + * Checks if this config contains the calendar url. + * @param {string} url The calendar url + * @returns {boolean} True if the calendar config contains the url, False otherwise + */ + hasCalendarURL (url) { + for (const calendar of this.config.calendars) { + if (calendar.url === url) { + return true; + } + } + + return false; + }, + + /** + * converts the given timestamp to a moment with a timezone + * @param {number} timestamp timestamp from an event + * @returns {moment.Moment} moment with a timezone + */ + timestampToMoment (timestamp) { + return moment(timestamp, "x").tz(moment.tz.guess()); + }, + + /** + * Creates the sorted list of all events. + * @param {boolean} limitNumberOfEntries Whether to filter returned events for display. + * @returns {object[]} Array with events. + */ + createEventList (limitNumberOfEntries) { + let now = moment(); + let future = now.clone().startOf("day").add(this.config.maximumNumberOfDays, "days"); + + let events = []; + + const urlToNameMap = {}; + (this.config.calendars || []).forEach((cfg) => { + if (cfg && cfg.url) { + // normalize keys the same way calendarUrl appears (no trimming change) + urlToNameMap[cfg.url] = (cfg.name || cfg.url).toString(); + } + }); + + for (const calendarUrl in this.calendarData) { + const calendar = this.calendarData[calendarUrl]; + let remainingEntries = this.maximumEntriesForUrl(calendarUrl); + let maxPastDaysCompare = now.clone().subtract(this.maximumPastDaysForUrl(calendarUrl), "days"); + let by_url_calevents = []; + for (const e in calendar) { + const event = JSON.parse(JSON.stringify(calendar[e])); // clone object + const eventStartDateMoment = this.timestampToMoment(event.startDate); + const eventEndDateMoment = this.timestampToMoment(event.endDate); + + if (this.config.hidePrivate && event.class === "PRIVATE") { + // do not add the current event, skip it + continue; + } + if (limitNumberOfEntries) { + if (eventEndDateMoment.isBefore(maxPastDaysCompare)) { + continue; + } + if (this.config.hideOngoing && eventStartDateMoment.isBefore(now)) { + continue; + } + if (this.config.hideDuplicates && this.listContainsEvent(events, event)) { + continue; + } + } + + event.url = calendarUrl; + + const cfgName = urlToNameMap[calendarUrl] || null; + event.calendarName = (cfgName || calendarUrl || "unknown").toString(); + + event.today = eventStartDateMoment.isSame(now, "d"); + event.dayBeforeYesterday = eventStartDateMoment.isSame(now.clone().subtract(2, "days"), "d"); + event.yesterday = eventStartDateMoment.isSame(now.clone().subtract(1, "days"), "d"); + event.tomorrow = eventStartDateMoment.isSame(now.clone().add(1, "days"), "d"); + event.dayAfterTomorrow = eventStartDateMoment.isSame(now.clone().add(2, "days"), "d"); + + /* + * if sliceMultiDayEvents is set to true, multiday events (events exceeding at least one midnight) are sliced into days, + * otherwise, esp. in dateheaders mode it is not clear how long these events are. + */ + const maxCount = eventEndDateMoment.diff(eventStartDateMoment, "days"); + if (this.config.sliceMultiDayEvents && maxCount > 1) { + const splitEvents = []; + let midnight + = eventStartDateMoment + .clone() + .startOf("day") + .add(1, "day") + .endOf("day"); + let count = 1; + while (eventEndDateMoment.isAfter(midnight)) { + const thisEvent = JSON.parse(JSON.stringify(event)); // clone object + thisEvent.today = this.timestampToMoment(thisEvent.startDate).isSame(now, "d"); + thisEvent.tomorrow = this.timestampToMoment(thisEvent.startDate).isSame(now.clone().add(1, "days"), "d"); + thisEvent.endDate = midnight.clone().subtract(1, "day").format("x"); + thisEvent.title += ` (${count}/${maxCount})`; + splitEvents.push(thisEvent); + + event.startDate = midnight.format("x"); + count += 1; + midnight = midnight.clone().add(1, "day").endOf("day"); // next day + } + // Last day + event.title += ` (${count}/${maxCount})`; + event.today += this.timestampToMoment(event.startDate).isSame(now, "d"); + event.tomorrow = this.timestampToMoment(event.startDate).isSame(now.clone().add(1, "days"), "d"); + splitEvents.push(event); + + for (let splitEvent of splitEvents) { + if (this.timestampToMoment(splitEvent.endDate).isAfter(now) && this.timestampToMoment(splitEvent.endDate).isSameOrBefore(future)) { + by_url_calevents.push(splitEvent); + } + } + } else { + by_url_calevents.push(event); + } + } + if (limitNumberOfEntries) { + // sort entries before clipping + by_url_calevents.sort(function (a, b) { + return a.startDate - b.startDate; + }); + Log.debug(`pushing ${by_url_calevents.length} events to total with room for ${remainingEntries}`); + events = events.concat(by_url_calevents.slice(0, remainingEntries)); + Log.debug(`events for calendar=${events.length}`); + } else { + events = events.concat(by_url_calevents); + } + } + Log.info(`sorting events count=${events.length}`); + + events.sort(function (a, b) { + return a.startDate - b.startDate; + }); + + /*const order = ["Holidays", "Birthdays", "Family", "Kevin", "Fabienne", "Mackenzie"]; + const orderMap = {}; + order.forEach((name, idx) => (orderMap[name.toLowerCase()] = idx)); + + events.sort((a, b) => { + const aIdx = orderMap[a.calendarName?.toLowerCase()] ?? 999; + const bIdx = orderMap[b.calendarName?.toLowerCase()] ?? 999; + if (aIdx !== bIdx) { + return aIdx - bIdx; + } + // fallback to date only if from same calendar + return a.startDate - b.startDate; + });*/ + + if (!limitNumberOfEntries) { + return events; + } + + /* + * Limit the number of days displayed + * If limitDays is set > 0, limit display to that number of days + */ + if (this.config.limitDays > 0 && events.length > 0) { // watch out for initial display before events arrive from helper + // Group all events by date, events on the same date will be in a list with the key being the date. + const eventsByDate = Object.groupBy(events, (ev) => this.timestampToMoment(ev.startDate).format("YYYY-MM-DD")); + const newEvents = []; + let currentDate = moment(); + let daysCollected = 0; + + while (daysCollected < this.config.limitDays) { + const dateStr = currentDate.format("YYYY-MM-DD"); + // Check if there are events on the currentDate + if (eventsByDate[dateStr] && eventsByDate[dateStr].length > 0) { + // If there are any events today then get all those events and select the currently active events and the events that are starting later in the day. + newEvents.push(...eventsByDate[dateStr].filter((ev) => this.timestampToMoment(ev.endDate).isAfter(moment()))); + // Since we found a day with events, increase the daysCollected by 1 + daysCollected++; + } + // Search for the next day + currentDate.add(1, "day"); + } + events = newEvents; + } + Log.info(`slicing events total maxcount=${this.config.maximumEntries}`); + return events.slice(0, this.config.maximumEntries); + }, + + listContainsEvent (eventList, event) { + for (const evt of eventList) { + if (evt.title === event.title && parseInt(evt.startDate) === parseInt(event.startDate) && parseInt(evt.endDate) === parseInt(event.endDate)) { + return true; + } + } + return false; + }, + + /** + * Requests node helper to add calendar url. + * @param {string} url The calendar url to add + * @param {object} auth The authentication method and credentials + * @param {object} calendarConfig The config of the specific calendar + */ + addCalendar (url, auth, calendarConfig) { + this.sendSocketNotification("ADD_CALENDAR", { + id: this.identifier, + url: url, + excludedEvents: calendarConfig.excludedEvents || this.config.excludedEvents, + maximumEntries: calendarConfig.maximumEntries || this.config.maximumEntries, + maximumNumberOfDays: calendarConfig.maximumNumberOfDays || this.config.maximumNumberOfDays, + pastDaysCount: calendarConfig.pastDaysCount || this.config.pastDaysCount, + fetchInterval: calendarConfig.fetchInterval || this.config.fetchInterval, + symbolClass: calendarConfig.symbolClass, + titleClass: calendarConfig.titleClass, + timeClass: calendarConfig.timeClass, + auth: auth, + broadcastPastEvents: calendarConfig.broadcastPastEvents || this.config.broadcastPastEvents, + selfSignedCert: calendarConfig.selfSignedCert || this.config.selfSignedCert + }); + }, + + /** + * Retrieves the symbols for a specific event. + * @param {object} event Event to look for. + * @returns {string[]} The symbols + */ + symbolsForEvent (event) { + let symbols = this.getCalendarPropertyAsArray(event.url, "symbol", this.config.defaultSymbol); + + if (event.recurringEvent === true && this.hasCalendarProperty(event.url, "recurringSymbol")) { + symbols = this.mergeUnique(this.getCalendarPropertyAsArray(event.url, "recurringSymbol", this.config.defaultSymbol), symbols); + } + + if (event.fullDayEvent === true && this.hasCalendarProperty(event.url, "fullDaySymbol")) { + symbols = this.mergeUnique(this.getCalendarPropertyAsArray(event.url, "fullDaySymbol", this.config.defaultSymbol), symbols); + } + + // If custom symbol is set, replace event symbol + for (let ev of this.config.customEvents) { + if (typeof ev.symbol !== "undefined" && ev.symbol !== "") { + let needle = new RegExp(ev.keyword, "gi"); + if (needle.test(event.title)) { + // Get the default prefix for this class name and add to the custom symbol provided + const className = this.getCalendarProperty(event.url, "symbolClassName", this.config.defaultSymbolClassName); + symbols[0] = className + ev.symbol; + break; + } + } + } + + return symbols; + }, + + mergeUnique (arr1, arr2) { + return arr1.concat( + arr2.filter(function (item) { + return arr1.indexOf(item) === -1; + }) + ); + }, + + /** + * Retrieves the symbolClass for a specific calendar url. + * @param {string} url The calendar url + * @returns {string} The class to be used for the symbols of the calendar + */ + symbolClassForUrl (url) { + return this.getCalendarProperty(url, "symbolClass", ""); + }, + + /** + * Retrieves the titleClass for a specific calendar url. + * @param {string} url The calendar url + * @returns {string} The class to be used for the title of the calendar + */ + titleClassForUrl (url) { + return this.getCalendarProperty(url, "titleClass", ""); + }, + + /** + * Retrieves the timeClass for a specific calendar url. + * @param {string} url The calendar url + * @returns {string} The class to be used for the time of the calendar + */ + timeClassForUrl (url) { + return this.getCalendarProperty(url, "timeClass", ""); + }, + + /** + * Retrieves the calendar name for a specific calendar url. + * @param {string} url The calendar url + * @returns {string} The name of the calendar + */ + calendarNameForUrl (url) { + return this.getCalendarProperty(url, "name", ""); + }, + + /** + * Retrieves the color for a specific calendar url. + * @param {string} url The calendar url + * @param {boolean} isBg Determines if we fetch the bgColor or not + * @returns {string} The color + */ + colorForUrl (url, isBg) { + return this.getCalendarProperty(url, isBg ? "bgColor" : "color", "#fff"); + }, + + /** + * Retrieves the count title for a specific calendar url. + * @param {string} url The calendar url + * @returns {string} The title + */ + countTitleForUrl (url) { + return this.getCalendarProperty(url, "repeatingCountTitle", this.config.defaultRepeatingCountTitle); + }, + + /** + * Retrieves the maximum entry count for a specific calendar url. + * @param {string} url The calendar url + * @returns {number} The maximum entry count + */ + maximumEntriesForUrl (url) { + return this.getCalendarProperty(url, "maximumEntries", this.config.maximumEntries); + }, + + /** + * Retrieves the maximum count of past days which events of should be displayed for a specific calendar url. + * @param {string} url The calendar url + * @returns {number} The maximum past days count + */ + maximumPastDaysForUrl (url) { + return this.getCalendarProperty(url, "pastDaysCount", this.config.pastDaysCount); + }, + + /** + * Helper method to retrieve the property for a specific calendar url. + * @param {string} url The calendar url + * @param {string} property The property to look for + * @param {string} defaultValue The value if the property is not found + * @returns {property} The property + */ + getCalendarProperty (url, property, defaultValue) { + for (const calendar of this.config.calendars) { + if (calendar.url === url && calendar.hasOwnProperty(property)) { + return calendar[property]; + } + } + + return defaultValue; + }, + + getCalendarPropertyAsArray (url, property, defaultValue) { + let p = this.getCalendarProperty(url, property, defaultValue); + if (property === "symbol" || property === "recurringSymbol" || property === "fullDaySymbol") { + const className = this.getCalendarProperty(url, "symbolClassName", this.config.defaultSymbolClassName); + if (p instanceof Array) { + let t = []; + p.forEach((n) => { t.push(className + n); }); + p = t; + } + else p = className + p; + } + if (!(p instanceof Array)) p = [p]; + return p; + }, + + hasCalendarProperty (url, property) { + return !!this.getCalendarProperty(url, property, undefined); + }, + + /** + * Broadcasts the events to all other modules for reuse. + * The all events available in one array, sorted on startdate. + */ + broadcastEvents () { + const eventList = this.createEventList(false); + for (const event of eventList) { + event.symbol = this.symbolsForEvent(event); + event.calendarName = this.calendarNameForUrl(event.url); + event.color = this.colorForUrl(event.url, false); + delete event.url; + } + + this.sendNotification("CALENDAR_EVENTS", eventList); + }, + + /** + * Refresh the DOM every minute if needed: When using relative date format for events that start + * or end in less than an hour, the date shows minute granularity and we want to keep that accurate. + * -- + * When updateOnFetch is not set, it will Avoid fade out/in on updateDom when many calendars are used + * and it's allow to refresh The DOM every minute with animation speed too + * (because updateDom is not set in CALENDAR_EVENTS for this case) + */ + selfUpdate () { + const ONE_MINUTE = 60 * 1000; + setTimeout( + () => { + setInterval(() => { + Log.debug("[Calendar] self update"); + if (this.config.updateOnFetch) { + this.updateDom(1); + } else { + this.updateDom(this.config.animationSpeed); + } + }, ONE_MINUTE); + }, + ONE_MINUTE - (new Date() % ONE_MINUTE) + ); + } +}); diff --git a/modules/default/calendar-backup/calendarfetcher.js b/modules/default/calendar-backup/calendarfetcher.js new file mode 100644 index 0000000000..6f254156b3 --- /dev/null +++ b/modules/default/calendar-backup/calendarfetcher.js @@ -0,0 +1,131 @@ +const https = require("node:https"); +const ical = require("node-ical"); +const Log = require("logger"); +const NodeHelper = require("node_helper"); +const CalendarFetcherUtils = require("./calendarfetcherutils"); +const { getUserAgent } = require("#server_functions"); +const { scheduleTimer } = require("#module_functions"); + +/** + * + * @param {string} url The url of the calendar to fetch + * @param {number} reloadInterval Time in ms the calendar is fetched again + * @param {string[]} excludedEvents An array of words / phrases from event titles that will be excluded from being shown. + * @param {number} maximumEntries The maximum number of events fetched. + * @param {number} maximumNumberOfDays The maximum number of days an event should be in the future. + * @param {object} auth The object containing options for authentication against the calendar. + * @param {boolean} includePastEvents If true events from the past maximumNumberOfDays will be fetched too + * @param {boolean} selfSignedCert If true, the server certificate is not verified against the list of supplied CAs. + * @class + */ +const CalendarFetcher = function (url, reloadInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, includePastEvents, selfSignedCert) { + let reloadTimer = null; + let events = []; + + let fetchFailedCallback = function () {}; + let eventsReceivedCallback = function () {}; + + /** + * Initiates calendar fetch. + */ + const fetchCalendar = () => { + clearTimeout(reloadTimer); + reloadTimer = null; + let httpsAgent = null; + let headers = { + "User-Agent": getUserAgent() + }; + + if (selfSignedCert) { + httpsAgent = new https.Agent({ + rejectUnauthorized: false + }); + } + if (auth) { + if (auth.method === "bearer") { + headers.Authorization = `Bearer ${auth.pass}`; + } else { + headers.Authorization = `Basic ${Buffer.from(`${auth.user}:${auth.pass}`).toString("base64")}`; + } + } + + fetch(url, { headers: headers, agent: httpsAgent }) + .then(NodeHelper.checkFetchStatus) + .then((response) => response.text()) + .then((responseData) => { + let data = []; + + try { + data = ical.parseICS(responseData); + Log.debug(`parsed data=${JSON.stringify(data, null, 2)}`); + events = CalendarFetcherUtils.filterEvents(data, { + excludedEvents, + includePastEvents, + maximumEntries, + maximumNumberOfDays + }); + } catch (error) { + fetchFailedCallback(this, error); + scheduleTimer(reloadTimer, reloadInterval, fetchCalendar); + return; + } + this.broadcastEvents(); + scheduleTimer(reloadTimer, reloadInterval, fetchCalendar); + }) + .catch((error) => { + fetchFailedCallback(this, error); + scheduleTimer(reloadTimer, reloadInterval, fetchCalendar); + }); + }; + + /* public methods */ + + /** + * Initiate fetchCalendar(); + */ + this.startFetch = function () { + fetchCalendar(); + }; + + /** + * Broadcast the existing events. + */ + this.broadcastEvents = function () { + Log.info(`Calendar-Fetcher: Broadcasting ${events.length} events from ${url}.`); + eventsReceivedCallback(this); + }; + + /** + * Sets the on success callback + * @param {eventsReceivedCallback} callback The on success callback. + */ + this.onReceive = function (callback) { + eventsReceivedCallback = callback; + }; + + /** + * Sets the on error callback + * @param {fetchFailedCallback} callback The on error callback. + */ + this.onError = function (callback) { + fetchFailedCallback = callback; + }; + + /** + * Returns the url of this fetcher. + * @returns {string} The url of this fetcher. + */ + this.url = function () { + return url; + }; + + /** + * Returns current available events for this fetcher. + * @returns {object[]} The current available events for this fetcher. + */ + this.events = function () { + return events; + }; +}; + +module.exports = CalendarFetcher; diff --git a/modules/default/calendar-backup/calendarfetcherutils.js b/modules/default/calendar-backup/calendarfetcherutils.js new file mode 100644 index 0000000000..729f121ce4 --- /dev/null +++ b/modules/default/calendar-backup/calendarfetcherutils.js @@ -0,0 +1,431 @@ +/** + * @external Moment + */ +const moment = require("moment-timezone"); + +const Log = require("logger"); + +const CalendarFetcherUtils = { + + /** + * Determine based on the title of an event if it should be excluded from the list of events + * TODO This seems like an overly complicated way to exclude events based on the title. + * @param {object} config the global config + * @param {string} title the title of the event + * @returns {object} excluded: true if the event should be excluded, false otherwise + * until: the date until the event should be excluded. + */ + shouldEventBeExcluded (config, title) { + let result = { + excluded: false, + until: null + }; + for (let f in config.excludedEvents) { + let filter = config.excludedEvents[f], + testTitle = title.toLowerCase(), + until = null, + useRegex = false, + regexFlags = "g"; + + if (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) { + result.until = until; + } else { + result.excluded = true; + } + break; + } + } + return result; + }, + + /** + * 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))); + }, + + /** + * 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 (data, config) { + const newEvents = []; + + const eventDate = function (event, time) { + const startMoment = event[time].tz ? moment.tz(event[time], event[time].tz) : moment.tz(event[time], CalendarFetcherUtils.getLocalTimezone()); + return CalendarFetcherUtils.isFullDayEvent(event) ? startMoment.startOf("day") : startMoment; + }; + + Log.debug(`There are ${Object.entries(data).length} calendar entries.`); + + const now = moment(); + const pastLocalMoment = config.includePastEvents ? now.clone().startOf("day").subtract(config.maximumNumberOfDays, "days") : now; + const futureLocalMoment + = now + .clone() + .startOf("day") + .add(config.maximumNumberOfDays, "days") + // Subtract 1 second so that events that start on the middle of the night will not repeat. + .subtract(1, "seconds"); + + Object.entries(data).forEach(([key, event]) => { + Log.debug("Processing entry..."); + + const title = CalendarFetcherUtils.getTitleFromEvent(event); + Log.debug(`title: ${title}`); + + // Return quickly if event should be excluded. + let { excluded, eventFilterUntil } = this.shouldEventBeExcluded(config, title); + if (excluded) { + return; + } + + // FIXME: Ugly fix to solve the facebook birthday issue. + // Otherwise, the recurring events only show the birthday for next year. + let isFacebookBirthday = false; + if (typeof event.uid !== "undefined") { + if (event.uid.indexOf("@facebook.com") !== -1) { + isFacebookBirthday = true; + } + } + + if (event.type === "VEVENT") { + Log.debug(`Event:\n${JSON.stringify(event, null, 2)}`); + let eventStartMoment = eventDate(event, "start"); + let eventEndMoment; + + if (typeof event.end !== "undefined") { + eventEndMoment = eventDate(event, "end"); + } else if (typeof event.duration !== "undefined") { + eventEndMoment = eventStartMoment.clone().add(moment.duration(event.duration)); + } else { + if (!isFacebookBirthday) { + // make copy of start date, separate storage area + eventEndMoment = eventStartMoment.clone(); + } else { + eventEndMoment = eventStartMoment.clone().add(1, "days"); + } + } + + Log.debug(`start: ${eventStartMoment.toDate()}`); + Log.debug(`end:: ${eventEndMoment.toDate()}`); + + // Calculate the duration of the event for use with recurring events. + const durationMs = eventEndMoment.valueOf() - eventStartMoment.valueOf(); + Log.debug(`duration: ${durationMs}`); + + const location = event.location || false; + const geo = event.geo || false; + const description = event.description || false; + + // TODO This should be a seperate function. + if (event.rrule && typeof event.rrule !== "undefined" && !isFacebookBirthday) { + // Recurring event. + let moments = CalendarFetcherUtils.getMomentsFromRecurringEvent(event, pastLocalMoment, futureLocalMoment, durationMs); + + // Loop through the set of moment entries to see which recurrences should be added to our event list. + // TODO This should create an event per moment so we can change anything we want. + for (let m in moments) { + let curEvent = event; + let showRecurrence = true; + let recurringEventStartMoment = moments[m].tz(CalendarFetcherUtils.getLocalTimezone()).clone(); + let recurringEventEndMoment = recurringEventStartMoment.clone().add(durationMs, "ms"); + + let dateKey = recurringEventStartMoment.tz("UTC").format("YYYY-MM-DD"); + + Log.debug("event date dateKey=", dateKey); + // For each date that we're checking, it's possible that there is a recurrence override for that one day. + if (curEvent.recurrences !== undefined) { + Log.debug("have recurrences=", curEvent.recurrences); + if (curEvent.recurrences[dateKey] !== undefined) { + Log.debug("have a recurrence match for dateKey=", dateKey); + // We found an override, so for this recurrence, use a potentially different title, start date, and duration. + curEvent = curEvent.recurrences[dateKey]; + // Some event start/end dates don't have timezones + if (curEvent.start.tz) { + recurringEventStartMoment = moment(curEvent.start).tz(curEvent.start.tz).tz(CalendarFetcherUtils.getLocalTimezone()); + } else { + recurringEventStartMoment = moment(curEvent.start).tz(CalendarFetcherUtils.getLocalTimezone()); + } + if (curEvent.end.tz) { + recurringEventEndMoment = moment(curEvent.end).tz(curEvent.end.tz).tz(CalendarFetcherUtils.getLocalTimezone()); + } else { + recurringEventEndMoment = moment(curEvent.end).tz(CalendarFetcherUtils.getLocalTimezone()); + } + } else { + Log.debug("recurrence key ", dateKey, " doesn't match"); + } + } + // If there's no recurrence override, check for an exception date. Exception dates represent exceptions to the rule. + if (curEvent.exdate !== undefined) { + Log.debug("have datekey=", dateKey, " exdates=", curEvent.exdate); + if (curEvent.exdate[dateKey] !== undefined) { + // This date is an exception date, which means we should skip it in the recurrence pattern. + showRecurrence = false; + } + } + + if (recurringEventStartMoment.valueOf() === recurringEventEndMoment.valueOf()) { + recurringEventEndMoment = recurringEventEndMoment.endOf("day"); + } + + const recurrenceTitle = CalendarFetcherUtils.getTitleFromEvent(curEvent); + + // If this recurrence ends before the start of the date range, or starts after the end of the date range, don"t add + // it to the event list. + if (recurringEventEndMoment.isBefore(pastLocalMoment) || recurringEventStartMoment.isAfter(futureLocalMoment)) { + showRecurrence = false; + } + + if (CalendarFetcherUtils.timeFilterApplies(now, recurringEventEndMoment, eventFilterUntil)) { + showRecurrence = false; + } + + if (showRecurrence === true) { + Log.debug(`saving event: ${recurrenceTitle}`); + newEvents.push({ + title: recurrenceTitle, + startDate: recurringEventStartMoment.format("x"), + endDate: recurringEventEndMoment.format("x"), + fullDayEvent: CalendarFetcherUtils.isFullDayEvent(event), + recurringEvent: true, + class: event.class, + firstYear: event.start.getFullYear(), + location: location, + geo: geo, + description: description + }); + } else { + Log.debug("not saving event ", recurrenceTitle, eventStartMoment); + } + Log.debug(" "); + } + // End recurring event parsing. + } else { + // Single event. + const fullDayEvent = isFacebookBirthday ? true : CalendarFetcherUtils.isFullDayEvent(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 (fullDayEvent && eventStartMoment.valueOf() === eventEndMoment.valueOf()) { + eventEndMoment = eventEndMoment.endOf("day"); + } + + if (config.includePastEvents) { + // Past event is too far in the past, so skip. + if (eventEndMoment < pastLocalMoment) { + return; + } + } else { + // It's not a fullday event, and it is in the past, so skip. + if (!fullDayEvent && eventEndMoment < now) { + return; + } + + // It's a fullday event, and it is before today, So skip. + if (fullDayEvent && eventEndMoment <= now.startOf("day")) { + return; + } + } + + // It exceeds the maximumNumberOfDays limit, so skip. + if (eventStartMoment > futureLocalMoment) { + return; + } + + if (CalendarFetcherUtils.timeFilterApplies(now, eventEndMoment, eventFilterUntil)) { + return; + } + + // Every thing is good. Add it to the list. + newEvents.push({ + title: title, + startDate: eventStartMoment.format("x"), + endDate: eventEndMoment.format("x"), + fullDayEvent: fullDayEvent, + recurringEvent: false, + class: event.class, + firstYear: event.start.getFullYear(), + location: location, + geo: geo, + description: description + }); + } + } + }); + + newEvents.sort(function (a, b) { + return a.startDate - b.startDate; + }); + + return newEvents; + }, + + /** + * 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 (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 (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 {moment.Moment} now Date object using previously created object for consistency + * @param {moment.Moment} endDate Moment object representing the event end date + * @param {string} filter The time to subtract from the end date to determine if an event should be shown + * @returns {boolean} True if the event should be filtered out, false otherwise + */ + timeFilterApplies (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; + } + + 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 (title, filter, useRegex, regexFlags) { + if (useRegex) { + let regexFilter = filter; + // Assume if leading slash, there is also trailing slash + if (filter[0] === "/") { + // Strip leading and trailing slashes + regexFilter = filter.substr(1).slice(0, -1); + } + return new RegExp(regexFilter, regexFlags).test(title); + } else { + return title.includes(filter); + } + } +}; + +if (typeof module !== "undefined") { + module.exports = CalendarFetcherUtils; +} diff --git a/modules/default/calendar-backup/calendarutils.js b/modules/default/calendar-backup/calendarutils.js new file mode 100644 index 0000000000..5cbc8d6824 --- /dev/null +++ b/modules/default/calendar-backup/calendarutils.js @@ -0,0 +1,128 @@ +const CalendarUtils = { + + /** + * Capitalize the first letter of a string + * @param {string} string The string to capitalize + * @returns {string} The capitalized string + */ + capFirst (string) { + return string.charAt(0).toUpperCase() + string.slice(1); + }, + + /** + * This function accepts a number (either 12 or 24) and returns a moment.js LocaleSpecification with the + * corresponding time-format to be used in the calendar display. If no number is given (or otherwise invalid input) + * it will a localeSpecification object with the system locale time format. + * @param {number} timeFormat Specifies either 12 or 24-hour time format + * @returns {moment.LocaleSpecification} formatted time + */ + getLocaleSpecification (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") } }; + } + } + }, + + /** + * Shortens a string if it's longer than maxLength and add an ellipsis to the end + * @param {string} string Text string to shorten + * @param {number} maxLength The max length of the string + * @param {boolean} wrapEvents Wrap the text after the line has reached maxLength + * @param {number} maxTitleLines The max number of vertical lines before cutting event title + * @returns {string} The shortened string + */ + shorten (string, maxLength, wrapEvents, maxTitleLines) { + if (typeof string !== "string") { + return ""; + } + + if (wrapEvents === true) { + const words = string.split(" "); + let temp = ""; + let currentLine = ""; + let line = 0; + + for (let i = 0; i < words.length; i++) { + const word = words[i]; + if (currentLine.length + word.length < (typeof maxLength === "number" ? maxLength : 25) - 1) { + // max - 1 to account for a space + currentLine += `${word} `; + } else { + line++; + if (line > maxTitleLines - 1) { + if (i < words.length) { + currentLine += "…"; + } + break; + } + + if (currentLine.length > 0) { + temp += `${currentLine}
${word} `; + } else { + temp += `${word}
`; + } + currentLine = ""; + } + } + + return (temp + currentLine).trim(); + } else { + if (maxLength && typeof maxLength === "number" && string.length > maxLength) { + return `${string.trim().slice(0, maxLength)}…`; + } else { + return string.trim(); + } + } + }, + + /** + * Transforms the title of an event for usage. + * Replaces parts of the text as defined in config.titleReplace. + * @param {string} title The title to transform. + * @param {object} titleReplace object definition of parts to be replaced in the title + * object definition: + * search: {string,required} RegEx in format //x or simple string to be searched. For (birthday) year calcluation, the element matching the year must be in a RegEx group + * replace: {string,required} Replacement string, may contain match group references (latter is required for year calculation) + * yearmatchgroup: {number,optional} match group for year element + * @returns {string} The transformed title. + */ + titleTransform (title, titleReplace) { + let transformedTitle = title; + for (let tr in titleReplace) { + let transform = titleReplace[tr]; + if (typeof transform === "object") { + if (typeof transform.search !== "undefined" && transform.search !== "" && typeof transform.replace !== "undefined") { + let regParts = transform.search.match(/^\/(.+)\/([gim]*)$/); + let needle = new RegExp(transform.search, "g"); + if (regParts) { + // the parsed pattern is a regexp with flags. + needle = new RegExp(regParts[1], regParts[2]); + } + + let replacement = transform.replace; + if (typeof transform.yearmatchgroup !== "undefined" && transform.yearmatchgroup !== "") { + const yearmatch = [...title.matchAll(needle)]; + if (yearmatch[0].length >= transform.yearmatchgroup + 1 && yearmatch[0][transform.yearmatchgroup] * 1 >= 1900) { + let calcage = new Date().getFullYear() - yearmatch[0][transform.yearmatchgroup] * 1; + let searchstr = `$${transform.yearmatchgroup}`; + replacement = replacement.replace(searchstr, calcage); + } + } + transformedTitle = transformedTitle.replace(needle, replacement); + } + } + } + return transformedTitle; + } +}; + +if (typeof module !== "undefined") { + module.exports = CalendarUtils; +} diff --git a/modules/default/calendar-backup/debug.js b/modules/default/calendar-backup/debug.js new file mode 100644 index 0000000000..3acfc31132 --- /dev/null +++ b/modules/default/calendar-backup/debug.js @@ -0,0 +1,42 @@ +/* + * CalendarFetcher Tester + * use this script with `node debug.js` to test the fetcher without the need + * of starting the MagicMirror² core. Adjust the values below to your desire. + */ +// Alias modules mentioned in package.js under _moduleAliases. +require("module-alias/register"); +const Log = require("logger"); + +const CalendarFetcher = require("./calendarfetcher"); + +const url = "https://calendar.google.com/calendar/ical/pkm1t2uedjbp0uvq1o7oj1jouo%40group.calendar.google.com/private-08ba559f89eec70dd74bbd887d0a3598/basic.ics"; // Standard test URL +//const url = "https://www.googleapis.com/calendar/v3/calendars/primary/events/"; // URL for Bearer auth (must be configured in Google OAuth2 first) +const fetchInterval = 60 * 60 * 1000; +const maximumEntries = 10; +const maximumNumberOfDays = 365; +const user = "magicmirror"; +const pass = "MyStrongPass"; +const auth = { + user: user, + pass: pass +}; + +Log.log("Create fetcher ..."); + +const fetcher = new CalendarFetcher(url, fetchInterval, [], maximumEntries, maximumNumberOfDays, auth); + +fetcher.onReceive(function (fetcher) { + Log.log(fetcher.events()); + Log.log("------------------------------------------------------------"); + process.exit(0); +}); + +fetcher.onError(function (fetcher, error) { + Log.log("Fetcher error:"); + Log.log(error); + process.exit(1); +}); + +fetcher.startFetch(); + +Log.log("Create fetcher done! "); diff --git a/modules/default/calendar-backup/node_helper.js b/modules/default/calendar-backup/node_helper.js new file mode 100644 index 0000000000..7901abf099 --- /dev/null +++ b/modules/default/calendar-backup/node_helper.js @@ -0,0 +1,94 @@ +const NodeHelper = require("node_helper"); +const Log = require("logger"); +const CalendarFetcher = require("./calendarfetcher"); + +module.exports = NodeHelper.create({ + // Override start method. + start () { + Log.log(`Starting node helper for: ${this.name}`); + this.fetchers = []; + }, + + // Override socketNotificationReceived method. + socketNotificationReceived (notification, payload) { + if (notification === "ADD_CALENDAR") { + this.createFetcher(payload.url, payload.fetchInterval, payload.excludedEvents, payload.maximumEntries, payload.maximumNumberOfDays, payload.auth, payload.broadcastPastEvents, payload.selfSignedCert, payload.id); + } else if (notification === "FETCH_CALENDAR") { + const key = payload.id + payload.url; + if (typeof this.fetchers[key] === "undefined") { + Log.error("Calendar Error. No fetcher exists with key: ", key); + this.sendSocketNotification("CALENDAR_ERROR", { error_type: "MODULE_ERROR_UNSPECIFIED" }); + return; + } + this.fetchers[key].startFetch(); + } + }, + + /** + * Creates a fetcher for a new url if it doesn't exist yet. + * Otherwise it reuses the existing one. + * @param {string} url The url of the calendar + * @param {number} fetchInterval How often does the calendar needs to be fetched in ms + * @param {string[]} excludedEvents An array of words / phrases from event titles that will be excluded from being shown. + * @param {number} maximumEntries The maximum number of events fetched. + * @param {number} maximumNumberOfDays The maximum number of days an event should be in the future. + * @param {object} auth The object containing options for authentication against the calendar. + * @param {boolean} broadcastPastEvents If true events from the past maximumNumberOfDays will be included in event broadcasts + * @param {boolean} selfSignedCert If true, the server certificate is not verified against the list of supplied CAs. + * @param {string} identifier ID of the module + */ + createFetcher (url, fetchInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, broadcastPastEvents, selfSignedCert, identifier) { + try { + new URL(url); + } catch (error) { + Log.error("Calendar Error. Malformed calendar url: ", url, error); + this.sendSocketNotification("CALENDAR_ERROR", { error_type: "MODULE_ERROR_MALFORMED_URL" }); + return; + } + + let fetcher; + let fetchIntervalCorrected; + if (typeof this.fetchers[identifier + url] === "undefined") { + if (fetchInterval < 60000) { + Log.warn(`fetchInterval for url ${url} must be >= 60000`); + fetchIntervalCorrected = 60000; + } + Log.log(`Create new calendarfetcher for url: ${url} - Interval: ${fetchIntervalCorrected || fetchInterval}`); + fetcher = new CalendarFetcher(url, fetchIntervalCorrected || fetchInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, broadcastPastEvents, selfSignedCert); + + fetcher.onReceive((fetcher) => { + this.broadcastEvents(fetcher, identifier); + }); + + fetcher.onError((fetcher, error) => { + Log.error("Calendar Error. Could not fetch calendar: ", fetcher.url(), error); + let error_type = NodeHelper.checkFetchError(error); + this.sendSocketNotification("CALENDAR_ERROR", { + id: identifier, + error_type + }); + }); + + this.fetchers[identifier + url] = fetcher; + } else { + Log.log(`Use existing calendarfetcher for url: ${url}`); + fetcher = this.fetchers[identifier + url]; + fetcher.broadcastEvents(); + } + + fetcher.startFetch(); + }, + + /** + * + * @param {object} fetcher the fetcher associated with the calendar + * @param {string} identifier the identifier of the calendar + */ + broadcastEvents (fetcher, identifier) { + this.sendSocketNotification("CALENDAR_EVENTS", { + id: identifier, + url: fetcher.url(), + events: fetcher.events() + }); + } +}); diff --git a/modules/default/calendar-backup/windowsZones.json b/modules/default/calendar-backup/windowsZones.json new file mode 100644 index 0000000000..cad82bb9ee --- /dev/null +++ b/modules/default/calendar-backup/windowsZones.json @@ -0,0 +1,237 @@ +{ + "Dateline Standard Time": { "iana": ["Etc/GMT+12"] }, + "UTC-11": { "iana": ["Etc/GMT+11"] }, + "Aleutian Standard Time": { "iana": ["America/Adak"] }, + "Hawaiian Standard Time": { "iana": ["Pacific/Honolulu"] }, + "Marquesas Standard Time": { "iana": ["Pacific/Marquesas"] }, + "Alaskan Standard Time": { "iana": ["America/Anchorage"] }, + "UTC-09": { "iana": ["Etc/GMT+9"] }, + "Pacific Standard Time (Mexico)": { "iana": ["America/Tijuana"] }, + "UTC-08": { "iana": ["Etc/GMT+8"] }, + "Pacific Standard Time": { "iana": ["America/Los_Angeles"] }, + "US Mountain Standard Time": { "iana": ["America/Phoenix"] }, + "Mountain Standard Time (Mexico)": { "iana": ["America/Chihuahua"] }, + "Mountain Standard Time": { "iana": ["America/Denver"] }, + "Central America Standard Time": { "iana": ["America/Guatemala"] }, + "Central Standard Time": { "iana": ["America/Chicago"] }, + "Easter Island Standard Time": { "iana": ["Pacific/Easter"] }, + "Central Standard Time (Mexico)": { "iana": ["America/Mexico_City"] }, + "Canada Central Standard Time": { "iana": ["America/Regina"] }, + "SA Pacific Standard Time": { "iana": ["America/Bogota"] }, + "Eastern Standard Time (Mexico)": { "iana": ["America/Cancun"] }, + "Eastern Standard Time": { "iana": ["America/New_York"] }, + "Haiti Standard Time": { "iana": ["America/Port-au-Prince"] }, + "Cuba Standard Time": { "iana": ["America/Havana"] }, + "US Eastern Standard Time": { "iana": ["America/Indianapolis"] }, + "Turks And Caicos Standard Time": { "iana": ["America/Grand_Turk"] }, + "Paraguay Standard Time": { "iana": ["America/Asuncion"] }, + "Atlantic Standard Time": { "iana": ["America/Halifax"] }, + "Venezuela Standard Time": { "iana": ["America/Caracas"] }, + "Central Brazilian Standard Time": { "iana": ["America/Cuiaba"] }, + "SA Western Standard Time": { "iana": ["America/La_Paz"] }, + "Pacific SA Standard Time": { "iana": ["America/Santiago"] }, + "Newfoundland Standard Time": { "iana": ["America/St_Johns"] }, + "Tocantins Standard Time": { "iana": ["America/Araguaina"] }, + "E. South America Standard Time": { "iana": ["America/Sao_Paulo"] }, + "SA Eastern Standard Time": { "iana": ["America/Cayenne"] }, + "Argentina Standard Time": { "iana": ["America/Buenos_Aires"] }, + "Greenland Standard Time": { "iana": ["America/Godthab"] }, + "Montevideo Standard Time": { "iana": ["America/Montevideo"] }, + "Magallanes Standard Time": { "iana": ["America/Punta_Arenas"] }, + "Saint Pierre Standard Time": { "iana": ["America/Miquelon"] }, + "Bahia Standard Time": { "iana": ["America/Bahia"] }, + "UTC-02": { "iana": ["Etc/GMT+2"] }, + "Azores Standard Time": { "iana": ["Atlantic/Azores"] }, + "Cape Verde Standard Time": { "iana": ["Atlantic/Cape_Verde"] }, + "UTC": { "iana": ["Etc/GMT"] }, + "GMT Standard Time": { "iana": ["Europe/London"] }, + "Greenwich Standard Time": { "iana": ["Atlantic/Reykjavik"] }, + "Sao Tome Standard Time": { "iana": ["Africa/Sao_Tome"] }, + "Morocco Standard Time": { "iana": ["Africa/Casablanca"] }, + "W. Europe Standard Time": { "iana": ["Europe/Berlin"] }, + "Central Europe Standard Time": { "iana": ["Europe/Budapest"] }, + "Romance Standard Time": { "iana": ["Europe/Paris"] }, + "Central European Standard Time": { "iana": ["Europe/Warsaw"] }, + "W. Central Africa Standard Time": { "iana": ["Africa/Lagos"] }, + "Jordan Standard Time": { "iana": ["Asia/Amman"] }, + "GTB Standard Time": { "iana": ["Europe/Bucharest"] }, + "Middle East Standard Time": { "iana": ["Asia/Beirut"] }, + "Egypt Standard Time": { "iana": ["Africa/Cairo"] }, + "E. Europe Standard Time": { "iana": ["Europe/Chisinau"] }, + "Syria Standard Time": { "iana": ["Asia/Damascus"] }, + "West Bank Standard Time": { "iana": ["Asia/Hebron"] }, + "South Africa Standard Time": { "iana": ["Africa/Johannesburg"] }, + "FLE Standard Time": { "iana": ["Europe/Kiev"] }, + "Israel Standard Time": { "iana": ["Asia/Jerusalem"] }, + "Kaliningrad Standard Time": { "iana": ["Europe/Kaliningrad"] }, + "Sudan Standard Time": { "iana": ["Africa/Khartoum"] }, + "Libya Standard Time": { "iana": ["Africa/Tripoli"] }, + "Namibia Standard Time": { "iana": ["Africa/Windhoek"] }, + "Arabic Standard Time": { "iana": ["Asia/Baghdad"] }, + "Turkey Standard Time": { "iana": ["Europe/Istanbul"] }, + "Arab Standard Time": { "iana": ["Asia/Riyadh"] }, + "Belarus Standard Time": { "iana": ["Europe/Minsk"] }, + "Russian Standard Time": { "iana": ["Europe/Moscow"] }, + "E. Africa Standard Time": { "iana": ["Africa/Nairobi"] }, + "Iran Standard Time": { "iana": ["Asia/Tehran"] }, + "Arabian Standard Time": { "iana": ["Asia/Dubai"] }, + "Astrakhan Standard Time": { "iana": ["Europe/Astrakhan"] }, + "Azerbaijan Standard Time": { "iana": ["Asia/Baku"] }, + "Russia Time Zone 3": { "iana": ["Europe/Samara"] }, + "Mauritius Standard Time": { "iana": ["Indian/Mauritius"] }, + "Saratov Standard Time": { "iana": ["Europe/Saratov"] }, + "Georgian Standard Time": { "iana": ["Asia/Tbilisi"] }, + "Volgograd Standard Time": { "iana": ["Europe/Volgograd"] }, + "Caucasus Standard Time": { "iana": ["Asia/Yerevan"] }, + "Afghanistan Standard Time": { "iana": ["Asia/Kabul"] }, + "West Asia Standard Time": { "iana": ["Asia/Tashkent"] }, + "Ekaterinburg Standard Time": { "iana": ["Asia/Yekaterinburg"] }, + "Pakistan Standard Time": { "iana": ["Asia/Karachi"] }, + "Qyzylorda Standard Time": { "iana": ["Asia/Qyzylorda"] }, + "India Standard Time": { "iana": ["Asia/Calcutta"] }, + "Sri Lanka Standard Time": { "iana": ["Asia/Colombo"] }, + "Nepal Standard Time": { "iana": ["Asia/Katmandu"] }, + "Central Asia Standard Time": { "iana": ["Asia/Almaty"] }, + "Bangladesh Standard Time": { "iana": ["Asia/Dhaka"] }, + "Omsk Standard Time": { "iana": ["Asia/Omsk"] }, + "Myanmar Standard Time": { "iana": ["Asia/Rangoon"] }, + "SE Asia Standard Time": { "iana": ["Asia/Bangkok"] }, + "Altai Standard Time": { "iana": ["Asia/Barnaul"] }, + "W. Mongolia Standard Time": { "iana": ["Asia/Hovd"] }, + "North Asia Standard Time": { "iana": ["Asia/Krasnoyarsk"] }, + "N. Central Asia Standard Time": { "iana": ["Asia/Novosibirsk"] }, + "Tomsk Standard Time": { "iana": ["Asia/Tomsk"] }, + "China Standard Time": { "iana": ["Asia/Shanghai"] }, + "North Asia East Standard Time": { "iana": ["Asia/Irkutsk"] }, + "Singapore Standard Time": { "iana": ["Asia/Singapore"] }, + "W. Australia Standard Time": { "iana": ["Australia/Perth"] }, + "Taipei Standard Time": { "iana": ["Asia/Taipei"] }, + "Ulaanbaatar Standard Time": { "iana": ["Asia/Ulaanbaatar"] }, + "Aus Central W. Standard Time": { "iana": ["Australia/Eucla"] }, + "Transbaikal Standard Time": { "iana": ["Asia/Chita"] }, + "Tokyo Standard Time": { "iana": ["Asia/Tokyo"] }, + "North Korea Standard Time": { "iana": ["Asia/Pyongyang"] }, + "Korea Standard Time": { "iana": ["Asia/Seoul"] }, + "Yakutsk Standard Time": { "iana": ["Asia/Yakutsk"] }, + "Cen. Australia Standard Time": { "iana": ["Australia/Adelaide"] }, + "AUS Central Standard Time": { "iana": ["Australia/Darwin"] }, + "E. Australia Standard Time": { "iana": ["Australia/Brisbane"] }, + "AUS Eastern Standard Time": { "iana": ["Australia/Sydney"] }, + "West Pacific Standard Time": { "iana": ["Pacific/Port_Moresby"] }, + "Tasmania Standard Time": { "iana": ["Australia/Hobart"] }, + "Vladivostok Standard Time": { "iana": ["Asia/Vladivostok"] }, + "Lord Howe Standard Time": { "iana": ["Australia/Lord_Howe"] }, + "Bougainville Standard Time": { "iana": ["Pacific/Bougainville"] }, + "Russia Time Zone 10": { "iana": ["Asia/Srednekolymsk"] }, + "Magadan Standard Time": { "iana": ["Asia/Magadan"] }, + "Norfolk Standard Time": { "iana": ["Pacific/Norfolk"] }, + "Sakhalin Standard Time": { "iana": ["Asia/Sakhalin"] }, + "Central Pacific Standard Time": { "iana": ["Pacific/Guadalcanal"] }, + "Russia Time Zone 11": { "iana": ["Asia/Kamchatka"] }, + "New Zealand Standard Time": { "iana": ["Pacific/Auckland"] }, + "UTC+12": { "iana": ["Etc/GMT-12"] }, + "Fiji Standard Time": { "iana": ["Pacific/Fiji"] }, + "Chatham Islands Standard Time": { "iana": ["Pacific/Chatham"] }, + "UTC+13": { "iana": ["Etc/GMT-13"] }, + "Tonga Standard Time": { "iana": ["Pacific/Tongatapu"] }, + "Samoa Standard Time": { "iana": ["Pacific/Apia"] }, + "Line Islands Standard Time": { "iana": ["Pacific/Kiritimati"] }, + "(UTC-12:00) International Date Line West": { "iana": ["Etc/GMT+12"] }, + "(UTC-11:00) Midway Island, Samoa": { "iana": ["Pacific/Apia"] }, + "(UTC-10:00) Hawaii": { "iana": ["Pacific/Honolulu"] }, + "(UTC-09:00) Alaska": { "iana": ["America/Anchorage"] }, + "(UTC-08:00) Pacific Time (US & Canada); Tijuana": { "iana": ["America/Los_Angeles"] }, + "(UTC-08:00) Pacific Time (US and Canada); Tijuana": { "iana": ["America/Los_Angeles"] }, + "(UTC-07:00) Mountain Time (US & Canada)": { "iana": ["America/Denver"] }, + "(UTC-07:00) Mountain Time (US and Canada)": { "iana": ["America/Denver"] }, + "(UTC-07:00) Chihuahua, La Paz, Mazatlan": { "iana": [null] }, + "(UTC-07:00) Arizona": { "iana": ["America/Phoenix"] }, + "(UTC-06:00) Central Time (US & Canada)": { "iana": ["America/Chicago"] }, + "(UTC-06:00) Central Time (US and Canada)": { "iana": ["America/Chicago"] }, + "(UTC-06:00) Saskatchewan": { "iana": ["America/Regina"] }, + "(UTC-06:00) Guadalajara, Mexico City, Monterrey": { "iana": [null] }, + "(UTC-06:00) Central America": { "iana": ["America/Guatemala"] }, + "(UTC-05:00) Eastern Time (US & Canada)": { "iana": ["America/New_York"] }, + "(UTC-05:00) Eastern Time (US and Canada)": { "iana": ["America/New_York"] }, + "(UTC-05:00) Indiana (East)": { "iana": ["America/Indianapolis"] }, + "(UTC-05:00) Bogota, Lima, Quito": { "iana": ["America/Bogota"] }, + "(UTC-04:00) Atlantic Time (Canada)": { "iana": ["America/Halifax"] }, + "(UTC-04:00) Georgetown, La Paz, San Juan": { "iana": ["America/La_Paz"] }, + "(UTC-04:00) Santiago": { "iana": ["America/Santiago"] }, + "(UTC-03:30) Newfoundland": { "iana": [null] }, + "(UTC-03:00) Brasilia": { "iana": ["America/Sao_Paulo"] }, + "(UTC-03:00) Georgetown": { "iana": ["America/Cayenne"] }, + "(UTC-03:00) Greenland": { "iana": ["America/Godthab"] }, + "(UTC-02:00) Mid-Atlantic": { "iana": [null] }, + "(UTC-01:00) Azores": { "iana": ["Atlantic/Azores"] }, + "(UTC-01:00) Cape Verde Islands": { "iana": ["Atlantic/Cape_Verde"] }, + "(UTC) Greenwich Mean Time: Dublin, Edinburgh, Lisbon, London": { "iana": [null] }, + "(UTC) Monrovia, Reykjavik": { "iana": ["Atlantic/Reykjavik"] }, + "(UTC+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague": { "iana": ["Europe/Budapest"] }, + "(UTC+01:00) Sarajevo, Skopje, Warsaw, Zagreb": { "iana": ["Europe/Warsaw"] }, + "(UTC+01:00) Brussels, Copenhagen, Madrid, Paris": { "iana": ["Europe/Paris"] }, + "(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna": { "iana": ["Europe/Berlin"] }, + "(UTC+01:00) West Central Africa": { "iana": ["Africa/Lagos"] }, + "(UTC+02:00) Minsk": { "iana": ["Europe/Chisinau"] }, + "(UTC+02:00) Cairo": { "iana": ["Africa/Cairo"] }, + "(UTC+02:00) Helsinki, Kiev, Riga, Sofia, Tallinn, Vilnius": { "iana": ["Europe/Kiev"] }, + "(UTC+02:00) Athens, Bucharest, Istanbul": { "iana": ["Europe/Bucharest"] }, + "(UTC+02:00) Jerusalem": { "iana": ["Asia/Jerusalem"] }, + "(UTC+02:00) Harare, Pretoria": { "iana": ["Africa/Johannesburg"] }, + "(UTC+03:00) Moscow, St. Petersburg, Volgograd": { "iana": ["Europe/Moscow"] }, + "(UTC+03:00) Kuwait, Riyadh": { "iana": ["Asia/Riyadh"] }, + "(UTC+03:00) Nairobi": { "iana": ["Africa/Nairobi"] }, + "(UTC+03:00) Baghdad": { "iana": ["Asia/Baghdad"] }, + "(UTC+03:30) Tehran": { "iana": ["Asia/Tehran"] }, + "(UTC+04:00) Abu Dhabi, Muscat": { "iana": ["Asia/Dubai"] }, + "(UTC+04:00) Baku, Tbilisi, Yerevan": { "iana": ["Asia/Yerevan"] }, + "(UTC+04:30) Kabul": { "iana": [null] }, + "(UTC+05:00) Ekaterinburg": { "iana": ["Asia/Yekaterinburg"] }, + "(UTC+05:00) Tashkent": { "iana": ["Asia/Tashkent"] }, + "(UTC+05:30) Chennai, Kolkata, Mumbai, New Delhi": { "iana": ["Asia/Calcutta"] }, + "(UTC+05:45) Kathmandu": { "iana": ["Asia/Katmandu"] }, + "(UTC+06:00) Astana, Dhaka": { "iana": ["Asia/Almaty"] }, + "(UTC+06:00) Sri Jayawardenepura": { "iana": ["Asia/Colombo"] }, + "(UTC+06:00) Almaty, Novosibirsk": { "iana": ["Asia/Novosibirsk"] }, + "(UTC+06:30) Yangon (Rangoon)": { "iana": ["Asia/Rangoon"] }, + "(UTC+07:00) Bangkok, Hanoi, Jakarta": { "iana": ["Asia/Bangkok"] }, + "(UTC+07:00) Krasnoyarsk": { "iana": ["Asia/Krasnoyarsk"] }, + "(UTC+08:00) Beijing, Chongqing, Hong Kong, Urumqi": { "iana": ["Asia/Shanghai"] }, + "(UTC+08:00) Kuala Lumpur, Singapore": { "iana": ["Asia/Singapore"] }, + "(UTC+08:00) Taipei": { "iana": ["Asia/Taipei"] }, + "(UTC+08:00) Perth": { "iana": ["Australia/Perth"] }, + "(UTC+08:00) Irkutsk, Ulaanbaatar": { "iana": ["Asia/Irkutsk"] }, + "(UTC+09:00) Seoul": { "iana": ["Asia/Seoul"] }, + "(UTC+09:00) Osaka, Sapporo, Tokyo": { "iana": ["Asia/Tokyo"] }, + "(UTC+09:00) Yakutsk": { "iana": ["Asia/Yakutsk"] }, + "(UTC+09:30) Darwin": { "iana": ["Australia/Darwin"] }, + "(UTC+09:30) Adelaide": { "iana": ["Australia/Adelaide"] }, + "(UTC+10:00) Canberra, Melbourne, Sydney": { "iana": ["Australia/Sydney"] }, + "(GMT+10:00) Canberra, Melbourne, Sydney": { "iana": ["Australia/Sydney"] }, + "(UTC+10:00) Brisbane": { "iana": ["Australia/Brisbane"] }, + "(UTC+10:00) Hobart": { "iana": ["Australia/Hobart"] }, + "(UTC+10:00) Vladivostok": { "iana": ["Asia/Vladivostok"] }, + "(UTC+10:00) Guam, Port Moresby": { "iana": ["Pacific/Port_Moresby"] }, + "(UTC+11:00) Magadan, Solomon Islands, New Caledonia": { "iana": ["Pacific/Guadalcanal"] }, + "(UTC+12:00) Fiji, Kamchatka, Marshall Is.": { "iana": [null] }, + "(UTC+12:00) Auckland, Wellington": { "iana": ["Pacific/Auckland"] }, + "(UTC+13:00) Nuku'alofa": { "iana": ["Pacific/Tongatapu"] }, + "(UTC-03:00) Buenos Aires": { "iana": ["America/Buenos_Aires"] }, + "(UTC+02:00) Beirut": { "iana": ["Asia/Beirut"] }, + "(UTC+02:00) Amman": { "iana": ["Asia/Amman"] }, + "(UTC-06:00) Guadalajara, Mexico City, Monterrey - New": { "iana": ["America/Mexico_City"] }, + "(UTC-07:00) Chihuahua, La Paz, Mazatlan - New": { "iana": ["America/Chihuahua"] }, + "(UTC-08:00) Tijuana, Baja California": { "iana": ["America/Tijuana"] }, + "(UTC+02:00) Windhoek": { "iana": ["Africa/Windhoek"] }, + "(UTC+03:00) Tbilisi": { "iana": ["Asia/Tbilisi"] }, + "(UTC-04:00) Manaus": { "iana": ["America/Cuiaba"] }, + "(UTC-03:00) Montevideo": { "iana": ["America/Montevideo"] }, + "(UTC+04:00) Yerevan": { "iana": [null] }, + "(UTC-04:30) Caracas": { "iana": ["America/Caracas"] }, + "(UTC) Casablanca": { "iana": ["Africa/Casablanca"] }, + "(UTC+05:00) Islamabad, Karachi": { "iana": ["Asia/Karachi"] }, + "(UTC+04:00) Port Louis": { "iana": ["Indian/Mauritius"] }, + "(UTC) Coordinated Universal Time": { "iana": ["Etc/GMT"] }, + "(UTC-04:00) Asuncion": { "iana": ["America/Asuncion"] }, + "(UTC+12:00) Petropavlovsk-Kamchatsky": { "iana": [null] } +} diff --git a/modules/default/newsfeed/newsfeed.js b/modules/default/newsfeed/newsfeed.js index 348a0d3361..602c4a9af4 100644 --- a/modules/default/newsfeed/newsfeed.js +++ b/modules/default/newsfeed/newsfeed.js @@ -227,6 +227,8 @@ Module.register("newsfeed", { return true; }, this); } + + newsItems.forEach((item) => { //Remove selected tags from the beginning of rss feed items (title or description) if (this.config.removeStartTags === "title" || this.config.removeStartTags === "both") { diff --git a/modules/default/weather/current.njk.wip b/modules/default/weather/current.njk.wip new file mode 100644 index 0000000000..6e29aa2611 --- /dev/null +++ b/modules/default/weather/current.njk.wip @@ -0,0 +1,104 @@ +{% macro humidity() %} + {% if current.humidity %} + {{ current.humidity | decimalSymbol }}  + {% endif %} +{% endmacro %} +{% if current %} + {% if not config.onlyTemp %} +
+ + + {{ current.windSpeed | unit("wind") | round }} + {% if config.showWindDirection %} + + {% if config.showWindDirectionAsArrow %} + + {% else %} + {{ current.cardinalWindDirection() | translate }} + {% endif %} +   + + {% endif %} + + {% if config.showHumidity === "wind" %} + {{ humidity() }} + {% endif %} + {% if config.showSun %} + + + {% if current.nextSunAction() === "sunset" %} + {{ current.sunset | formatTime }} + {% else %} + {{ current.sunrise | formatTime }} + {% endif %} + + {% endif %} + {% if config.showUVIndex %} + +
+ {{ current.uv_index }} + + {% endif %} +
+ {% endif %} +
+ {% if config.showIndoorTemperature and indoor.temperature or config.showIndoorHumidity and indoor.humidity %} + + + {% if config.showIndoorTemperature and indoor.temperature %} + + {{ indoor.temperature | roundValue | unit("temperature") | decimalSymbol }} + + {% endif %} + {% if config.showIndoorHumidity and indoor.humidity %} + + {{ indoor.humidity | roundValue | unit("humidity") | decimalSymbol }} + + {% endif %} + + {% endif %} + + {% if current.weatherType %} + + {{ current.iconHTML | safe }} + + {% endif %} + + + {{ current.temperature | roundValue | unit("temperature") | decimalSymbol }} + + + {% if config.showHumidity === "temp" %} + {{ humidity() }} + {% endif %} +
+ {% if (config.showFeelsLike or config.showPrecipitationAmount or config.showPrecipitationProbability) and not config.onlyTemp %} +
+ {% if config.showFeelsLike %} + + {% if config.showHumidity === "feelslike" %} + {{ humidity() }} + {% endif %} + {{ "FEELS" | translate({DEGREE: current.feelsLike() | roundValue | unit("temperature") | decimalSymbol }) }} + +
+ {% endif %} + {% if config.showPrecipitationAmount and current.precipitationAmount %} + {{ "PRECIP_AMOUNT" | translate }} {{ current.precipitationAmount | unit("precip", current.precipitationUnits) }} +
+ {% endif %} + {% if config.showPrecipitationProbability and current.precipitationProbability %} + {{ "PRECIP_POP" | translate }} {{ current.precipitationProbability }}% + {% endif %} +
+ {% endif %} + {% if config.showHumidity === "below" %} + {{ humidity() }} + {% endif %} +{% else %} +
{{ "LOADING" | translate }}
+{% endif %} + + diff --git a/modules/default/weather/custom-weather-icons.css b/modules/default/weather/custom-weather-icons.css new file mode 100644 index 0000000000..703a5eb498 --- /dev/null +++ b/modules/default/weather/custom-weather-icons.css @@ -0,0 +1,381 @@ +.icon { + position: relative; + display: inline-block; + width: 12em; + height: 10em; + font-size: 1em; /* control icon size here */ +} + +/* === Moon / Night icon (for clear-night, etc.) === */ +.icon.night { + position: relative; + display: inline-block; + width: 12em; /* match other icons */ + height: 10em; /* match other icons */ + font-size: 1em; +} + +.icon.night .moon { + position: absolute; + top: 50%; + left: 50%; + width: 5.5em; + height: 5.5em; + background: #fff; + border-radius: 50%; + box-shadow: inset -1em 1em 0 0 #aaa; + transform: translate(-50%, -50%); /* 👈 center it exactly like sun/cloud */ +} + +.icon.night .moon .crater { + position: absolute; + background: #ccc; + border-radius: 50%; + width: 0.8em; + height: 0.8em; + top: 1em; + left: 2.2em; + box-shadow: + 1.2em 0.8em 0 0.1em #bbb, + 0.4em 1.8em 0 0.1em #bbb; +} + +/* Fog icon from qPKoyB */ +.fog { + position: absolute; + top: 50%; + left: 50%; + margin: 1.75em 0 0; +} + +.fog::before, +.fog::after { + content: ""; + position: absolute; + left: 50%; + width: 5em; + height: 0.5em; + margin-left: -2.5em; + color: rgb(255 255 255 / 25%); + background: currentcolor; + border-radius: 0.5em; + animation: fog 6s infinite linear alternate; +} + +.fog::before { + top: 0.75em; +} + +.fog::after { + top: 1.5em; + animation-delay: -6s; +} + +/* Animation keyframe from the pen */ +@keyframes fog { + 0% { + transform: translateX(-0.5em); + } + + 100% { + transform: translateX(0.5em); + } +} + +.cloud { + position: absolute; + z-index: 1; + top: 50%; + left: 50%; + width: 3.6875em; + height: 3.6875em; + margin: -1.8438em; + background: currentcolor; + border-radius: 50%; + box-shadow: + -2.1875em 0.6875em 0 -0.6875em, + 2.0625em 0.9375em 0 -0.9375em, + 0 0 0 0.375em #fff, + -2.1875em 0.6875em 0 -0.3125em #fff, + 2.0625em 0.9375em 0 -0.5625em #fff; +} + +.cloud::after { + content: ""; + position: absolute; + bottom: 0; + left: -0.5em; + display: block; + width: 4.5625em; + height: 1em; + background: currentcolor; + box-shadow: 0 0.4375em 0 -0.0625em #fff; +} + +.cloud:nth-child(2) { + z-index: 0; + background: #fff; + box-shadow: + -2.1875em 0.6875em 0 -0.6875em #fff, + 2.0625em 0.9375em 0 -0.9375em #fff, + 0 0 0 0.375em #fff, + -2.1875em 0.6875em 0 -0.3125em #fff, + 2.0625em 0.9375em 0 -0.5625em #fff; + opacity: 0.3; + transform: scale(0.5) translate(6em, -3em); + animation: cloud 4s linear infinite; +} + +.cloud:nth-child(2)::after { + background: #fff; +} + +.sun { + position: absolute; + top: 50%; + left: 50%; + width: 2.5em; + height: 2.5em; + margin: -1.25em; + background: currentcolor; + border-radius: 50%; + box-shadow: 0 0 0 0.375em #fff; + animation: spin 12s infinite linear; +} + +.rays { + position: absolute; + top: -2em; + left: 50%; + display: block; + width: 0.375em; + height: 1.125em; + margin-left: -0.1875em; + background: #fff; + border-radius: 0.25em; + box-shadow: 0 5.375em #fff; +} + +.rays::before, +.rays::after { + content: ""; + position: absolute; + top: 0; + left: 0; + display: block; + width: 0.375em; + height: 1.125em; + transform: rotate(60deg); + transform-origin: 50% 3.25em; + background: #fff; + border-radius: 0.25em; + box-shadow: 0 5.375em #fff; +} + +.rays::before { + transform: rotate(120deg); +} + +.cloud + .sun { + margin: -2em 1em; +} + +.rain, +.lightning, +.snow { + position: absolute; + z-index: 2; + top: 50%; + left: 50%; + width: 3.75em; + height: 3.75em; + margin: 0.375em 0 0 -2em; + background: currentcolor; +} + +.rain::after { + content: ""; + position: absolute; + z-index: 2; + top: 50%; + left: 50%; + width: 1.125em; + height: 1.125em; + margin: -1em 0 0 -0.25em; + background: #0cf; + border-radius: 100% 0 60% 50% / 60% 0 100% 50%; + box-shadow: + 0.625em 0.875em 0 -0.125em rgb(255 255 255 / 20%), + -0.875em 1.125em 0 -0.125em rgb(255 255 255 / 20%), + -1.375em -0.125em 0 rgb(255 255 255 / 20%); + transform: rotate(-28deg); + animation: rain 3s linear infinite; +} + +.bolt { + position: absolute; + top: 50%; + left: 50%; + margin: -0.25em 0 0 -0.125em; + color: #fff; + opacity: 0.3; + animation: lightning 2s linear infinite; +} + +.bolt:nth-child(2) { + width: 0.5em; + height: 0.25em; + margin: -1.75em 0 0 -1.875em; + transform: translate(2.5em, 2.25em); + opacity: 0.2; + animation: lightning 1.5s linear infinite; +} + +.bolt::before, +.bolt::after { + content: ""; + position: absolute; + z-index: 2; + top: 50%; + left: 50%; + margin: -1.625em 0 0 -1.0125em; + border-top: 1.25em solid transparent; + border-right: 0.75em solid; + border-bottom: 0.75em solid; + border-left: 0.5em solid transparent; + transform: skewX(-10deg); +} + +.bolt::after { + margin: -0.25em 0 0 -0.25em; + border-top: 0.75em solid; + border-right: 0.5em solid transparent; + border-bottom: 1.25em solid transparent; + border-left: 0.75em solid; + transform: skewX(-10deg); +} + +.bolt:nth-child(2)::before { + margin: -0.75em 0 0 -0.5em; + border-top: 0.625em solid transparent; + border-right: 0.375em solid; + border-bottom: 0.375em solid; + border-left: 0.25em solid transparent; +} + +.bolt:nth-child(2)::after { + margin: -0.125em 0 0 -0.125em; + border-top: 0.375em solid; + border-right: 0.25em solid transparent; + border-bottom: 0.625em solid transparent; + border-left: 0.375em solid; +} + +.flake::before, +.flake::after { + content: "\2744"; + position: absolute; + top: 50%; + left: 50%; + margin: -1.025em 0 0 -1.0125em; + color: #fff; + line-height: 1em; + opacity: 0.2; + animation: spin 8s linear infinite reverse; +} + +.flake::after { + margin: 0.125em 0 0 -1em; + font-size: 1.5em; + opacity: 0.4; + animation: spin 14s linear infinite; +} + +.flake:nth-child(2)::before { + margin: -0.5em 0 0 0.25em; + font-size: 1.25em; + opacity: 0.2; + animation: spin 10s linear infinite; +} + +.flake:nth-child(2)::after { + margin: 0.375em 0 0 0.125em; + font-size: 2em; + opacity: 0.4; + animation: spin 16s linear infinite reverse; +} + +/* Animations */ + +@keyframes spin { + 100% { + transform: rotate(360deg); + } +} + +@keyframes cloud { + 0% { + opacity: 0; + } + + 50% { + opacity: 0.3; + } + + 100% { + opacity: 0; + transform: scale(0.5) translate(-200%, -3em); + } +} + +@keyframes rain { + 0% { + background: #0cf; + box-shadow: + 0.625em 0.875em 0 -0.125em rgb(255 255 255 / 20%), + -0.875em 1.125em 0 -0.125em rgb(255 255 255 / 20%), + -1.375em -0.125em 0 #0cf; + } + + 25% { + box-shadow: + 0.625em 0.875em 0 -0.125em rgb(255 255 255 / 20%), + -0.875em 1.125em 0 -0.125em #0cf, + -1.375em -0.125em 0 rgb(255 255 255 / 20%); + } + + 50% { + background: rgb(255 255 255 / 30%); + box-shadow: + 0.625em 0.875em 0 -0.125em #0cf, + -0.875em 1.125em 0 -0.125em rgb(255 255 255 / 20%), + -1.375em -0.125em 0 rgb(255 255 255 / 20%); + } + + 100% { + box-shadow: + 0.625em 0.875em 0 -0.125em rgb(255 255 255 / 20%), + -0.875em 1.125em 0 -0.125em rgb(255 255 255 / 20%), + -1.375em -0.125em 0 #0cf; + } +} + +@keyframes lightning { + 45% { + color: #fff; + background: #fff; + opacity: 0.2; + } + + 50% { + color: #0cf; + background: #0cf; + opacity: 1; + } + + 55% { + color: #fff; + background: #fff; + opacity: 0.2; + } +} diff --git a/modules/default/weather/custom-weather-icons.css.wip b/modules/default/weather/custom-weather-icons.css.wip new file mode 100644 index 0000000000..a32e955d0b --- /dev/null +++ b/modules/default/weather/custom-weather-icons.css.wip @@ -0,0 +1,370 @@ +.icon { + position: relative; + display: inline-block; + width: 12em; + height: 10em; + font-size: 1em; /* control icon size here */ +} + +.cloud { + position: absolute; + z-index: 1; + top: 50%; + left: 50%; + width: 3.6875em; + height: 3.6875em; + margin: -1.84375em; + background: #000; + border-radius: 50%; + box-shadow: + -2.1875em 0.6875em 0 -0.6875em #000, + 2.0625em 0.9375em 0 -0.9375em #000, + 0 0 0 0.375em #fff, + -2.1875em 0.6875em 0 -0.3125em #fff, + 2.0625em 0.9375em 0 -0.5625em #fff; +} +.cloud:after { + content: ''; + position: absolute; + bottom: 0; + left: -0.5em; + display: block; + width: 4.5625em; + height: 1em; + background: #000; + box-shadow: 0 0.4375em 0 -0.0625em #fff; +} +.cloud:nth-child(2) { + z-index: 0; + background: #fff; + box-shadow: + -2.1875em 0.6875em 0 -0.6875em #fff, + 2.0625em 0.9375em 0 -0.9375em #fff, + 0 0 0 0.375em #fff, + -2.1875em 0.6875em 0 -0.3125em #fff, + 2.0625em 0.9375em 0 -0.5625em #fff; + opacity: 0.3; + transform: scale(0.5) translate(6em, -3em); + animation: cloud 4s linear infinite; +} +.cloud:nth-child(2):after { background: #fff; } + +.sun { + position: absolute; + top: 50%; + left: 50%; + width: 2.5em; + height: 2.5em; + margin: -1.25em; + background: #000; + border-radius: 50%; + box-shadow: 0 0 0 0.375em #fff; + animation: spin 12s infinite linear; +} +.rays { + position: absolute; + top: -2em; + left: 50%; + display: block; + width: 0.375em; + height: 1.125em; + margin-left: -0.1875em; + background: #fff; + border-radius: 0.25em; + box-shadow: 0 5.375em #fff; +} +.rays:before, +.rays:after { + content: ''; + position: absolute; + top: 0em; + left: 0em; + display: block; + width: 0.375em; + height: 1.125em; + transform: rotate(60deg); + transform-origin: 50% 3.25em; + background: #fff; + border-radius: 0.25em; + box-shadow: 0 5.375em #fff; +} +.rays:before { + transform: rotate(120deg); +} +.cloud + .sun { + margin: -2em 1em; +} + +.fog { + position: absolute; + z-index: 2; + top: 50%; + left: 50%; + width: 3.75em; + height: 3.75em; + margin: 0.375em 0 0 -2em; + background: #000; +} + +.rain, +.lightning, +.snow { + position: absolute; + z-index: 2; + top: 50%; + left: 50%; + width: 3.75em; + height: 3.75em; + margin: 0.375em 0 0 -2em; + background: #000; +} + +.rain:after { + content: ''; + position: absolute; + z-index: 2; + top: 50%; + left: 50%; + width: 1.125em; + height: 1.125em; + margin: -1em 0 0 -0.25em; + background: #0cf; + border-radius: 100% 0 60% 50% / 60% 0 100% 50%; + box-shadow: + 0.625em 0.875em 0 -0.125em rgba(255,255,255,0.2), + -0.875em 1.125em 0 -0.125em rgba(255,255,255,0.2), + -1.375em -0.125em 0 rgba(255,255,255,0.2); + transform: rotate(-28deg); + animation: rain 3s linear infinite; +} + +.bolt { + position: absolute; + top: 50%; + left: 50%; + margin: -0.25em 0 0 -0.125em; + color: #fff; + opacity: 0.3; + animation: lightning 2s linear infinite; +} +.bolt:nth-child(2) { + width: 0.5em; + height: 0.25em; + margin: -1.75em 0 0 -1.875em; + transform: translate(2.5em, 2.25em); + opacity: 0.2; + animation: lightning 1.5s linear infinite; +} +.bolt:before, +.bolt:after { + content: ''; + position: absolute; + z-index: 2; + top: 50%; + left: 50%; + margin: -1.625em 0 0 -1.0125em; + border-top: 1.25em solid transparent; + border-right: 0.75em solid; + border-bottom: 0.75em solid; + border-left: 0.5em solid transparent; + transform: skewX(-10deg); +} +.bolt:after { + margin: -0.25em 0 0 -0.25em; + border-top: 0.75em solid; + border-right: 0.5em solid transparent; + border-bottom: 1.25em solid transparent; + border-left: 0.75em solid; + transform: skewX(-10deg); +} +.bolt:nth-child(2):before { + margin: -0.75em 0 0 -0.5em; + border-top: 0.625em solid transparent; + border-right: 0.375em solid; + border-bottom: 0.375em solid; + border-left: 0.25em solid transparent; +} +.bolt:nth-child(2):after { + margin: -0.125em 0 0 -0.125em; + border-top: 0.375em solid; + border-right: 0.25em solid transparent; + border-bottom: 0.625em solid transparent; + border-left: 0.375em solid; +} + +.flake:before, +.flake:after { + content: '\2744'; + position: absolute; + top: 50%; + left: 50%; + margin: -1.025em 0 0 -1.0125em; + color: #fff; + list-height: 1em; + opacity: 0.2; + animation: spin 8s linear infinite reverse; +} +.flake:after { + margin: 0.125em 0 0 -1em; + font-size: 1.5em; + opacity: 0.4; + animation: spin 14s linear infinite; +} +.flake:nth-child(2):before { + margin: -0.5em 0 0 0.25em; + font-size: 1.25em; + opacity: 0.2; + animation: spin 10s linear infinite; +} +.flake:nth-child(2):after { + margin: 0.375em 0 0 0.125em; + font-size: 2em; + opacity: 0.4; + animation: spin 16s linear infinite reverse; +} + +.moon { + position: absolute; + top: 50%; + left: 50%; + width: 3em; + height: 3em; + margin: -1.5em; + background: #000; + border-radius: 50%; + box-shadow: + inset -0.75em 0 rgba(255,255,255,0.2), + 0 0 0 0.375em #fff; +} + +.stars, +.stars:before, +.stars:after { + content: ''; + position: absolute; + top: 25%; + left: 70%; + width: 0.25em; + height: 0.25em; + margin: -0.125em; + color: rgba(255,255,255,0.5); + background: #000; + border-radius: 50%; + box-shadow: -4.5em 5em; + animation: twinkle 5s steps(2) infinite; +} + +.stars:before { + top: 1em; + left: 0.25em; + box-shadow: -3.75em 4em; + opacity: 0.5; + animation: twinkle 4s steps(2) infinite; +} + +.stars:after { + top: 0.5em; + left: -0.75em; + box-shadow: -3.75em 3.75em; + opacity: 0.25; + animation: twinkle 3s steps(2) infinite; +} + +/*.fog { + position: absolute; + top: 50%; + left: 50%; + margin: 1.75em 0 0 0; +}*/ + +.fog::before, +.fog::after { + content: ''; + position: absolute; + left: 50%; + width: 5em; + height: 0.5em; + margin-left: -2.5em; + color: rgba(255,255,255,0.25); + background: currentColor; + border-radius: 0.5em; + animation: fog 6s infinite linear alternate; +} + +.fog::before { top: 0.75em; } + +.fog::after { + top: 1.5em; + animation-delay: -6s; +} + +/* Animations */ + +@keyframes spin { + 100% { transform: rotate(360deg); } +} + +@keyframes cloud { + 0% { opacity: 0; } + 50% { opacity: 0.3; } + 100% { + opacity: 0; + transform: scale(0.5) translate(-200%, -3em); + } +} + +@keyframes rain { + 0% { + background: #0cf; + box-shadow: + 0.625em 0.875em 0 -0.125em rgba(255,255,255,0.2), + -0.875em 1.125em 0 -0.125em rgba(255,255,255,0.2), + -1.375em -0.125em 0 #0cf; + } + 25% { + box-shadow: + 0.625em 0.875em 0 -0.125em rgba(255,255,255,0.2), + -0.875em 1.125em 0 -0.125em #0cf, + -1.375em -0.125em 0 rgba(255,255,255,0.2); + } + 50% { + background: rgba(255,255,255,0.3); + box-shadow: + 0.625em 0.875em 0 -0.125em #0cf, + -0.875em 1.125em 0 -0.125em rgba(255,255,255,0.2), + -1.375em -0.125em 0 rgba(255,255,255,0.2); + } + 100% { + box-shadow: + 0.625em 0.875em 0 -0.125em rgba(255,255,255,0.2), + -0.875em 1.125em 0 -0.125em rgba(255,255,255,0.2), + -1.375em -0.125em 0 #0cf; + } +} + +@keyframes lightning { + 45% { + color: #fff; + background: #fff; + opacity: 0.2; + } + 50% { + color: #0cf; + background: #0cf; + opacity: 1; + } + 55% { + color: #fff; + background: #fff; + opacity: 0.2; + } +} + +@keyframes twinkle { + 100% { color: rgba(255,255,255,0.2); } +} + +@keyframes fog { + 0% { transform: translateX(-0.5em); } + 100% { transform: translateX(0.5em); } +} diff --git a/modules/default/weather/forecast.njk.wip b/modules/default/weather/forecast.njk.wip new file mode 100644 index 0000000000..dffaa9b0ce --- /dev/null +++ b/modules/default/weather/forecast.njk.wip @@ -0,0 +1,46 @@ +{% if forecast %} + {% set numSteps = forecast | calcNumSteps %} + {% set currentStep = 0 %} + + {% if config.ignoreToday %} + {% set forecast = forecast.splice(1) %} + {% endif %} + {% set forecast = forecast.slice(0, numSteps) %} + {% for f in forecast %} + + {% if (currentStep == 0) and config.ignoreToday == false and config.absoluteDates == false %} + + {% elif (currentStep == 1) and config.ignoreToday == false and config.absoluteDates == false %} + + {% else %} + + {% endif %} + + + + {% if config.showPrecipitationAmount %} + + {% endif %} + {% if config.showPrecipitationProbability %} + + {% endif %} + {% if config.showUVIndex %} + + {% endif %} + + {% set currentStep = currentStep + 1 %} + {% endfor %} +
{{ "TODAY" | translate }}{{ "TOMORROW" | translate }}{{ f.date.format("ddd") }} + {{ f.iconHTML | safe }} + {{ f.maxTemperature | roundValue | unit("temperature") | decimalSymbol }}{{ f.minTemperature | roundValue | unit("temperature") | decimalSymbol }}{{ f.precipitationAmount | unit("precip", f.precipitationUnits) }}{{ f.precipitationProbability | unit('precip', '%') }} + {{ f.uv_index }} + +
+{% else %} +
{{ "LOADING" | translate }}
+{% endif %} + + diff --git a/modules/default/weather/weather.js.wip b/modules/default/weather/weather.js.wip new file mode 100644 index 0000000000..f028811962 --- /dev/null +++ b/modules/default/weather/weather.js.wip @@ -0,0 +1,421 @@ +/* global WeatherProvider, WeatherUtils, formatTime */ + +Module.register("weather", { + // Default module config. + defaults: { + weatherProvider: "openweathermap", + roundTemp: false, + type: "current", // current, forecast, daily (equivalent to forecast), hourly (only with OpenWeatherMap /onecall endpoint) + lang: config.language, + units: config.units, + tempUnits: config.units, + windUnits: config.units, + timeFormat: config.timeFormat, + updateInterval: 10 * 60 * 1000, // every 10 minutes + animationSpeed: 1000, + showFeelsLike: true, + showHumidity: "none", // possible options for "current" weather are "none", "wind", "temp", "feelslike" or "below", for "hourly" weather "none" or "true" + hideZeroes: false, // hide zeroes (and empty columns) in hourly, currently only for precipitation + showIndoorHumidity: false, + showIndoorTemperature: false, + allowOverrideNotification: false, + showPeriod: true, + showPeriodUpper: false, + showPrecipitationAmount: false, + showPrecipitationProbability: false, + showUVIndex: false, + showSun: true, + showWindDirection: true, + showWindDirectionAsArrow: false, + degreeLabel: false, + decimalSymbol: ".", + maxNumberOfDays: 5, + maxEntries: 5, + ignoreToday: false, + fade: true, + fadePoint: 0.25, // Start on 1/4th of the list. + initialLoadDelay: 0, // 0 seconds delay + appendLocationNameToHeader: true, + calendarClass: "calendar", + tableClass: "small", + onlyTemp: false, + colored: false, + absoluteDates: false, + hourlyForecastIncrements: 1 + }, + + // Module properties. + weatherProvider: null, + + // Can be used by the provider to display location of event if nothing else is specified + firstEvent: null, + + // Define required scripts. + getStyles () { + return ["font-awesome.css", "weather-icons.css", "weather.css", "custom-weather-icons.css"]; + }, + + // Return the scripts that are necessary for the weather module. + getScripts () { + return ["moment.js", "weatherutils.js", "weatherobject.js", this.file("providers/overrideWrapper.js"), "weatherprovider.js", "suncalc.js", this.file(`providers/${this.config.weatherProvider.toLowerCase()}.js`)]; + }, + + // Override getHeader method. + getHeader () { + if (this.config.appendLocationNameToHeader && this.weatherProvider) { + if (this.data.header) return `${this.data.header} ${this.weatherProvider.fetchedLocation()}`; + else return this.weatherProvider.fetchedLocation(); + } + + return this.data.header ? this.data.header : ""; + }, + + // Start the weather module. + start () { + moment.locale(this.config.lang); + + if (this.config.useKmh) { + Log.warn("Your are using the deprecated config values 'useKmh'. Please switch to windUnits!"); + this.windUnits = "kmh"; + } else if (this.config.useBeaufort) { + Log.warn("Your are using the deprecated config values 'useBeaufort'. Please switch to windUnits!"); + this.windUnits = "beaufort"; + } + if (typeof this.config.showHumidity === "boolean") { + Log.warn("[weather] Deprecation warning: Please consider updating showHumidity to the new style (config string)."); + this.config.showHumidity = this.config.showHumidity ? "wind" : "none"; + } + + // Initialize the weather provider. + this.weatherProvider = WeatherProvider.initialize(this.config.weatherProvider, this); + + // Let the weather provider know we are starting. + this.weatherProvider.start(); + + // Add custom filters + this.addFilters(); + + // Schedule the first update. + this.scheduleUpdate(this.config.initialLoadDelay); + }, + + // Override notification handler. + notificationReceived (notification, payload, sender) { + if (notification === "CALENDAR_EVENTS") { + const senderClasses = sender.data.classes.toLowerCase().split(" "); + if (senderClasses.indexOf(this.config.calendarClass.toLowerCase()) !== -1) { + this.firstEvent = null; + for (let event of payload) { + if (event.location || event.geo) { + this.firstEvent = event; + Log.debug("First upcoming event with location: ", event); + break; + } + } + } + } else if (notification === "INDOOR_TEMPERATURE") { + this.indoorTemperature = this.roundValue(payload); + this.updateDom(300); + } else if (notification === "INDOOR_HUMIDITY") { + this.indoorHumidity = this.roundValue(payload); + this.updateDom(300); + } else if (notification === "CURRENT_WEATHER_OVERRIDE" && this.config.allowOverrideNotification) { + this.weatherProvider.notificationReceived(payload); + } + }, + + // Select the template depending on the display type. + getTemplate () { + switch (this.config.type.toLowerCase()) { + case "current": + return "current.njk"; + case "hourly": + return "hourly.njk"; + case "daily": + case "forecast": + return "forecast.njk"; + //Make the invalid values use the "Loading..." from forecast + default: + return "forecast.njk"; + } + }, + + // Add all the data to the template. + getTemplateData () { + const currentData = this.weatherProvider.currentWeather(); + const forecastData = this.weatherProvider.weatherForecast(); + + const mapIcon = (weatherType) => { + console.log("Weather type received:", weatherType); + switch (weatherType) { + case "day-sunny": + case "day-windy": + case "day-light-wind": + case "solar-eclipse": + case "hot": + return `
`; + case "lunar-eclipse": + case "stars": + return `
`; + case "day-rain": + case "day-rain-mix": + case "day-rain-wind": + case "day-showers": + case "day-sleet": + case "day-sprinkle": + case "day-sunny-overcast": + case "night-alt-rain": + case "night-alt-rain-mix": + case "night-alt-rain-wind": + case "night-alt-showers": + case "night-alt-sleet": + case "night-alt-sprinkle": + case "night-rain": + case "night-rain-mix": + case "night-rain-wind": + case "night-showers": + case "night-sleet": + case "night-sprinkle": + case "rain": + case "rain-mix": + case "rain-wind": + case "showers": + case "sleet": + case "sprinkle": + return `
`; + case "day-hail": + case "day-snow": + case "day-snow-thunderstorm": + case "day-snow-wind": + case "night-alt-hail": + case "night-alt-snow": + case "night-alt-snow-thunderstorm": + case "night-alt-snow-wind": + case "night-hail": + case "night-snow": + case "night-snow-thunderstorm": + case "night-snow-wind": + case "hail": + case "snow": + case "snow-wind": + return `
`; + case "day-lightning": + case "day-sleet-storm": + case "day-storm-showers": + case "day-thunderstorm": + case "night-alt-lightning": + case "night-alt-sleet-storm": + case "night-alt-storm-showers": + case "night-alt-thunderstorm": + case "night-lightning": + case "night-sleet-storm": + case "night-storm-showers": + case "night-thunderstorm": + case "storm-showers": + case "thunderstorm": + case "lightning": + return `
`; + case "day-cloudy": + case "day-cloudy-gusts": + case "day-cloud-windy": + case "day-cloudy-high": + case "night-alt-cloudy": + case "night-alt-cloudy-gusts": + case "night-alt-cloudy-windy": + case "night-alt-cloudy-high": + case "night-alt-partly-cloudy": + case "night-cloudy": + case "night-cloudy-gusts": + case "night-cloudy-windy": + case "night-partly-cloudy": + case "night-cloudy-high": + case "cloud": + case "cloudy": + case "cloudy-gusts": + case "cloudy-windy": + return `
`; + case "night-clear": + case "day-fog": + case "day-haze": + case "night-fog": + case "fog": + case "smog": + case "smoke ": + return `
`; + default: + return `
`; + } + }; + + if (forecastData) { + forecastData.forEach(f => { + console.log("Current weather object:", currentData); + f.iconHTML = mapIcon(f.weatherType); + }); + } + + // Add iconHTML to current weather + if (currentData) { + currentData.iconHTML = mapIcon(currentData.weatherType); + } + + // Skip some hourly forecast entries if configured + const hourlyData = this.weatherProvider.weatherHourly()?.filter((e, i) => (i + 1) % this.config.hourlyForecastIncrements === this.config.hourlyForecastIncrements - 1); + + return { + config: this.config, + current: currentData, + forecast: forecastData, + hourly: hourlyData, + indoor: { + humidity: this.indoorHumidity, + temperature: this.indoorTemperature + } + }; + }, + + // What to do when the weather provider has new information available? + updateAvailable () { + Log.log("New weather information available."); + // this value was changed from 0 to 300 to stabilize weather tests: + this.updateDom(300); + this.scheduleUpdate(); + + if (this.weatherProvider.currentWeather()) { + this.sendNotification("CURRENTWEATHER_TYPE", { type: this.weatherProvider.currentWeather().weatherType?.replace("-", "_") }); + } + + const notificationPayload = { + currentWeather: this.config.units === "imperial" + ? WeatherUtils.convertWeatherObjectToImperial(this.weatherProvider?.currentWeatherObject?.simpleClone()) ?? null + : this.weatherProvider?.currentWeatherObject?.simpleClone() ?? null, + forecastArray: this.config.units === "imperial" + ? this.weatherProvider?.weatherForecastArray?.map((ar) => WeatherUtils.convertWeatherObjectToImperial(ar.simpleClone())) ?? [] + : this.weatherProvider?.weatherForecastArray?.map((ar) => ar.simpleClone()) ?? [], + hourlyArray: this.config.units === "imperial" + ? this.weatherProvider?.weatherHourlyArray?.map((ar) => WeatherUtils.convertWeatherObjectToImperial(ar.simpleClone())) ?? [] + : this.weatherProvider?.weatherHourlyArray?.map((ar) => ar.simpleClone()) ?? [], + locationName: this.weatherProvider?.fetchedLocationName, + providerName: this.weatherProvider.providerName + }; + + this.sendNotification("WEATHER_UPDATED", notificationPayload); + }, + + scheduleUpdate (delay = null) { + let nextLoad = this.config.updateInterval; + if (delay !== null && delay >= 0) { + nextLoad = delay; + } + + setTimeout(() => { + switch (this.config.type.toLowerCase()) { + case "current": + this.weatherProvider.fetchCurrentWeather(); + break; + case "hourly": + this.weatherProvider.fetchWeatherHourly(); + break; + case "daily": + case "forecast": + this.weatherProvider.fetchWeatherForecast(); + break; + default: + Log.error(`Invalid type ${this.config.type} configured (must be one of 'current', 'hourly', 'daily' or 'forecast')`); + } + }, nextLoad); + }, + + roundValue (temperature) { + const decimals = this.config.roundTemp ? 0 : 1; + const roundValue = parseFloat(temperature).toFixed(decimals); + return roundValue === "-0" ? 0 : roundValue; + }, + + addFilters () { + this.nunjucksEnvironment().addFilter( + "formatTime", + function (date) { + return formatTime(this.config, date); + }.bind(this) + ); + + this.nunjucksEnvironment().addFilter( + "unit", + function (value, type, valueUnit) { + let formattedValue; + if (type === "temperature") { + formattedValue = `${this.roundValue(WeatherUtils.convertTemp(value, this.config.tempUnits))}°`; + if (this.config.degreeLabel) { + if (this.config.tempUnits === "metric") { + formattedValue += "C"; + } else if (this.config.tempUnits === "imperial") { + formattedValue += "F"; + } else { + formattedValue += "K"; + } + } + } else if (type === "precip") { + if (value === null || isNaN(value)) { + formattedValue = ""; + } else { + formattedValue = WeatherUtils.convertPrecipitationUnit(value, valueUnit, this.config.units); + } + } else if (type === "humidity") { + formattedValue = `${value}%`; + } else if (type === "wind") { + formattedValue = WeatherUtils.convertWind(value, this.config.windUnits); + } + return formattedValue; + }.bind(this) + ); + + this.nunjucksEnvironment().addFilter( + "roundValue", + function (value) { + return this.roundValue(value); + }.bind(this) + ); + + this.nunjucksEnvironment().addFilter( + "decimalSymbol", + function (value) { + return value.toString().replace(/\./g, this.config.decimalSymbol); + }.bind(this) + ); + + this.nunjucksEnvironment().addFilter( + "calcNumSteps", + function (forecast) { + return Math.min(forecast.length, this.config.maxNumberOfDays); + }.bind(this) + ); + + this.nunjucksEnvironment().addFilter( + "calcNumEntries", + function (dataArray) { + return Math.min(dataArray.length, this.config.maxEntries); + }.bind(this) + ); + + this.nunjucksEnvironment().addFilter( + "opacity", + function (currentStep, numSteps) { + if (this.config.fade && this.config.fadePoint < 1) { + if (this.config.fadePoint < 0) { + this.config.fadePoint = 0; + } + const startingPoint = numSteps * this.config.fadePoint; + const numFadesteps = numSteps - startingPoint; + if (currentStep >= startingPoint) { + return 1 - (currentStep - startingPoint) / numFadesteps; + } else { + return 1; + } + } else { + return 1; + } + }.bind(this) + ); + } +}); From b3f3bff946a0e666f9b9eac97581d4630fa059d3 Mon Sep 17 00:00:00 2001 From: ddongchul Date: Sun, 30 Aug 2026 09:24:37 -0400 Subject: [PATCH 2/2] Remove unused calendar-backup module (superseded by calendar-modded) --- defaultmodules/calendar-backup/README.md | 6 - defaultmodules/calendar-backup/calendar.css | 55 - defaultmodules/calendar-backup/calendar.js | 1084 ----------------- .../calendar-backup/calendarfetcher.js | 131 -- .../calendar-backup/calendarfetcherutils.js | 431 ------- .../calendar-backup/calendarutils.js | 128 -- defaultmodules/calendar-backup/debug.js | 42 - defaultmodules/calendar-backup/node_helper.js | 94 -- .../calendar-backup/windowsZones.json | 237 ---- 9 files changed, 2208 deletions(-) delete mode 100644 defaultmodules/calendar-backup/README.md delete mode 100644 defaultmodules/calendar-backup/calendar.css delete mode 100644 defaultmodules/calendar-backup/calendar.js delete mode 100644 defaultmodules/calendar-backup/calendarfetcher.js delete mode 100644 defaultmodules/calendar-backup/calendarfetcherutils.js delete mode 100644 defaultmodules/calendar-backup/calendarutils.js delete mode 100644 defaultmodules/calendar-backup/debug.js delete mode 100644 defaultmodules/calendar-backup/node_helper.js delete mode 100644 defaultmodules/calendar-backup/windowsZones.json diff --git a/defaultmodules/calendar-backup/README.md b/defaultmodules/calendar-backup/README.md deleted file mode 100644 index 1527595828..0000000000 --- a/defaultmodules/calendar-backup/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# Module: Calendar - -The `calendar` module is one of the default modules of the MagicMirror². -This module displays events from a public .ical calendar. It can combine multiple calendars. - -For configuration options, please check the [MagicMirror² documentation](https://docs.magicmirror.builders/modules/calendar.html). diff --git a/defaultmodules/calendar-backup/calendar.css b/defaultmodules/calendar-backup/calendar.css deleted file mode 100644 index e7d6403879..0000000000 --- a/defaultmodules/calendar-backup/calendar.css +++ /dev/null @@ -1,55 +0,0 @@ -.calendar .symbol { - display: flex; - flex-direction: row; - justify-content: flex-end; - gap: 5px; -} - -.calendar .title { - padding: 0 10px; -} - -.calendar .time { - padding-left: 20px; - text-align: right; -} - -/* ============================== - MagicMirror Calendar: Two-row layout, full-width title - ============================== */ - -/* Calendar symbol and info wrapper */ -td.symbol { - vertical-align: top; /* align with first line */ - white-space: nowrap; /* keep name/date on one line */ -} - -/* Calendar name inline with date/time */ -td.symbol span.calendar-name { - display: inline-block; /* inline, so date can sit next to it */ - font-weight: bold; - margin-right: 8px; /* space between name and date/time */ -} - -/* Date/Time in the same cell as calendar name */ -td.time.light { - display: inline-block; /* sit next to name */ - font-weight: normal; - color: #aaa; /* optional styling */ -} - -/* Force the event title on a new line below, full width */ -td.title { - display: block; /* starts a new line */ - margin-left: 0; /* remove any indentation */ - padding-left: 0; /* remove padding */ - color: #fff; /* event title color */ - white-space: normal; /* allow wrapping */ - width: 100%; /* span full width of row */ - box-sizing: border-box; /* ensure width includes padding */ -} - -/* Optional: spacing between events */ -table.small tr.event-wrapper { - padding-bottom: 4px; -} diff --git a/defaultmodules/calendar-backup/calendar.js b/defaultmodules/calendar-backup/calendar.js deleted file mode 100644 index e8a3933f30..0000000000 --- a/defaultmodules/calendar-backup/calendar.js +++ /dev/null @@ -1,1084 +0,0 @@ -/* global CalendarUtils */ - -Module.register("calendar", { - // Define module defaults - defaults: { - maximumEntries: 10, // Total Maximum Entries - maximumNumberOfDays: 365, - limitDays: 0, // Limit the number of days shown, 0 = no limit - pastDaysCount: 0, - displaySymbol: true, - defaultSymbol: "calendar-days", // Fontawesome Symbol see https://fontawesome.com/search?ic=free&o=r - defaultSymbolClassName: "fas fa-fw fa-", - 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: 60 * 60 * 1000, // Update every hour - animationSpeed: 2000, - fade: true, - fadePoint: 0.25, // Start on 1/4th of the list. - urgency: 7, - timeFormat: "relative", - dateFormat: "MMM Do", - dateEndFormat: "LT", - fullDayEventDateFormat: "MMM Do", - showEnd: false, - showEndsOnlyWithDuration: false, - getRelative: 6, - hidePrivate: false, - hideOngoing: false, - hideTime: false, - hideDuplicates: true, - showTimeToday: false, - colored: false, - forceUseCurrentTime: false, - tableClass: "small", - calendars: [ - { - symbol: "calendar-alt", - url: "https://www.calendarlabs.com/templates/ical/US-Holidays.ics" - } - ], - customEvents: [ - // Array of {keyword: "", symbol: "", color: "", eventClass: ""} where Keyword is a regexp and symbol/color/eventClass are to be applied for matched - { keyword: ".*", transform: { search: "De verjaardag van ", replace: "" } }, - { keyword: ".*", transform: { search: "'s birthday", replace: "" } } - ], - locationTitleReplace: { - "street ": "" - }, - broadcastEvents: true, - excludedEvents: [], - sliceMultiDayEvents: false, - broadcastPastEvents: false, - nextDaysRelative: false, - selfSignedCert: false, - coloredText: false, - coloredBorder: false, - coloredSymbol: false, - coloredBackground: false, - limitDaysNeverSkip: false, - flipDateHeaderTitle: false, - updateOnFetch: true - }, - - requiresVersion: "2.1.0", - - // Define required scripts. - getStyles () { - return ["calendar.css", "font-awesome.css"]; - }, - - // Define required scripts. - getScripts () { - return ["calendarutils.js", "moment.js", "moment-timezone.js"]; - }, - - // Define required translations. - getTranslations () { - - /* - * The translations for the default modules are defined in the core translation files. - * Therefore we can just return false. Otherwise we should have returned a dictionary. - * If you're trying to build your own module including translations, check out the documentation. - */ - return false; - }, - - // Override start method. - start () { - Log.info(`Starting module: ${this.name}`); - - if (this.config.colored) { - Log.warn("Your are using the deprecated config values 'colored'. Please switch to 'coloredSymbol' & 'coloredText'!"); - this.config.coloredText = true; - this.config.coloredSymbol = true; - } - if (this.config.coloredSymbolOnly) { - Log.warn("Your are using the deprecated config values 'coloredSymbolOnly'. Please switch to 'coloredSymbol' & 'coloredText'!"); - this.config.coloredText = false; - this.config.coloredSymbol = true; - } - - // Set locale. - moment.updateLocale(config.language, CalendarUtils.getLocaleSpecification(config.timeFormat)); - - // clear data holder before start - this.calendarData = {}; - - // indicate no data available yet - this.loaded = false; - - // data holder of calendar url. Avoid fade out/in on updateDom (one for each calendar update) - this.calendarDisplayer = {}; - - this.config.calendars.forEach((calendar) => { - calendar.url = calendar.url.replace("webcal://", "http://"); - - const calendarConfig = { - maximumEntries: calendar.maximumEntries, - maximumNumberOfDays: calendar.maximumNumberOfDays, - pastDaysCount: calendar.pastDaysCount, - broadcastPastEvents: calendar.broadcastPastEvents, - selfSignedCert: calendar.selfSignedCert, - excludedEvents: calendar.excludedEvents, - fetchInterval: calendar.fetchInterval - }; - - if (typeof calendar.symbolClass === "undefined" || calendar.symbolClass === null) { - calendarConfig.symbolClass = ""; - } - if (typeof calendar.titleClass === "undefined" || calendar.titleClass === null) { - calendarConfig.titleClass = ""; - } - if (typeof calendar.timeClass === "undefined" || calendar.timeClass === null) { - calendarConfig.timeClass = ""; - } - - // we check user and password here for backwards compatibility with old configs - if (calendar.user && calendar.pass) { - Log.warn("Deprecation warning: Please update your calendar authentication configuration."); - Log.warn("https://docs.magicmirror.builders/modules/calendar.html#configuration-options"); - calendar.auth = { - user: calendar.user, - pass: calendar.pass - }; - } - - /* - * tell helper to start a fetcher for this calendar - * fetcher till cycle - */ - this.addCalendar(calendar.url, calendar.auth, calendarConfig); - }); - - // for backward compatibility titleReplace - if (typeof this.config.titleReplace !== "undefined") { - Log.warn("Deprecation warning: Please consider upgrading your calendar titleReplace configuration to customEvents."); - for (const [titlesearchstr, titlereplacestr] of Object.entries(this.config.titleReplace)) { - this.config.customEvents.push({ keyword: ".*", transform: { search: titlesearchstr, replace: titlereplacestr } }); - } - } - - this.selfUpdate(); - }, - - notificationReceived (notification, payload, sender) { - if (notification === "FETCH_CALENDAR") { - if (this.hasCalendarURL(payload.url)) { - this.sendSocketNotification(notification, { url: payload.url, id: this.identifier }); - } - } - }, - - /*sortEventsByCalendarOrder(events) { - const order = ["Holidays", "Birthdays", "Family", "Kevin", "Fabienne", "Mackenzie"]; - const orderMap = {}; - order.forEach((name, idx) => (orderMap[name.toLowerCase()] = idx)); - - return events.sort((a, b) => { - const aIdx = orderMap[a.calendarName?.toLowerCase()] ?? 999; - const bIdx = orderMap[b.calendarName?.toLowerCase()] ?? 999; - return aIdx - bIdx; - }); - },*/ - - // Override socket notification handler. - socketNotificationReceived (notification, payload) { - - if (this.identifier !== payload.id) { - return; - } - - if (notification === "CALENDAR_EVENTS") { - if (this.hasCalendarURL(payload.url)) { - this.calendarData[payload.url] = payload.events; - this.error = null; - this.loaded = true; - - /*let allEvents = []; - Object.values(this.calendarData).forEach(list => { - if (Array.isArray(list)) { - allEvents = allEvents.concat(list); - } - }); - this.events = this.sortEventsByCalendarOrder(allEvents);*/ - - if (this.config.broadcastEvents) { - this.broadcastEvents(); - } - - if (!this.config.updateOnFetch) { - if (this.calendarDisplayer[payload.url] === undefined) { - // calendar will never displayed, so display it - this.updateDom(this.config.animationSpeed); - // set this calendar as displayed - this.calendarDisplayer[payload.url] = true; - } else { - Log.debug("[Calendar] DOM not updated waiting self update()"); - } - return; - } - } - } 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 () { - const events = this.createEventList(true); - const wrapper = document.createElement("table"); - wrapper.className = this.config.tableClass; - wrapper.style.width = "100%"; - wrapper.style.tableLayout = "fixed"; - - if (this.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 eventStartDateMoment = this.timestampToMoment(event.startDate); - const eventEndDateMoment = this.timestampToMoment(event.endDate); - const dateAsString = eventStartDateMoment.format(this.config.dateFormat); - if (this.config.timeFormat === "dateheaders") { - if (lastSeenDate !== dateAsString) { - const dateRow = document.createElement("tr"); - dateRow.className = "dateheader normal"; - if (event.today) dateRow.className += " today"; - else if (event.dayBeforeYesterday) dateRow.className += " dayBeforeYesterday"; - else if (event.yesterday) dateRow.className += " yesterday"; - else if (event.tomorrow) dateRow.className += " tomorrow"; - else if (event.dayAfterTomorrow) dateRow.className += " dayAfterTomorrow"; - - 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 nameDateRow = document.createElement("tr"); - //const nameSpan = document.createElement("td"); - //nameSpan.className = "calendar-name"; - //const dateSpan = document.createElement("td"); - //dateSpan.className = "event-time"; - const titleRow = document.createElement("tr"); - //const titleCell = document.createElement("td"); - //titleCell.colspan = 2; - //titleCell.className = "event-title"; - - - const eventWrapper = document.createElement("tr"); - - if (this.config.coloredText) { - eventWrapper.style.cssText = `color:${this.colorForUrl(event.url, false)}`; - } - - if (this.config.coloredBackground) { - eventWrapper.style.backgroundColor = this.colorForUrl(event.url, true); - } - - if (this.config.coloredBorder) { - eventWrapper.style.borderColor = this.colorForUrl(event.url, false); - } - - eventWrapper.className = "event-wrapper normal event"; - if (event.today) eventWrapper.className += " today"; - else if (event.dayBeforeYesterday) eventWrapper.className += " dayBeforeYesterday"; - else if (event.yesterday) eventWrapper.className += " yesterday"; - else if (event.tomorrow) eventWrapper.className += " tomorrow"; - else if (event.dayAfterTomorrow) eventWrapper.className += " dayAfterTomorrow"; - - /* const symbolWrapper = document.createElement("td"); - - if (this.config.displaySymbol) { - if (this.config.coloredSymbol) { - symbolWrapper.style.cssText = `color:${this.colorForUrl(event.url, false)}`; - } - - const symbolClass = this.symbolClassForUrl(event.url); - symbolWrapper.className = `symbol ${symbolClass}`; - - const symbols = this.symbolsForEvent(event); - symbols.forEach((s) => { - const symbol = document.createElement("span"); - symbol.className = s; - symbolWrapper.appendChild(symbol); - }); - eventWrapper.appendChild(symbolWrapper); - } else if (this.config.timeFormat === "dateheaders") { - const blankCell = document.createElement("td"); - blankCell.innerHTML = "   "; - eventWrapper.appendChild(blankCell); - }*/ - - //const symbolWrapper = document.createElement("tr"); - const nameCell = document.createElement("td"); - nameCell.className = "calendar-name"; - nameCell.style.textAlign = "left"; - const timeCell = document.createElement("td"); - timeCell.className = "event-time"; - timeCell.style.textAlign = "right"; - timeCell.style.whiteSpace = "nowrap"; - const titleCell = document.createElement("td"); - titleCell.colSpan = 2; - titleCell.style.padding = "0"; - titleCell.style.margin = "0"; - //nameDateRow.appendChild(nameCell); - //nameDateRow.appendChild(timeCell); - - - if (this.config.displaySymbol) { - //const nameCell = document.createElement("td"); - //nameCell.className = "calendar-name"; - nameCell.innerText = event.calendarName || ""; - //nameCell.style.textAlign = "left"; - //nameCell.style.color = this.config.customEvents[ev].color; - //symbolWrapper.appendChild(nameCell); - - - //const symbolClass = this.symbolClassForUrl(event.url); - //symbolWrapper.className = `symbol ${symbolClass}`; - - // Optional: color like the old symbol - if (this.config.coloredSymbol) { - //symbolWrapper.style.cssText = `color:${this.colorForUrl(event.url, false)}`; - nameCell.style.cssText += `color:${this.colorForUrl(event.url, false)}`; - //nameCell.style.color = this.config.customEvents[ev].color; - } - - //eventWrapper.appendChild(symbolWrapper); - //nameDateCell.appendChild(symbolWrapper); - nameDateRow.appendChild(nameCell); - } else if (this.config.timeFormat === "dateheaders") { - const blankCell = document.createElement("td"); - blankCell.innerHTML = "   "; - //eventWrapper.appendChild(blankCell); - nameDateRow.appendChild(blankCell); - } - - const titleWrapper = document.createElement("div"); - titleWrapper.colSpan = 2; - titleWrapper.style.whiteSpace = "nowrap"; - titleWrapper.style.overflow = "hidden"; - titleWrapper.style.textOverflow = "ellipsis"; - titleWrapper.style.width = "100%"; - titleWrapper.style.padding = "0"; - titleWrapper.style.paddingBottom = "10px"; - titleWrapper.style.margin = "0"; - let repeatingCountTitle = ""; - - if (this.config.displayRepeatingCountTitle && event.firstYear !== undefined) { - repeatingCountTitle = this.countTitleForUrl(event.url); - - if (repeatingCountTitle !== "") { - const thisYear = eventStartDateMoment.year(), - yearDiff = thisYear - event.firstYear; - - repeatingCountTitle = `, ${yearDiff} ${repeatingCountTitle}`; - } - } - - var transformedTitle = event.title; - - // Color events if custom color or eventClass are specified, transform title if required - if (this.config.customEvents.length > 0) { - for (let ev in this.config.customEvents) { - let needle = new RegExp(this.config.customEvents[ev].keyword, "gi"); - if (needle.test(event.title)) { - if (typeof this.config.customEvents[ev].transform === "object") { - transformedTitle = CalendarUtils.titleTransform(transformedTitle, [this.config.customEvents[ev].transform]); - } - if (typeof this.config.customEvents[ev].color !== "undefined" && this.config.customEvents[ev].color !== "") { - // Respect parameter ColoredSymbolOnly also for custom events - if (this.config.coloredText) { - eventWrapper.style.cssText = `color:${this.config.customEvents[ev].color}`; - titleWrapper.style.cssText = `color:${this.config.customEvents[ev].color}`; - } - if (this.config.displaySymbol && this.config.coloredSymbol) { - //symbolWrapper.style.cssText = `color:${this.config.customEvents[ev].color}`; - nameCell.style.cssText = `color:${this.config.customEvents[ev].color}`; - } - } - if (typeof this.config.customEvents[ev].eventClass !== "undefined" && this.config.customEvents[ev].eventClass !== "") { - eventWrapper.className += ` ${this.config.customEvents[ev].eventClass}`; - } - } - //nameCell.style.color = this.config.customEvents[ev].color; - } - } - - titleWrapper.innerHTML = CalendarUtils.shorten(transformedTitle, this.config.maxTitleLength, this.config.wrapEvents, this.config.maxTitleLines) + repeatingCountTitle; - - const titleClass = this.titleClassForUrl(event.url); - - if (!this.config.coloredText) { - titleWrapper.className = `title bright ${titleClass}`; - } else { - titleWrapper.className = `title ${titleClass}`; - } - - if (this.config.timeFormat === "dateheaders") { - //if (this.config.flipDateHeaderTitle) eventWrapper.appendChild(titleWrapper); - if (this.config.flipDateHeaderTitle) titleCell.appendChild(titleWrapper); - - if (event.fullDayEvent) { - titleWrapper.colSpan = "2"; - titleWrapper.classList.add("align-left"); - } else { - const timeWrapper = document.createElement("td"); - timeWrapper.className = `time light ${this.config.flipDateHeaderTitle ? "align-right " : "align-left "}${this.timeClassForUrl(event.url)}`; - timeWrapper.colSpan = "2"; - //timeWrapper.style.paddingLeft = "2px"; - timeWrapper.style.textAlign = this.config.flipDateHeaderTitle ? "right" : "left"; - timeWrapper.innerHTML = eventStartDateMoment.format("LT"); - - // Add endDate to dataheaders if showEnd is enabled - if (this.config.showEnd) { - if (this.config.showEndsOnlyWithDuration && event.startDate === event.endDate) { - // no duration here, don't display end - } else { - timeWrapper.innerHTML += ` - ${CalendarUtils.capFirst(eventEndDateMoment.format("LT"))}`; - } - } - - //eventWrapper.appendChild(timeWrapper); - nameDateRow.appendChild(timeWrapper); - - if (!this.config.flipDateHeaderTitle) titleWrapper.classList.add("align-right"); - } - if (!this.config.flipDateHeaderTitle) eventWrapper.appendChild(titleWrapper); - } else { - const timeWrapper = document.createElement("td"); - - //eventWrapper.appendChild(titleWrapper); - titleCell.appendChild(titleWrapper); - - const now = moment(); - - if (this.config.timeFormat === "absolute") { - // Use dateFormat - timeWrapper.innerHTML = CalendarUtils.capFirst(eventStartDateMoment.format(this.config.dateFormat)); - // Add end time if showEnd - if (this.config.showEnd) { - // and has a duation - if (event.startDate !== event.endDate) { - timeWrapper.innerHTML += "-"; - timeWrapper.innerHTML += CalendarUtils.capFirst(eventEndDateMoment.format(this.config.dateEndFormat)); - } - } - - // For full day events we use the fullDayEventDateFormat - if (event.fullDayEvent) { - //subtract one second so that fullDayEvents end at 23:59:59, and not at 0:00:00 one the next day - eventEndDateMoment.subtract(1, "second"); - timeWrapper.innerHTML = CalendarUtils.capFirst(eventStartDateMoment.format(this.config.fullDayEventDateFormat)); - // only show end if requested and allowed and the dates are different - if (this.config.showEnd && !this.config.showEndsOnlyWithDuration && !eventStartDateMoment.isSame(eventEndDateMoment, "d")) { - timeWrapper.innerHTML += "-"; - timeWrapper.innerHTML += CalendarUtils.capFirst(eventEndDateMoment.format(this.config.fullDayEventDateFormat)); - } else if (!eventStartDateMoment.isSame(eventEndDateMoment, "d") && eventStartDateMoment.isBefore(now)) { - timeWrapper.innerHTML = CalendarUtils.capFirst(now.format(this.config.fullDayEventDateFormat)); - } - } else if (this.config.getRelative > 0 && eventStartDateMoment.isBefore(now)) { - // Ongoing and getRelative is set - timeWrapper.innerHTML = CalendarUtils.capFirst( - this.translate("RUNNING", { - fallback: `${this.translate("RUNNING")} {timeUntilEnd}`, - timeUntilEnd: eventEndDateMoment.fromNow(true) - }) - ); - } else if (this.config.urgency > 0 && eventStartDateMoment.diff(now, "d") < this.config.urgency) { - // Within urgency days - timeWrapper.innerHTML = CalendarUtils.capFirst(eventStartDateMoment.fromNow()); - } - if (event.fullDayEvent && this.config.nextDaysRelative) { - // Full days events within the next two days - if (event.today) { - timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TODAY")); - } else if (event.yesterday) { - timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("YESTERDAY")); - } else if (event.tomorrow) { - timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TOMORROW")); - } else if (event.dayAfterTomorrow) { - if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") { - timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW")); - } - } - } - } else { - // Show relative times - if (eventStartDateMoment.isSameOrAfter(now) || (event.fullDayEvent && eventEndDateMoment.diff(now, "days") === 0)) { - // Use relative time - if (!this.config.hideTime && !event.fullDayEvent) { - Log.debug("event not hidden and not fullday"); - //timeWrapper.innerHTML = `${CalendarUtils.capFirst(eventStartDateMoment.calendar(null, { sameElse: this.config.dateFormat }))}`; - timeWrapper.innerHTML = CalendarUtils.capFirst( - eventStartDateMoment.calendar(null, { - sameDay: this.config.showTimeToday ? "@ h:mm a" : `[${this.translate("TODAY")}]`, - nextDay: `[${this.translate("Tmr")} @] h:mm a`, - nextWeek: "ddd @ h:mm a", // abbreviated weekday + time - sameElse: "ddd @ h:mm a" // fallback for anything else - }) - ); - } else { - Log.debug("event full day or hidden"); - timeWrapper.innerHTML = `${CalendarUtils.capFirst( - eventStartDateMoment.calendar(null, { - sameDay: this.config.showTimeToday ? "h:mm a" : `[${this.translate("TODAY")}]`, - nextDay: `[${this.translate("TOMORROW")}]`, - nextWeek: "dddd", - sameElse: event.fullDayEvent ? this.config.fullDayEventDateFormat : this.config.dateFormat - }) - )}`; - } - if (event.fullDayEvent) { - // Full days events within the next two days - if (event.today || (event.fullDayEvent && eventEndDateMoment.diff(now, "days") === 0)) { - timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TODAY")); - } else if (event.dayBeforeYesterday) { - if (this.translate("DAYBEFOREYESTERDAY") !== "DAYBEFOREYESTERDAY") { - timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYBEFOREYESTERDAY")); - } - } else if (event.yesterday) { - timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("YESTERDAY")); - } else if (event.tomorrow) { - timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("TOMORROW")); - } else if (event.dayAfterTomorrow) { - if (this.translate("DAYAFTERTOMORROW") !== "DAYAFTERTOMORROW") { - timeWrapper.innerHTML = CalendarUtils.capFirst(this.translate("DAYAFTERTOMORROW")); - } - } - Log.info("event fullday"); - } else if (eventStartDateMoment.diff(now, "h") < this.config.getRelative) { - Log.info("not full day but within getrelative size"); - // If event is within getRelative hours, display 'in xxx' time format or moment.fromNow() - timeWrapper.innerHTML = `${CalendarUtils.capFirst(eventStartDateMoment.fromNow())}`; - } - } else { - // Ongoing event - timeWrapper.innerHTML = CalendarUtils.capFirst( - this.translate("RUNNING", { - fallback: `${this.translate("RUNNING")} {timeUntilEnd}`, - timeUntilEnd: eventEndDateMoment.fromNow(true) - }) - ); - } - } - timeWrapper.style.whiteSpace = "nowrap"; - timeWrapper.className = `time light ${this.timeClassForUrl(event.url)}`; - //eventWrapper.appendChild(timeWrapper); - timeCell.appendChild(timeWrapper); - titleRow.appendChild(titleCell); - nameDateRow.appendChild(timeCell); - } - - // Create fade effect. - if (index >= startFade) { - currentFadeStep = index - startFade; - eventWrapper.style.opacity = 1 - (1 / fadeSteps) * currentFadeStep; - } - //wrapper.appendChild(eventWrapper); - wrapper.appendChild(nameDateRow); - wrapper.appendChild(titleRow); - - if (this.config.showLocation) { - if (event.location !== false) { - const locationRow = document.createElement("tr"); - locationRow.className = "event-wrapper-location normal xsmall light"; - if (event.today) locationRow.className += " today"; - else if (event.dayBeforeYesterday) locationRow.className += " dayBeforeYesterday"; - else if (event.yesterday) locationRow.className += " yesterday"; - else if (event.tomorrow) locationRow.className += " tomorrow"; - else if (event.dayAfterTomorrow) locationRow.className += " dayAfterTomorrow"; - - if (this.config.displaySymbol) { - const symbolCell = document.createElement("td"); - locationRow.appendChild(symbolCell); - } - - if (this.config.coloredText) { - locationRow.style.cssText = `color:${this.colorForUrl(event.url, false)}`; - } - - if (this.config.coloredBackground) { - locationRow.style.backgroundColor = this.colorForUrl(event.url, true); - } - - if (this.config.coloredBorder) { - locationRow.style.borderColor = this.colorForUrl(event.url, false); - } - - const descCell = document.createElement("td"); - descCell.className = "location"; - descCell.colSpan = "2"; - - const transformedTitle = CalendarUtils.titleTransform(event.location, this.config.locationTitleReplace); - descCell.innerHTML = CalendarUtils.shorten(transformedTitle, this.config.maxLocationTitleLength, this.config.wrapLocationEvents, this.config.maxEventTitleLines); - locationRow.appendChild(descCell); - - wrapper.appendChild(locationRow); - - if (index >= startFade) { - currentFadeStep = index - startFade; - locationRow.style.opacity = 1 - (1 / fadeSteps) * currentFadeStep; - } - } - } - }); - - return wrapper; - }, - - /** - * Checks if this config contains the calendar url. - * @param {string} url The calendar url - * @returns {boolean} True if the calendar config contains the url, False otherwise - */ - hasCalendarURL (url) { - for (const calendar of this.config.calendars) { - if (calendar.url === url) { - return true; - } - } - - return false; - }, - - /** - * converts the given timestamp to a moment with a timezone - * @param {number} timestamp timestamp from an event - * @returns {moment.Moment} moment with a timezone - */ - timestampToMoment (timestamp) { - return moment(timestamp, "x").tz(moment.tz.guess()); - }, - - /** - * Creates the sorted list of all events. - * @param {boolean} limitNumberOfEntries Whether to filter returned events for display. - * @returns {object[]} Array with events. - */ - createEventList (limitNumberOfEntries) { - let now = moment(); - let future = now.clone().startOf("day").add(this.config.maximumNumberOfDays, "days"); - - let events = []; - - const urlToNameMap = {}; - (this.config.calendars || []).forEach((cfg) => { - if (cfg && cfg.url) { - // normalize keys the same way calendarUrl appears (no trimming change) - urlToNameMap[cfg.url] = (cfg.name || cfg.url).toString(); - } - }); - - for (const calendarUrl in this.calendarData) { - const calendar = this.calendarData[calendarUrl]; - let remainingEntries = this.maximumEntriesForUrl(calendarUrl); - let maxPastDaysCompare = now.clone().subtract(this.maximumPastDaysForUrl(calendarUrl), "days"); - let by_url_calevents = []; - for (const e in calendar) { - const event = JSON.parse(JSON.stringify(calendar[e])); // clone object - const eventStartDateMoment = this.timestampToMoment(event.startDate); - const eventEndDateMoment = this.timestampToMoment(event.endDate); - - if (this.config.hidePrivate && event.class === "PRIVATE") { - // do not add the current event, skip it - continue; - } - if (limitNumberOfEntries) { - if (eventEndDateMoment.isBefore(maxPastDaysCompare)) { - continue; - } - if (this.config.hideOngoing && eventStartDateMoment.isBefore(now)) { - continue; - } - if (this.config.hideDuplicates && this.listContainsEvent(events, event)) { - continue; - } - } - - event.url = calendarUrl; - - const cfgName = urlToNameMap[calendarUrl] || null; - event.calendarName = (cfgName || calendarUrl || "unknown").toString(); - - event.today = eventStartDateMoment.isSame(now, "d"); - event.dayBeforeYesterday = eventStartDateMoment.isSame(now.clone().subtract(2, "days"), "d"); - event.yesterday = eventStartDateMoment.isSame(now.clone().subtract(1, "days"), "d"); - event.tomorrow = eventStartDateMoment.isSame(now.clone().add(1, "days"), "d"); - event.dayAfterTomorrow = eventStartDateMoment.isSame(now.clone().add(2, "days"), "d"); - - /* - * if sliceMultiDayEvents is set to true, multiday events (events exceeding at least one midnight) are sliced into days, - * otherwise, esp. in dateheaders mode it is not clear how long these events are. - */ - const maxCount = eventEndDateMoment.diff(eventStartDateMoment, "days"); - if (this.config.sliceMultiDayEvents && maxCount > 1) { - const splitEvents = []; - let midnight - = eventStartDateMoment - .clone() - .startOf("day") - .add(1, "day") - .endOf("day"); - let count = 1; - while (eventEndDateMoment.isAfter(midnight)) { - const thisEvent = JSON.parse(JSON.stringify(event)); // clone object - thisEvent.today = this.timestampToMoment(thisEvent.startDate).isSame(now, "d"); - thisEvent.tomorrow = this.timestampToMoment(thisEvent.startDate).isSame(now.clone().add(1, "days"), "d"); - thisEvent.endDate = midnight.clone().subtract(1, "day").format("x"); - thisEvent.title += ` (${count}/${maxCount})`; - splitEvents.push(thisEvent); - - event.startDate = midnight.format("x"); - count += 1; - midnight = midnight.clone().add(1, "day").endOf("day"); // next day - } - // Last day - event.title += ` (${count}/${maxCount})`; - event.today += this.timestampToMoment(event.startDate).isSame(now, "d"); - event.tomorrow = this.timestampToMoment(event.startDate).isSame(now.clone().add(1, "days"), "d"); - splitEvents.push(event); - - for (let splitEvent of splitEvents) { - if (this.timestampToMoment(splitEvent.endDate).isAfter(now) && this.timestampToMoment(splitEvent.endDate).isSameOrBefore(future)) { - by_url_calevents.push(splitEvent); - } - } - } else { - by_url_calevents.push(event); - } - } - if (limitNumberOfEntries) { - // sort entries before clipping - by_url_calevents.sort(function (a, b) { - return a.startDate - b.startDate; - }); - Log.debug(`pushing ${by_url_calevents.length} events to total with room for ${remainingEntries}`); - events = events.concat(by_url_calevents.slice(0, remainingEntries)); - Log.debug(`events for calendar=${events.length}`); - } else { - events = events.concat(by_url_calevents); - } - } - Log.info(`sorting events count=${events.length}`); - - events.sort(function (a, b) { - return a.startDate - b.startDate; - }); - - /*const order = ["Holidays", "Birthdays", "Family", "Kevin", "Fabienne", "Mackenzie"]; - const orderMap = {}; - order.forEach((name, idx) => (orderMap[name.toLowerCase()] = idx)); - - events.sort((a, b) => { - const aIdx = orderMap[a.calendarName?.toLowerCase()] ?? 999; - const bIdx = orderMap[b.calendarName?.toLowerCase()] ?? 999; - if (aIdx !== bIdx) { - return aIdx - bIdx; - } - // fallback to date only if from same calendar - return a.startDate - b.startDate; - });*/ - - if (!limitNumberOfEntries) { - return events; - } - - /* - * Limit the number of days displayed - * If limitDays is set > 0, limit display to that number of days - */ - if (this.config.limitDays > 0 && events.length > 0) { // watch out for initial display before events arrive from helper - // Group all events by date, events on the same date will be in a list with the key being the date. - const eventsByDate = Object.groupBy(events, (ev) => this.timestampToMoment(ev.startDate).format("YYYY-MM-DD")); - const newEvents = []; - let currentDate = moment(); - let daysCollected = 0; - - while (daysCollected < this.config.limitDays) { - const dateStr = currentDate.format("YYYY-MM-DD"); - // Check if there are events on the currentDate - if (eventsByDate[dateStr] && eventsByDate[dateStr].length > 0) { - // If there are any events today then get all those events and select the currently active events and the events that are starting later in the day. - newEvents.push(...eventsByDate[dateStr].filter((ev) => this.timestampToMoment(ev.endDate).isAfter(moment()))); - // Since we found a day with events, increase the daysCollected by 1 - daysCollected++; - } - // Search for the next day - currentDate.add(1, "day"); - } - events = newEvents; - } - Log.info(`slicing events total maxcount=${this.config.maximumEntries}`); - return events.slice(0, this.config.maximumEntries); - }, - - listContainsEvent (eventList, event) { - for (const evt of eventList) { - if (evt.title === event.title && parseInt(evt.startDate) === parseInt(event.startDate) && parseInt(evt.endDate) === parseInt(event.endDate)) { - return true; - } - } - return false; - }, - - /** - * Requests node helper to add calendar url. - * @param {string} url The calendar url to add - * @param {object} auth The authentication method and credentials - * @param {object} calendarConfig The config of the specific calendar - */ - addCalendar (url, auth, calendarConfig) { - this.sendSocketNotification("ADD_CALENDAR", { - id: this.identifier, - url: url, - excludedEvents: calendarConfig.excludedEvents || this.config.excludedEvents, - maximumEntries: calendarConfig.maximumEntries || this.config.maximumEntries, - maximumNumberOfDays: calendarConfig.maximumNumberOfDays || this.config.maximumNumberOfDays, - pastDaysCount: calendarConfig.pastDaysCount || this.config.pastDaysCount, - fetchInterval: calendarConfig.fetchInterval || this.config.fetchInterval, - symbolClass: calendarConfig.symbolClass, - titleClass: calendarConfig.titleClass, - timeClass: calendarConfig.timeClass, - auth: auth, - broadcastPastEvents: calendarConfig.broadcastPastEvents || this.config.broadcastPastEvents, - selfSignedCert: calendarConfig.selfSignedCert || this.config.selfSignedCert - }); - }, - - /** - * Retrieves the symbols for a specific event. - * @param {object} event Event to look for. - * @returns {string[]} The symbols - */ - symbolsForEvent (event) { - let symbols = this.getCalendarPropertyAsArray(event.url, "symbol", this.config.defaultSymbol); - - if (event.recurringEvent === true && this.hasCalendarProperty(event.url, "recurringSymbol")) { - symbols = this.mergeUnique(this.getCalendarPropertyAsArray(event.url, "recurringSymbol", this.config.defaultSymbol), symbols); - } - - if (event.fullDayEvent === true && this.hasCalendarProperty(event.url, "fullDaySymbol")) { - symbols = this.mergeUnique(this.getCalendarPropertyAsArray(event.url, "fullDaySymbol", this.config.defaultSymbol), symbols); - } - - // If custom symbol is set, replace event symbol - for (let ev of this.config.customEvents) { - if (typeof ev.symbol !== "undefined" && ev.symbol !== "") { - let needle = new RegExp(ev.keyword, "gi"); - if (needle.test(event.title)) { - // Get the default prefix for this class name and add to the custom symbol provided - const className = this.getCalendarProperty(event.url, "symbolClassName", this.config.defaultSymbolClassName); - symbols[0] = className + ev.symbol; - break; - } - } - } - - return symbols; - }, - - mergeUnique (arr1, arr2) { - return arr1.concat( - arr2.filter(function (item) { - return arr1.indexOf(item) === -1; - }) - ); - }, - - /** - * Retrieves the symbolClass for a specific calendar url. - * @param {string} url The calendar url - * @returns {string} The class to be used for the symbols of the calendar - */ - symbolClassForUrl (url) { - return this.getCalendarProperty(url, "symbolClass", ""); - }, - - /** - * Retrieves the titleClass for a specific calendar url. - * @param {string} url The calendar url - * @returns {string} The class to be used for the title of the calendar - */ - titleClassForUrl (url) { - return this.getCalendarProperty(url, "titleClass", ""); - }, - - /** - * Retrieves the timeClass for a specific calendar url. - * @param {string} url The calendar url - * @returns {string} The class to be used for the time of the calendar - */ - timeClassForUrl (url) { - return this.getCalendarProperty(url, "timeClass", ""); - }, - - /** - * Retrieves the calendar name for a specific calendar url. - * @param {string} url The calendar url - * @returns {string} The name of the calendar - */ - calendarNameForUrl (url) { - return this.getCalendarProperty(url, "name", ""); - }, - - /** - * Retrieves the color for a specific calendar url. - * @param {string} url The calendar url - * @param {boolean} isBg Determines if we fetch the bgColor or not - * @returns {string} The color - */ - colorForUrl (url, isBg) { - return this.getCalendarProperty(url, isBg ? "bgColor" : "color", "#fff"); - }, - - /** - * Retrieves the count title for a specific calendar url. - * @param {string} url The calendar url - * @returns {string} The title - */ - countTitleForUrl (url) { - return this.getCalendarProperty(url, "repeatingCountTitle", this.config.defaultRepeatingCountTitle); - }, - - /** - * Retrieves the maximum entry count for a specific calendar url. - * @param {string} url The calendar url - * @returns {number} The maximum entry count - */ - maximumEntriesForUrl (url) { - return this.getCalendarProperty(url, "maximumEntries", this.config.maximumEntries); - }, - - /** - * Retrieves the maximum count of past days which events of should be displayed for a specific calendar url. - * @param {string} url The calendar url - * @returns {number} The maximum past days count - */ - maximumPastDaysForUrl (url) { - return this.getCalendarProperty(url, "pastDaysCount", this.config.pastDaysCount); - }, - - /** - * Helper method to retrieve the property for a specific calendar url. - * @param {string} url The calendar url - * @param {string} property The property to look for - * @param {string} defaultValue The value if the property is not found - * @returns {property} The property - */ - getCalendarProperty (url, property, defaultValue) { - for (const calendar of this.config.calendars) { - if (calendar.url === url && calendar.hasOwnProperty(property)) { - return calendar[property]; - } - } - - return defaultValue; - }, - - getCalendarPropertyAsArray (url, property, defaultValue) { - let p = this.getCalendarProperty(url, property, defaultValue); - if (property === "symbol" || property === "recurringSymbol" || property === "fullDaySymbol") { - const className = this.getCalendarProperty(url, "symbolClassName", this.config.defaultSymbolClassName); - if (p instanceof Array) { - let t = []; - p.forEach((n) => { t.push(className + n); }); - p = t; - } - else p = className + p; - } - if (!(p instanceof Array)) p = [p]; - return p; - }, - - hasCalendarProperty (url, property) { - return !!this.getCalendarProperty(url, property, undefined); - }, - - /** - * Broadcasts the events to all other modules for reuse. - * The all events available in one array, sorted on startdate. - */ - broadcastEvents () { - const eventList = this.createEventList(false); - for (const event of eventList) { - event.symbol = this.symbolsForEvent(event); - event.calendarName = this.calendarNameForUrl(event.url); - event.color = this.colorForUrl(event.url, false); - delete event.url; - } - - this.sendNotification("CALENDAR_EVENTS", eventList); - }, - - /** - * Refresh the DOM every minute if needed: When using relative date format for events that start - * or end in less than an hour, the date shows minute granularity and we want to keep that accurate. - * -- - * When updateOnFetch is not set, it will Avoid fade out/in on updateDom when many calendars are used - * and it's allow to refresh The DOM every minute with animation speed too - * (because updateDom is not set in CALENDAR_EVENTS for this case) - */ - selfUpdate () { - const ONE_MINUTE = 60 * 1000; - setTimeout( - () => { - setInterval(() => { - Log.debug("[Calendar] self update"); - if (this.config.updateOnFetch) { - this.updateDom(1); - } else { - this.updateDom(this.config.animationSpeed); - } - }, ONE_MINUTE); - }, - ONE_MINUTE - (new Date() % ONE_MINUTE) - ); - } -}); diff --git a/defaultmodules/calendar-backup/calendarfetcher.js b/defaultmodules/calendar-backup/calendarfetcher.js deleted file mode 100644 index 6f254156b3..0000000000 --- a/defaultmodules/calendar-backup/calendarfetcher.js +++ /dev/null @@ -1,131 +0,0 @@ -const https = require("node:https"); -const ical = require("node-ical"); -const Log = require("logger"); -const NodeHelper = require("node_helper"); -const CalendarFetcherUtils = require("./calendarfetcherutils"); -const { getUserAgent } = require("#server_functions"); -const { scheduleTimer } = require("#module_functions"); - -/** - * - * @param {string} url The url of the calendar to fetch - * @param {number} reloadInterval Time in ms the calendar is fetched again - * @param {string[]} excludedEvents An array of words / phrases from event titles that will be excluded from being shown. - * @param {number} maximumEntries The maximum number of events fetched. - * @param {number} maximumNumberOfDays The maximum number of days an event should be in the future. - * @param {object} auth The object containing options for authentication against the calendar. - * @param {boolean} includePastEvents If true events from the past maximumNumberOfDays will be fetched too - * @param {boolean} selfSignedCert If true, the server certificate is not verified against the list of supplied CAs. - * @class - */ -const CalendarFetcher = function (url, reloadInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, includePastEvents, selfSignedCert) { - let reloadTimer = null; - let events = []; - - let fetchFailedCallback = function () {}; - let eventsReceivedCallback = function () {}; - - /** - * Initiates calendar fetch. - */ - const fetchCalendar = () => { - clearTimeout(reloadTimer); - reloadTimer = null; - let httpsAgent = null; - let headers = { - "User-Agent": getUserAgent() - }; - - if (selfSignedCert) { - httpsAgent = new https.Agent({ - rejectUnauthorized: false - }); - } - if (auth) { - if (auth.method === "bearer") { - headers.Authorization = `Bearer ${auth.pass}`; - } else { - headers.Authorization = `Basic ${Buffer.from(`${auth.user}:${auth.pass}`).toString("base64")}`; - } - } - - fetch(url, { headers: headers, agent: httpsAgent }) - .then(NodeHelper.checkFetchStatus) - .then((response) => response.text()) - .then((responseData) => { - let data = []; - - try { - data = ical.parseICS(responseData); - Log.debug(`parsed data=${JSON.stringify(data, null, 2)}`); - events = CalendarFetcherUtils.filterEvents(data, { - excludedEvents, - includePastEvents, - maximumEntries, - maximumNumberOfDays - }); - } catch (error) { - fetchFailedCallback(this, error); - scheduleTimer(reloadTimer, reloadInterval, fetchCalendar); - return; - } - this.broadcastEvents(); - scheduleTimer(reloadTimer, reloadInterval, fetchCalendar); - }) - .catch((error) => { - fetchFailedCallback(this, error); - scheduleTimer(reloadTimer, reloadInterval, fetchCalendar); - }); - }; - - /* public methods */ - - /** - * Initiate fetchCalendar(); - */ - this.startFetch = function () { - fetchCalendar(); - }; - - /** - * Broadcast the existing events. - */ - this.broadcastEvents = function () { - Log.info(`Calendar-Fetcher: Broadcasting ${events.length} events from ${url}.`); - eventsReceivedCallback(this); - }; - - /** - * Sets the on success callback - * @param {eventsReceivedCallback} callback The on success callback. - */ - this.onReceive = function (callback) { - eventsReceivedCallback = callback; - }; - - /** - * Sets the on error callback - * @param {fetchFailedCallback} callback The on error callback. - */ - this.onError = function (callback) { - fetchFailedCallback = callback; - }; - - /** - * Returns the url of this fetcher. - * @returns {string} The url of this fetcher. - */ - this.url = function () { - return url; - }; - - /** - * Returns current available events for this fetcher. - * @returns {object[]} The current available events for this fetcher. - */ - this.events = function () { - return events; - }; -}; - -module.exports = CalendarFetcher; diff --git a/defaultmodules/calendar-backup/calendarfetcherutils.js b/defaultmodules/calendar-backup/calendarfetcherutils.js deleted file mode 100644 index 729f121ce4..0000000000 --- a/defaultmodules/calendar-backup/calendarfetcherutils.js +++ /dev/null @@ -1,431 +0,0 @@ -/** - * @external Moment - */ -const moment = require("moment-timezone"); - -const Log = require("logger"); - -const CalendarFetcherUtils = { - - /** - * Determine based on the title of an event if it should be excluded from the list of events - * TODO This seems like an overly complicated way to exclude events based on the title. - * @param {object} config the global config - * @param {string} title the title of the event - * @returns {object} excluded: true if the event should be excluded, false otherwise - * until: the date until the event should be excluded. - */ - shouldEventBeExcluded (config, title) { - let result = { - excluded: false, - until: null - }; - for (let f in config.excludedEvents) { - let filter = config.excludedEvents[f], - testTitle = title.toLowerCase(), - until = null, - useRegex = false, - regexFlags = "g"; - - if (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) { - result.until = until; - } else { - result.excluded = true; - } - break; - } - } - return result; - }, - - /** - * 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))); - }, - - /** - * 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 (data, config) { - const newEvents = []; - - const eventDate = function (event, time) { - const startMoment = event[time].tz ? moment.tz(event[time], event[time].tz) : moment.tz(event[time], CalendarFetcherUtils.getLocalTimezone()); - return CalendarFetcherUtils.isFullDayEvent(event) ? startMoment.startOf("day") : startMoment; - }; - - Log.debug(`There are ${Object.entries(data).length} calendar entries.`); - - const now = moment(); - const pastLocalMoment = config.includePastEvents ? now.clone().startOf("day").subtract(config.maximumNumberOfDays, "days") : now; - const futureLocalMoment - = now - .clone() - .startOf("day") - .add(config.maximumNumberOfDays, "days") - // Subtract 1 second so that events that start on the middle of the night will not repeat. - .subtract(1, "seconds"); - - Object.entries(data).forEach(([key, event]) => { - Log.debug("Processing entry..."); - - const title = CalendarFetcherUtils.getTitleFromEvent(event); - Log.debug(`title: ${title}`); - - // Return quickly if event should be excluded. - let { excluded, eventFilterUntil } = this.shouldEventBeExcluded(config, title); - if (excluded) { - return; - } - - // FIXME: Ugly fix to solve the facebook birthday issue. - // Otherwise, the recurring events only show the birthday for next year. - let isFacebookBirthday = false; - if (typeof event.uid !== "undefined") { - if (event.uid.indexOf("@facebook.com") !== -1) { - isFacebookBirthday = true; - } - } - - if (event.type === "VEVENT") { - Log.debug(`Event:\n${JSON.stringify(event, null, 2)}`); - let eventStartMoment = eventDate(event, "start"); - let eventEndMoment; - - if (typeof event.end !== "undefined") { - eventEndMoment = eventDate(event, "end"); - } else if (typeof event.duration !== "undefined") { - eventEndMoment = eventStartMoment.clone().add(moment.duration(event.duration)); - } else { - if (!isFacebookBirthday) { - // make copy of start date, separate storage area - eventEndMoment = eventStartMoment.clone(); - } else { - eventEndMoment = eventStartMoment.clone().add(1, "days"); - } - } - - Log.debug(`start: ${eventStartMoment.toDate()}`); - Log.debug(`end:: ${eventEndMoment.toDate()}`); - - // Calculate the duration of the event for use with recurring events. - const durationMs = eventEndMoment.valueOf() - eventStartMoment.valueOf(); - Log.debug(`duration: ${durationMs}`); - - const location = event.location || false; - const geo = event.geo || false; - const description = event.description || false; - - // TODO This should be a seperate function. - if (event.rrule && typeof event.rrule !== "undefined" && !isFacebookBirthday) { - // Recurring event. - let moments = CalendarFetcherUtils.getMomentsFromRecurringEvent(event, pastLocalMoment, futureLocalMoment, durationMs); - - // Loop through the set of moment entries to see which recurrences should be added to our event list. - // TODO This should create an event per moment so we can change anything we want. - for (let m in moments) { - let curEvent = event; - let showRecurrence = true; - let recurringEventStartMoment = moments[m].tz(CalendarFetcherUtils.getLocalTimezone()).clone(); - let recurringEventEndMoment = recurringEventStartMoment.clone().add(durationMs, "ms"); - - let dateKey = recurringEventStartMoment.tz("UTC").format("YYYY-MM-DD"); - - Log.debug("event date dateKey=", dateKey); - // For each date that we're checking, it's possible that there is a recurrence override for that one day. - if (curEvent.recurrences !== undefined) { - Log.debug("have recurrences=", curEvent.recurrences); - if (curEvent.recurrences[dateKey] !== undefined) { - Log.debug("have a recurrence match for dateKey=", dateKey); - // We found an override, so for this recurrence, use a potentially different title, start date, and duration. - curEvent = curEvent.recurrences[dateKey]; - // Some event start/end dates don't have timezones - if (curEvent.start.tz) { - recurringEventStartMoment = moment(curEvent.start).tz(curEvent.start.tz).tz(CalendarFetcherUtils.getLocalTimezone()); - } else { - recurringEventStartMoment = moment(curEvent.start).tz(CalendarFetcherUtils.getLocalTimezone()); - } - if (curEvent.end.tz) { - recurringEventEndMoment = moment(curEvent.end).tz(curEvent.end.tz).tz(CalendarFetcherUtils.getLocalTimezone()); - } else { - recurringEventEndMoment = moment(curEvent.end).tz(CalendarFetcherUtils.getLocalTimezone()); - } - } else { - Log.debug("recurrence key ", dateKey, " doesn't match"); - } - } - // If there's no recurrence override, check for an exception date. Exception dates represent exceptions to the rule. - if (curEvent.exdate !== undefined) { - Log.debug("have datekey=", dateKey, " exdates=", curEvent.exdate); - if (curEvent.exdate[dateKey] !== undefined) { - // This date is an exception date, which means we should skip it in the recurrence pattern. - showRecurrence = false; - } - } - - if (recurringEventStartMoment.valueOf() === recurringEventEndMoment.valueOf()) { - recurringEventEndMoment = recurringEventEndMoment.endOf("day"); - } - - const recurrenceTitle = CalendarFetcherUtils.getTitleFromEvent(curEvent); - - // If this recurrence ends before the start of the date range, or starts after the end of the date range, don"t add - // it to the event list. - if (recurringEventEndMoment.isBefore(pastLocalMoment) || recurringEventStartMoment.isAfter(futureLocalMoment)) { - showRecurrence = false; - } - - if (CalendarFetcherUtils.timeFilterApplies(now, recurringEventEndMoment, eventFilterUntil)) { - showRecurrence = false; - } - - if (showRecurrence === true) { - Log.debug(`saving event: ${recurrenceTitle}`); - newEvents.push({ - title: recurrenceTitle, - startDate: recurringEventStartMoment.format("x"), - endDate: recurringEventEndMoment.format("x"), - fullDayEvent: CalendarFetcherUtils.isFullDayEvent(event), - recurringEvent: true, - class: event.class, - firstYear: event.start.getFullYear(), - location: location, - geo: geo, - description: description - }); - } else { - Log.debug("not saving event ", recurrenceTitle, eventStartMoment); - } - Log.debug(" "); - } - // End recurring event parsing. - } else { - // Single event. - const fullDayEvent = isFacebookBirthday ? true : CalendarFetcherUtils.isFullDayEvent(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 (fullDayEvent && eventStartMoment.valueOf() === eventEndMoment.valueOf()) { - eventEndMoment = eventEndMoment.endOf("day"); - } - - if (config.includePastEvents) { - // Past event is too far in the past, so skip. - if (eventEndMoment < pastLocalMoment) { - return; - } - } else { - // It's not a fullday event, and it is in the past, so skip. - if (!fullDayEvent && eventEndMoment < now) { - return; - } - - // It's a fullday event, and it is before today, So skip. - if (fullDayEvent && eventEndMoment <= now.startOf("day")) { - return; - } - } - - // It exceeds the maximumNumberOfDays limit, so skip. - if (eventStartMoment > futureLocalMoment) { - return; - } - - if (CalendarFetcherUtils.timeFilterApplies(now, eventEndMoment, eventFilterUntil)) { - return; - } - - // Every thing is good. Add it to the list. - newEvents.push({ - title: title, - startDate: eventStartMoment.format("x"), - endDate: eventEndMoment.format("x"), - fullDayEvent: fullDayEvent, - recurringEvent: false, - class: event.class, - firstYear: event.start.getFullYear(), - location: location, - geo: geo, - description: description - }); - } - } - }); - - newEvents.sort(function (a, b) { - return a.startDate - b.startDate; - }); - - return newEvents; - }, - - /** - * 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 (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 (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 {moment.Moment} now Date object using previously created object for consistency - * @param {moment.Moment} endDate Moment object representing the event end date - * @param {string} filter The time to subtract from the end date to determine if an event should be shown - * @returns {boolean} True if the event should be filtered out, false otherwise - */ - timeFilterApplies (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; - } - - 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 (title, filter, useRegex, regexFlags) { - if (useRegex) { - let regexFilter = filter; - // Assume if leading slash, there is also trailing slash - if (filter[0] === "/") { - // Strip leading and trailing slashes - regexFilter = filter.substr(1).slice(0, -1); - } - return new RegExp(regexFilter, regexFlags).test(title); - } else { - return title.includes(filter); - } - } -}; - -if (typeof module !== "undefined") { - module.exports = CalendarFetcherUtils; -} diff --git a/defaultmodules/calendar-backup/calendarutils.js b/defaultmodules/calendar-backup/calendarutils.js deleted file mode 100644 index 5cbc8d6824..0000000000 --- a/defaultmodules/calendar-backup/calendarutils.js +++ /dev/null @@ -1,128 +0,0 @@ -const CalendarUtils = { - - /** - * Capitalize the first letter of a string - * @param {string} string The string to capitalize - * @returns {string} The capitalized string - */ - capFirst (string) { - return string.charAt(0).toUpperCase() + string.slice(1); - }, - - /** - * This function accepts a number (either 12 or 24) and returns a moment.js LocaleSpecification with the - * corresponding time-format to be used in the calendar display. If no number is given (or otherwise invalid input) - * it will a localeSpecification object with the system locale time format. - * @param {number} timeFormat Specifies either 12 or 24-hour time format - * @returns {moment.LocaleSpecification} formatted time - */ - getLocaleSpecification (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") } }; - } - } - }, - - /** - * Shortens a string if it's longer than maxLength and add an ellipsis to the end - * @param {string} string Text string to shorten - * @param {number} maxLength The max length of the string - * @param {boolean} wrapEvents Wrap the text after the line has reached maxLength - * @param {number} maxTitleLines The max number of vertical lines before cutting event title - * @returns {string} The shortened string - */ - shorten (string, maxLength, wrapEvents, maxTitleLines) { - if (typeof string !== "string") { - return ""; - } - - if (wrapEvents === true) { - const words = string.split(" "); - let temp = ""; - let currentLine = ""; - let line = 0; - - for (let i = 0; i < words.length; i++) { - const word = words[i]; - if (currentLine.length + word.length < (typeof maxLength === "number" ? maxLength : 25) - 1) { - // max - 1 to account for a space - currentLine += `${word} `; - } else { - line++; - if (line > maxTitleLines - 1) { - if (i < words.length) { - currentLine += "…"; - } - break; - } - - if (currentLine.length > 0) { - temp += `${currentLine}
${word} `; - } else { - temp += `${word}
`; - } - currentLine = ""; - } - } - - return (temp + currentLine).trim(); - } else { - if (maxLength && typeof maxLength === "number" && string.length > maxLength) { - return `${string.trim().slice(0, maxLength)}…`; - } else { - return string.trim(); - } - } - }, - - /** - * Transforms the title of an event for usage. - * Replaces parts of the text as defined in config.titleReplace. - * @param {string} title The title to transform. - * @param {object} titleReplace object definition of parts to be replaced in the title - * object definition: - * search: {string,required} RegEx in format //x or simple string to be searched. For (birthday) year calcluation, the element matching the year must be in a RegEx group - * replace: {string,required} Replacement string, may contain match group references (latter is required for year calculation) - * yearmatchgroup: {number,optional} match group for year element - * @returns {string} The transformed title. - */ - titleTransform (title, titleReplace) { - let transformedTitle = title; - for (let tr in titleReplace) { - let transform = titleReplace[tr]; - if (typeof transform === "object") { - if (typeof transform.search !== "undefined" && transform.search !== "" && typeof transform.replace !== "undefined") { - let regParts = transform.search.match(/^\/(.+)\/([gim]*)$/); - let needle = new RegExp(transform.search, "g"); - if (regParts) { - // the parsed pattern is a regexp with flags. - needle = new RegExp(regParts[1], regParts[2]); - } - - let replacement = transform.replace; - if (typeof transform.yearmatchgroup !== "undefined" && transform.yearmatchgroup !== "") { - const yearmatch = [...title.matchAll(needle)]; - if (yearmatch[0].length >= transform.yearmatchgroup + 1 && yearmatch[0][transform.yearmatchgroup] * 1 >= 1900) { - let calcage = new Date().getFullYear() - yearmatch[0][transform.yearmatchgroup] * 1; - let searchstr = `$${transform.yearmatchgroup}`; - replacement = replacement.replace(searchstr, calcage); - } - } - transformedTitle = transformedTitle.replace(needle, replacement); - } - } - } - return transformedTitle; - } -}; - -if (typeof module !== "undefined") { - module.exports = CalendarUtils; -} diff --git a/defaultmodules/calendar-backup/debug.js b/defaultmodules/calendar-backup/debug.js deleted file mode 100644 index 3acfc31132..0000000000 --- a/defaultmodules/calendar-backup/debug.js +++ /dev/null @@ -1,42 +0,0 @@ -/* - * CalendarFetcher Tester - * use this script with `node debug.js` to test the fetcher without the need - * of starting the MagicMirror² core. Adjust the values below to your desire. - */ -// Alias modules mentioned in package.js under _moduleAliases. -require("module-alias/register"); -const Log = require("logger"); - -const CalendarFetcher = require("./calendarfetcher"); - -const url = "https://calendar.google.com/calendar/ical/pkm1t2uedjbp0uvq1o7oj1jouo%40group.calendar.google.com/private-08ba559f89eec70dd74bbd887d0a3598/basic.ics"; // Standard test URL -//const url = "https://www.googleapis.com/calendar/v3/calendars/primary/events/"; // URL for Bearer auth (must be configured in Google OAuth2 first) -const fetchInterval = 60 * 60 * 1000; -const maximumEntries = 10; -const maximumNumberOfDays = 365; -const user = "magicmirror"; -const pass = "MyStrongPass"; -const auth = { - user: user, - pass: pass -}; - -Log.log("Create fetcher ..."); - -const fetcher = new CalendarFetcher(url, fetchInterval, [], maximumEntries, maximumNumberOfDays, auth); - -fetcher.onReceive(function (fetcher) { - Log.log(fetcher.events()); - Log.log("------------------------------------------------------------"); - process.exit(0); -}); - -fetcher.onError(function (fetcher, error) { - Log.log("Fetcher error:"); - Log.log(error); - process.exit(1); -}); - -fetcher.startFetch(); - -Log.log("Create fetcher done! "); diff --git a/defaultmodules/calendar-backup/node_helper.js b/defaultmodules/calendar-backup/node_helper.js deleted file mode 100644 index 7901abf099..0000000000 --- a/defaultmodules/calendar-backup/node_helper.js +++ /dev/null @@ -1,94 +0,0 @@ -const NodeHelper = require("node_helper"); -const Log = require("logger"); -const CalendarFetcher = require("./calendarfetcher"); - -module.exports = NodeHelper.create({ - // Override start method. - start () { - Log.log(`Starting node helper for: ${this.name}`); - this.fetchers = []; - }, - - // Override socketNotificationReceived method. - socketNotificationReceived (notification, payload) { - if (notification === "ADD_CALENDAR") { - this.createFetcher(payload.url, payload.fetchInterval, payload.excludedEvents, payload.maximumEntries, payload.maximumNumberOfDays, payload.auth, payload.broadcastPastEvents, payload.selfSignedCert, payload.id); - } else if (notification === "FETCH_CALENDAR") { - const key = payload.id + payload.url; - if (typeof this.fetchers[key] === "undefined") { - Log.error("Calendar Error. No fetcher exists with key: ", key); - this.sendSocketNotification("CALENDAR_ERROR", { error_type: "MODULE_ERROR_UNSPECIFIED" }); - return; - } - this.fetchers[key].startFetch(); - } - }, - - /** - * Creates a fetcher for a new url if it doesn't exist yet. - * Otherwise it reuses the existing one. - * @param {string} url The url of the calendar - * @param {number} fetchInterval How often does the calendar needs to be fetched in ms - * @param {string[]} excludedEvents An array of words / phrases from event titles that will be excluded from being shown. - * @param {number} maximumEntries The maximum number of events fetched. - * @param {number} maximumNumberOfDays The maximum number of days an event should be in the future. - * @param {object} auth The object containing options for authentication against the calendar. - * @param {boolean} broadcastPastEvents If true events from the past maximumNumberOfDays will be included in event broadcasts - * @param {boolean} selfSignedCert If true, the server certificate is not verified against the list of supplied CAs. - * @param {string} identifier ID of the module - */ - createFetcher (url, fetchInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, broadcastPastEvents, selfSignedCert, identifier) { - try { - new URL(url); - } catch (error) { - Log.error("Calendar Error. Malformed calendar url: ", url, error); - this.sendSocketNotification("CALENDAR_ERROR", { error_type: "MODULE_ERROR_MALFORMED_URL" }); - return; - } - - let fetcher; - let fetchIntervalCorrected; - if (typeof this.fetchers[identifier + url] === "undefined") { - if (fetchInterval < 60000) { - Log.warn(`fetchInterval for url ${url} must be >= 60000`); - fetchIntervalCorrected = 60000; - } - Log.log(`Create new calendarfetcher for url: ${url} - Interval: ${fetchIntervalCorrected || fetchInterval}`); - fetcher = new CalendarFetcher(url, fetchIntervalCorrected || fetchInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, broadcastPastEvents, selfSignedCert); - - fetcher.onReceive((fetcher) => { - this.broadcastEvents(fetcher, identifier); - }); - - fetcher.onError((fetcher, error) => { - Log.error("Calendar Error. Could not fetch calendar: ", fetcher.url(), error); - let error_type = NodeHelper.checkFetchError(error); - this.sendSocketNotification("CALENDAR_ERROR", { - id: identifier, - error_type - }); - }); - - this.fetchers[identifier + url] = fetcher; - } else { - Log.log(`Use existing calendarfetcher for url: ${url}`); - fetcher = this.fetchers[identifier + url]; - fetcher.broadcastEvents(); - } - - fetcher.startFetch(); - }, - - /** - * - * @param {object} fetcher the fetcher associated with the calendar - * @param {string} identifier the identifier of the calendar - */ - broadcastEvents (fetcher, identifier) { - this.sendSocketNotification("CALENDAR_EVENTS", { - id: identifier, - url: fetcher.url(), - events: fetcher.events() - }); - } -}); diff --git a/defaultmodules/calendar-backup/windowsZones.json b/defaultmodules/calendar-backup/windowsZones.json deleted file mode 100644 index cad82bb9ee..0000000000 --- a/defaultmodules/calendar-backup/windowsZones.json +++ /dev/null @@ -1,237 +0,0 @@ -{ - "Dateline Standard Time": { "iana": ["Etc/GMT+12"] }, - "UTC-11": { "iana": ["Etc/GMT+11"] }, - "Aleutian Standard Time": { "iana": ["America/Adak"] }, - "Hawaiian Standard Time": { "iana": ["Pacific/Honolulu"] }, - "Marquesas Standard Time": { "iana": ["Pacific/Marquesas"] }, - "Alaskan Standard Time": { "iana": ["America/Anchorage"] }, - "UTC-09": { "iana": ["Etc/GMT+9"] }, - "Pacific Standard Time (Mexico)": { "iana": ["America/Tijuana"] }, - "UTC-08": { "iana": ["Etc/GMT+8"] }, - "Pacific Standard Time": { "iana": ["America/Los_Angeles"] }, - "US Mountain Standard Time": { "iana": ["America/Phoenix"] }, - "Mountain Standard Time (Mexico)": { "iana": ["America/Chihuahua"] }, - "Mountain Standard Time": { "iana": ["America/Denver"] }, - "Central America Standard Time": { "iana": ["America/Guatemala"] }, - "Central Standard Time": { "iana": ["America/Chicago"] }, - "Easter Island Standard Time": { "iana": ["Pacific/Easter"] }, - "Central Standard Time (Mexico)": { "iana": ["America/Mexico_City"] }, - "Canada Central Standard Time": { "iana": ["America/Regina"] }, - "SA Pacific Standard Time": { "iana": ["America/Bogota"] }, - "Eastern Standard Time (Mexico)": { "iana": ["America/Cancun"] }, - "Eastern Standard Time": { "iana": ["America/New_York"] }, - "Haiti Standard Time": { "iana": ["America/Port-au-Prince"] }, - "Cuba Standard Time": { "iana": ["America/Havana"] }, - "US Eastern Standard Time": { "iana": ["America/Indianapolis"] }, - "Turks And Caicos Standard Time": { "iana": ["America/Grand_Turk"] }, - "Paraguay Standard Time": { "iana": ["America/Asuncion"] }, - "Atlantic Standard Time": { "iana": ["America/Halifax"] }, - "Venezuela Standard Time": { "iana": ["America/Caracas"] }, - "Central Brazilian Standard Time": { "iana": ["America/Cuiaba"] }, - "SA Western Standard Time": { "iana": ["America/La_Paz"] }, - "Pacific SA Standard Time": { "iana": ["America/Santiago"] }, - "Newfoundland Standard Time": { "iana": ["America/St_Johns"] }, - "Tocantins Standard Time": { "iana": ["America/Araguaina"] }, - "E. South America Standard Time": { "iana": ["America/Sao_Paulo"] }, - "SA Eastern Standard Time": { "iana": ["America/Cayenne"] }, - "Argentina Standard Time": { "iana": ["America/Buenos_Aires"] }, - "Greenland Standard Time": { "iana": ["America/Godthab"] }, - "Montevideo Standard Time": { "iana": ["America/Montevideo"] }, - "Magallanes Standard Time": { "iana": ["America/Punta_Arenas"] }, - "Saint Pierre Standard Time": { "iana": ["America/Miquelon"] }, - "Bahia Standard Time": { "iana": ["America/Bahia"] }, - "UTC-02": { "iana": ["Etc/GMT+2"] }, - "Azores Standard Time": { "iana": ["Atlantic/Azores"] }, - "Cape Verde Standard Time": { "iana": ["Atlantic/Cape_Verde"] }, - "UTC": { "iana": ["Etc/GMT"] }, - "GMT Standard Time": { "iana": ["Europe/London"] }, - "Greenwich Standard Time": { "iana": ["Atlantic/Reykjavik"] }, - "Sao Tome Standard Time": { "iana": ["Africa/Sao_Tome"] }, - "Morocco Standard Time": { "iana": ["Africa/Casablanca"] }, - "W. Europe Standard Time": { "iana": ["Europe/Berlin"] }, - "Central Europe Standard Time": { "iana": ["Europe/Budapest"] }, - "Romance Standard Time": { "iana": ["Europe/Paris"] }, - "Central European Standard Time": { "iana": ["Europe/Warsaw"] }, - "W. Central Africa Standard Time": { "iana": ["Africa/Lagos"] }, - "Jordan Standard Time": { "iana": ["Asia/Amman"] }, - "GTB Standard Time": { "iana": ["Europe/Bucharest"] }, - "Middle East Standard Time": { "iana": ["Asia/Beirut"] }, - "Egypt Standard Time": { "iana": ["Africa/Cairo"] }, - "E. Europe Standard Time": { "iana": ["Europe/Chisinau"] }, - "Syria Standard Time": { "iana": ["Asia/Damascus"] }, - "West Bank Standard Time": { "iana": ["Asia/Hebron"] }, - "South Africa Standard Time": { "iana": ["Africa/Johannesburg"] }, - "FLE Standard Time": { "iana": ["Europe/Kiev"] }, - "Israel Standard Time": { "iana": ["Asia/Jerusalem"] }, - "Kaliningrad Standard Time": { "iana": ["Europe/Kaliningrad"] }, - "Sudan Standard Time": { "iana": ["Africa/Khartoum"] }, - "Libya Standard Time": { "iana": ["Africa/Tripoli"] }, - "Namibia Standard Time": { "iana": ["Africa/Windhoek"] }, - "Arabic Standard Time": { "iana": ["Asia/Baghdad"] }, - "Turkey Standard Time": { "iana": ["Europe/Istanbul"] }, - "Arab Standard Time": { "iana": ["Asia/Riyadh"] }, - "Belarus Standard Time": { "iana": ["Europe/Minsk"] }, - "Russian Standard Time": { "iana": ["Europe/Moscow"] }, - "E. Africa Standard Time": { "iana": ["Africa/Nairobi"] }, - "Iran Standard Time": { "iana": ["Asia/Tehran"] }, - "Arabian Standard Time": { "iana": ["Asia/Dubai"] }, - "Astrakhan Standard Time": { "iana": ["Europe/Astrakhan"] }, - "Azerbaijan Standard Time": { "iana": ["Asia/Baku"] }, - "Russia Time Zone 3": { "iana": ["Europe/Samara"] }, - "Mauritius Standard Time": { "iana": ["Indian/Mauritius"] }, - "Saratov Standard Time": { "iana": ["Europe/Saratov"] }, - "Georgian Standard Time": { "iana": ["Asia/Tbilisi"] }, - "Volgograd Standard Time": { "iana": ["Europe/Volgograd"] }, - "Caucasus Standard Time": { "iana": ["Asia/Yerevan"] }, - "Afghanistan Standard Time": { "iana": ["Asia/Kabul"] }, - "West Asia Standard Time": { "iana": ["Asia/Tashkent"] }, - "Ekaterinburg Standard Time": { "iana": ["Asia/Yekaterinburg"] }, - "Pakistan Standard Time": { "iana": ["Asia/Karachi"] }, - "Qyzylorda Standard Time": { "iana": ["Asia/Qyzylorda"] }, - "India Standard Time": { "iana": ["Asia/Calcutta"] }, - "Sri Lanka Standard Time": { "iana": ["Asia/Colombo"] }, - "Nepal Standard Time": { "iana": ["Asia/Katmandu"] }, - "Central Asia Standard Time": { "iana": ["Asia/Almaty"] }, - "Bangladesh Standard Time": { "iana": ["Asia/Dhaka"] }, - "Omsk Standard Time": { "iana": ["Asia/Omsk"] }, - "Myanmar Standard Time": { "iana": ["Asia/Rangoon"] }, - "SE Asia Standard Time": { "iana": ["Asia/Bangkok"] }, - "Altai Standard Time": { "iana": ["Asia/Barnaul"] }, - "W. Mongolia Standard Time": { "iana": ["Asia/Hovd"] }, - "North Asia Standard Time": { "iana": ["Asia/Krasnoyarsk"] }, - "N. Central Asia Standard Time": { "iana": ["Asia/Novosibirsk"] }, - "Tomsk Standard Time": { "iana": ["Asia/Tomsk"] }, - "China Standard Time": { "iana": ["Asia/Shanghai"] }, - "North Asia East Standard Time": { "iana": ["Asia/Irkutsk"] }, - "Singapore Standard Time": { "iana": ["Asia/Singapore"] }, - "W. Australia Standard Time": { "iana": ["Australia/Perth"] }, - "Taipei Standard Time": { "iana": ["Asia/Taipei"] }, - "Ulaanbaatar Standard Time": { "iana": ["Asia/Ulaanbaatar"] }, - "Aus Central W. Standard Time": { "iana": ["Australia/Eucla"] }, - "Transbaikal Standard Time": { "iana": ["Asia/Chita"] }, - "Tokyo Standard Time": { "iana": ["Asia/Tokyo"] }, - "North Korea Standard Time": { "iana": ["Asia/Pyongyang"] }, - "Korea Standard Time": { "iana": ["Asia/Seoul"] }, - "Yakutsk Standard Time": { "iana": ["Asia/Yakutsk"] }, - "Cen. Australia Standard Time": { "iana": ["Australia/Adelaide"] }, - "AUS Central Standard Time": { "iana": ["Australia/Darwin"] }, - "E. Australia Standard Time": { "iana": ["Australia/Brisbane"] }, - "AUS Eastern Standard Time": { "iana": ["Australia/Sydney"] }, - "West Pacific Standard Time": { "iana": ["Pacific/Port_Moresby"] }, - "Tasmania Standard Time": { "iana": ["Australia/Hobart"] }, - "Vladivostok Standard Time": { "iana": ["Asia/Vladivostok"] }, - "Lord Howe Standard Time": { "iana": ["Australia/Lord_Howe"] }, - "Bougainville Standard Time": { "iana": ["Pacific/Bougainville"] }, - "Russia Time Zone 10": { "iana": ["Asia/Srednekolymsk"] }, - "Magadan Standard Time": { "iana": ["Asia/Magadan"] }, - "Norfolk Standard Time": { "iana": ["Pacific/Norfolk"] }, - "Sakhalin Standard Time": { "iana": ["Asia/Sakhalin"] }, - "Central Pacific Standard Time": { "iana": ["Pacific/Guadalcanal"] }, - "Russia Time Zone 11": { "iana": ["Asia/Kamchatka"] }, - "New Zealand Standard Time": { "iana": ["Pacific/Auckland"] }, - "UTC+12": { "iana": ["Etc/GMT-12"] }, - "Fiji Standard Time": { "iana": ["Pacific/Fiji"] }, - "Chatham Islands Standard Time": { "iana": ["Pacific/Chatham"] }, - "UTC+13": { "iana": ["Etc/GMT-13"] }, - "Tonga Standard Time": { "iana": ["Pacific/Tongatapu"] }, - "Samoa Standard Time": { "iana": ["Pacific/Apia"] }, - "Line Islands Standard Time": { "iana": ["Pacific/Kiritimati"] }, - "(UTC-12:00) International Date Line West": { "iana": ["Etc/GMT+12"] }, - "(UTC-11:00) Midway Island, Samoa": { "iana": ["Pacific/Apia"] }, - "(UTC-10:00) Hawaii": { "iana": ["Pacific/Honolulu"] }, - "(UTC-09:00) Alaska": { "iana": ["America/Anchorage"] }, - "(UTC-08:00) Pacific Time (US & Canada); Tijuana": { "iana": ["America/Los_Angeles"] }, - "(UTC-08:00) Pacific Time (US and Canada); Tijuana": { "iana": ["America/Los_Angeles"] }, - "(UTC-07:00) Mountain Time (US & Canada)": { "iana": ["America/Denver"] }, - "(UTC-07:00) Mountain Time (US and Canada)": { "iana": ["America/Denver"] }, - "(UTC-07:00) Chihuahua, La Paz, Mazatlan": { "iana": [null] }, - "(UTC-07:00) Arizona": { "iana": ["America/Phoenix"] }, - "(UTC-06:00) Central Time (US & Canada)": { "iana": ["America/Chicago"] }, - "(UTC-06:00) Central Time (US and Canada)": { "iana": ["America/Chicago"] }, - "(UTC-06:00) Saskatchewan": { "iana": ["America/Regina"] }, - "(UTC-06:00) Guadalajara, Mexico City, Monterrey": { "iana": [null] }, - "(UTC-06:00) Central America": { "iana": ["America/Guatemala"] }, - "(UTC-05:00) Eastern Time (US & Canada)": { "iana": ["America/New_York"] }, - "(UTC-05:00) Eastern Time (US and Canada)": { "iana": ["America/New_York"] }, - "(UTC-05:00) Indiana (East)": { "iana": ["America/Indianapolis"] }, - "(UTC-05:00) Bogota, Lima, Quito": { "iana": ["America/Bogota"] }, - "(UTC-04:00) Atlantic Time (Canada)": { "iana": ["America/Halifax"] }, - "(UTC-04:00) Georgetown, La Paz, San Juan": { "iana": ["America/La_Paz"] }, - "(UTC-04:00) Santiago": { "iana": ["America/Santiago"] }, - "(UTC-03:30) Newfoundland": { "iana": [null] }, - "(UTC-03:00) Brasilia": { "iana": ["America/Sao_Paulo"] }, - "(UTC-03:00) Georgetown": { "iana": ["America/Cayenne"] }, - "(UTC-03:00) Greenland": { "iana": ["America/Godthab"] }, - "(UTC-02:00) Mid-Atlantic": { "iana": [null] }, - "(UTC-01:00) Azores": { "iana": ["Atlantic/Azores"] }, - "(UTC-01:00) Cape Verde Islands": { "iana": ["Atlantic/Cape_Verde"] }, - "(UTC) Greenwich Mean Time: Dublin, Edinburgh, Lisbon, London": { "iana": [null] }, - "(UTC) Monrovia, Reykjavik": { "iana": ["Atlantic/Reykjavik"] }, - "(UTC+01:00) Belgrade, Bratislava, Budapest, Ljubljana, Prague": { "iana": ["Europe/Budapest"] }, - "(UTC+01:00) Sarajevo, Skopje, Warsaw, Zagreb": { "iana": ["Europe/Warsaw"] }, - "(UTC+01:00) Brussels, Copenhagen, Madrid, Paris": { "iana": ["Europe/Paris"] }, - "(UTC+01:00) Amsterdam, Berlin, Bern, Rome, Stockholm, Vienna": { "iana": ["Europe/Berlin"] }, - "(UTC+01:00) West Central Africa": { "iana": ["Africa/Lagos"] }, - "(UTC+02:00) Minsk": { "iana": ["Europe/Chisinau"] }, - "(UTC+02:00) Cairo": { "iana": ["Africa/Cairo"] }, - "(UTC+02:00) Helsinki, Kiev, Riga, Sofia, Tallinn, Vilnius": { "iana": ["Europe/Kiev"] }, - "(UTC+02:00) Athens, Bucharest, Istanbul": { "iana": ["Europe/Bucharest"] }, - "(UTC+02:00) Jerusalem": { "iana": ["Asia/Jerusalem"] }, - "(UTC+02:00) Harare, Pretoria": { "iana": ["Africa/Johannesburg"] }, - "(UTC+03:00) Moscow, St. Petersburg, Volgograd": { "iana": ["Europe/Moscow"] }, - "(UTC+03:00) Kuwait, Riyadh": { "iana": ["Asia/Riyadh"] }, - "(UTC+03:00) Nairobi": { "iana": ["Africa/Nairobi"] }, - "(UTC+03:00) Baghdad": { "iana": ["Asia/Baghdad"] }, - "(UTC+03:30) Tehran": { "iana": ["Asia/Tehran"] }, - "(UTC+04:00) Abu Dhabi, Muscat": { "iana": ["Asia/Dubai"] }, - "(UTC+04:00) Baku, Tbilisi, Yerevan": { "iana": ["Asia/Yerevan"] }, - "(UTC+04:30) Kabul": { "iana": [null] }, - "(UTC+05:00) Ekaterinburg": { "iana": ["Asia/Yekaterinburg"] }, - "(UTC+05:00) Tashkent": { "iana": ["Asia/Tashkent"] }, - "(UTC+05:30) Chennai, Kolkata, Mumbai, New Delhi": { "iana": ["Asia/Calcutta"] }, - "(UTC+05:45) Kathmandu": { "iana": ["Asia/Katmandu"] }, - "(UTC+06:00) Astana, Dhaka": { "iana": ["Asia/Almaty"] }, - "(UTC+06:00) Sri Jayawardenepura": { "iana": ["Asia/Colombo"] }, - "(UTC+06:00) Almaty, Novosibirsk": { "iana": ["Asia/Novosibirsk"] }, - "(UTC+06:30) Yangon (Rangoon)": { "iana": ["Asia/Rangoon"] }, - "(UTC+07:00) Bangkok, Hanoi, Jakarta": { "iana": ["Asia/Bangkok"] }, - "(UTC+07:00) Krasnoyarsk": { "iana": ["Asia/Krasnoyarsk"] }, - "(UTC+08:00) Beijing, Chongqing, Hong Kong, Urumqi": { "iana": ["Asia/Shanghai"] }, - "(UTC+08:00) Kuala Lumpur, Singapore": { "iana": ["Asia/Singapore"] }, - "(UTC+08:00) Taipei": { "iana": ["Asia/Taipei"] }, - "(UTC+08:00) Perth": { "iana": ["Australia/Perth"] }, - "(UTC+08:00) Irkutsk, Ulaanbaatar": { "iana": ["Asia/Irkutsk"] }, - "(UTC+09:00) Seoul": { "iana": ["Asia/Seoul"] }, - "(UTC+09:00) Osaka, Sapporo, Tokyo": { "iana": ["Asia/Tokyo"] }, - "(UTC+09:00) Yakutsk": { "iana": ["Asia/Yakutsk"] }, - "(UTC+09:30) Darwin": { "iana": ["Australia/Darwin"] }, - "(UTC+09:30) Adelaide": { "iana": ["Australia/Adelaide"] }, - "(UTC+10:00) Canberra, Melbourne, Sydney": { "iana": ["Australia/Sydney"] }, - "(GMT+10:00) Canberra, Melbourne, Sydney": { "iana": ["Australia/Sydney"] }, - "(UTC+10:00) Brisbane": { "iana": ["Australia/Brisbane"] }, - "(UTC+10:00) Hobart": { "iana": ["Australia/Hobart"] }, - "(UTC+10:00) Vladivostok": { "iana": ["Asia/Vladivostok"] }, - "(UTC+10:00) Guam, Port Moresby": { "iana": ["Pacific/Port_Moresby"] }, - "(UTC+11:00) Magadan, Solomon Islands, New Caledonia": { "iana": ["Pacific/Guadalcanal"] }, - "(UTC+12:00) Fiji, Kamchatka, Marshall Is.": { "iana": [null] }, - "(UTC+12:00) Auckland, Wellington": { "iana": ["Pacific/Auckland"] }, - "(UTC+13:00) Nuku'alofa": { "iana": ["Pacific/Tongatapu"] }, - "(UTC-03:00) Buenos Aires": { "iana": ["America/Buenos_Aires"] }, - "(UTC+02:00) Beirut": { "iana": ["Asia/Beirut"] }, - "(UTC+02:00) Amman": { "iana": ["Asia/Amman"] }, - "(UTC-06:00) Guadalajara, Mexico City, Monterrey - New": { "iana": ["America/Mexico_City"] }, - "(UTC-07:00) Chihuahua, La Paz, Mazatlan - New": { "iana": ["America/Chihuahua"] }, - "(UTC-08:00) Tijuana, Baja California": { "iana": ["America/Tijuana"] }, - "(UTC+02:00) Windhoek": { "iana": ["Africa/Windhoek"] }, - "(UTC+03:00) Tbilisi": { "iana": ["Asia/Tbilisi"] }, - "(UTC-04:00) Manaus": { "iana": ["America/Cuiaba"] }, - "(UTC-03:00) Montevideo": { "iana": ["America/Montevideo"] }, - "(UTC+04:00) Yerevan": { "iana": [null] }, - "(UTC-04:30) Caracas": { "iana": ["America/Caracas"] }, - "(UTC) Casablanca": { "iana": ["Africa/Casablanca"] }, - "(UTC+05:00) Islamabad, Karachi": { "iana": ["Asia/Karachi"] }, - "(UTC+04:00) Port Louis": { "iana": ["Indian/Mauritius"] }, - "(UTC) Coordinated Universal Time": { "iana": ["Etc/GMT"] }, - "(UTC-04:00) Asuncion": { "iana": ["America/Asuncion"] }, - "(UTC+12:00) Petropavlovsk-Kamchatsky": { "iana": [null] } -}