diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index e3ececd6..c09454eb 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -6,6 +6,8 @@ If you're not sure if it's a real bug or if it's just you, please open a topic o Problems installing or configuring your MagicMirror? Check out: [https://forum.magicmirror.builders/category/10/troubleshooting](https://forum.magicmirror.builders/category/10/troubleshooting) +A common problem is that your config file could be invalid. Please run in your MagicMirror directory: `npm run config:check` and see if it reports an error. + ## I found a bug in the MagicMirror installer If you are facing an issue or found a bug while trying to install MagicMirror via the installer please report it in the respective GitHub repository: @@ -23,9 +25,9 @@ If you are facing an issue or found a bug while running MagicMirror inside a Doc Please make sure to only submit reproducible issues. You can safely remove everything above the dividing line. When submitting a new issue, please supply the following information: -**Platform**: Place your platform here... give us your web browser/Electron version _and_ your hardware (Raspberry Pi 2/3, Windows, Mac, Linux, System V UNIX). +**Platform**: Place your platform here... give us your web browser/Electron version _and_ your hardware (Raspberry Pi 2/3/4, Windows, Mac, Linux, System V UNIX). -**Node Version**: Make sure it's version 8 or later. +**Node Version**: Make sure it's version 10 or later. **MagicMirror Version**: Please let us now which version of MagicMirror you are running. It can be found in the `package.log` file. diff --git a/.github/workflows/codecov-test-suites.yml b/.github/workflows/codecov-test-suites.yml new file mode 100644 index 00000000..0d98e3a0 --- /dev/null +++ b/.github/workflows/codecov-test-suites.yml @@ -0,0 +1,24 @@ +# This workflow runs the automated test and uploads the coverage results to codecov.io + +name: "Run Codecov Tests" + +on: + push: + branches: [ master, develop ] + pull_request: + branches: [ master, develop ] + +jobs: + run-and-upload-coverage-report: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - run: | + Xvfb :99 -screen 0 1024x768x16 & + export DISPLAY=:99 + npm ci + npm run test:coverage + - uses: codecov/codecov-action@v1 + with: + file: ./coverage/lcov.info + fail_ci_if_error: true diff --git a/.github/workflows/enforce-changelog.yml b/.github/workflows/enforce-changelog.yml index eefa1645..67a95a48 100644 --- a/.github/workflows/enforce-changelog.yml +++ b/.github/workflows/enforce-changelog.yml @@ -1,10 +1,12 @@ +# This workflow enforces the update of a changelog file on every pull request + name: "Enforce Changelog" + on: pull_request: types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] jobs: - # Enforces the update of a changelog file on every pull request check: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/node-ci.js.yml b/.github/workflows/node-ci.js.yml index f794c941..7f6f23b1 100644 --- a/.github/workflows/node-ci.js.yml +++ b/.github/workflows/node-ci.js.yml @@ -1,7 +1,7 @@ # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions -name: Automated Tests +name: "Run Automated Tests" on: push: @@ -11,13 +11,10 @@ on: jobs: test: - runs-on: ubuntu-latest - strategy: matrix: node-version: [10.x, 12.x, 14.x] - steps: - uses: actions/checkout@v2 - name: Use Node.js ${{ matrix.node-version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e981a81..7d30b8ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,66 @@ This project adheres to [Semantic Versioning](https://semver.org/). ❤️ **Donate:** Enjoying MagicMirror²? [Please consider a donation!](https://magicmirror.builders/donate) With your help we can continue to improve the MagicMirror² -## [2.14.0] - Unreleased (Develop Branch) +## [2.15.0] - Unreleased (Develop Branch) -_This release is scheduled to be released on 2021-01-01._ +_This release is scheduled to be released on 2021-04-01._ + +### Added + +- Added GitHub workflows for automated testing and changelog enforcement. +- Added CodeCov badge to Readme. +- Added CURRENTWEATHER_TYPE notification to currentweather and weather module, use it in compliments module. +- Added `start:dev` command to the npm scripts for starting electron with devTools open. +- Added logging when using deprecated modules weatherforecast or currentweather. +- Portuguese translations for "MODULE_CONFIG_CHANGED" and PRECIP. +- Respect parameter ColoredSymbolOnly also for custom events +- Added a new parameter to hide time portion on relative times +- `module.show` has now the option for a callback on error. +- Added locale to sample config file +- Added support for self-signed certificates for the default calendar module (#466) +- Added hiddenOnStartup flag to module config (#2475) + +### Updated + +- Updated markdown files. +- Cleaned up old code on server side. +- Convert `-0` to `0` when displaying temperature. +- Code cleanup for FEELS like and added {DEGREE} placeholder for FEELSLIKE for each language +- Converted newsfeed module to use templates. +- Update documentation and help screen about invalid config files. +- Moving weather provider specific code and configuration into each provider and making hourly part of the interface. +- Bump electron to v11 and enable contextIsolation. +- Don't update the DOM when a module is not displayed. +- Cleaned up jsdoc and tests. +- Exposed logger as node module for easier access for 3rd party modules +- Replaced deprecated `request` package with `node-fetch` and `digest-fetch` +- Refactored calendar fetcher + +### Removed + +- Removed danger.js library. +- Removed `ical` which was substituted by `node-ical` in release `v2.13.0`. Module developers must install this dependency themselves in the module folder if needed. +- Removed valid-url library. + +### Fixed + +- Added default log levels to stop calendar log spamming. +- Fix socket.io cors errors, see [breaking change since socket.io v3](https://socket.io/docs/v3/handling-cors/) +- Fix Issue with weather forecast icons due to fixed day start and end time (#2221) +- Fix empty directory for each module's main javascript file in the inspector +- Fix Issue with weather forecast icons unit tests with different timezones (#2221) +- Fix issue with unencoded characters in translated strings when using nunjuck template (`Loading …` as an example) +- Fix socket.io backward compatibility with socket v2 clients +- 3rd party module language loading if language is English +- Fix e2e tests after spectron update +- Fix updatenotification creating zombie processes by setting a timeout for the git process +- Fix weather module openweathermap not loading if lat and lon set without onecall. + +## [2.14.0] - 2021-01-01 + +Special thanks to the following contributors: @Alvinger, @AndyPoms, @ashishtank, @bluemanos, @flopp999, @jakemulley, @jakobsarwary1, @marvai-vgtu, @mirontoli, @rejas, @sdetweil, @Snille & @Sub028. + +ℹ️ **Note:** This update uses new dependencies. Please update using the following command: `git pull && npm install`. ### Added @@ -17,11 +74,12 @@ _This release is scheduled to be released on 2021-01-01._ - Added Weatherbit as a provider to Weather module. - Added SMHI as a provider to Weather module. - Added Hindi & Gujarati translation. -- Chuvash translation. +- Added optional support for DEGREE position in Feels like translation. +- Added support for variables in nunjucks templates for translate filter. +- Added Chuvash translation. - Calendar: new options "limitDays" and "coloredEvents". - Added new option "limitDays" - limit the number of discreet days displayed. - Added new option "customEvents" - use custom symbol/color based on keyword in event title. -- Added GitHub workflows for automated testing and changelog enforcement. ### Updated @@ -31,10 +89,16 @@ _This release is scheduled to be released on 2021-01-01._ - Update dependencies eslint, feedme, simple-git and socket.io to latest versions. - Update lithuanian translation. - Update config sample. +- Highlight required version mismatch. +- No select Text for TouchScreen use. +- Corrected logic for timeFormat "relative" and "absolute". +- Added missing function call in module.show() +- Translator variables can have falsy values (e.g. empty string) +- Fix issue with weather module with DEGREE label in FEELS like ### Deleted -- Removed Travis CI intergration. +- Removed Travis CI integration. ### Fixed @@ -50,15 +114,14 @@ _This release is scheduled to be released on 2021-01-01._ - Fix calendar full day event east of UTC start time. (#2200) - Fix non-fullday recurring rule processing. (#2216) - Catch errors when parsing calendar data with ical. (#2022) -- Corrected logic for timeFormat "relative" and "absolute". - Fix Default Alert Module does not hide black overlay when alert is dismissed manually. (#2228) -- Weather module - Always displays night icons when local is other then English. (#2221) -- update Node-ical 0.12.4 , fix invalid RRULE format in cal entries -- fix package.json for optional electron dependency (2378) -- update node-ical version again, 0.12.5, change RRULE fix (#2371, #2379) -- Added missing function call in module.show() -- remove undefined objects from modules array (#2382) -- update node-ical version again, 0.12.7, change RRULE fix (#2371, #2379), node-ical now throws error (which we catch) +- Weather module - Always displays night icons when local is other than English. (#2221) +- Update node-ical 0.12.4, fix invalid RRULE format in cal entries +- Fix package.json for optional electron dependency (2378) +- Update node-ical version again, 0.12.5, change RRULE fix (#2371, #2379) +- Remove undefined objects from modules array (#2382) +- Update node-ical version again, 0.12.7, change RRULE fix (#2371, #2379), node-ical now throws error (which we catch) +- Update simple-git version to 2.31 unhandled promise rejection (#2383) ## [2.13.0] - 2020-10-01 @@ -68,11 +131,11 @@ Special thanks to the following contributors: @bryanzzhu, @bugsounet, @chamakura ### Added -- `--dry-run` option adde in fetch call within updatenotification node_helper. This is to prevent +- `--dry-run` Added option in fetch call within updatenotification node_helper. This is to prevent MagicMirror from consuming any fetch result. Causes conflict with MMPM when attempting to check for updates to MagicMirror and/or MagicMirror modules. - Test coverage with Istanbul, run it with `npm run test:coverage`. -- Add lithuanian language. +- Added lithuanian language. - Added support in weatherforecast for OpenWeather onecall API. - Added config option to calendar-icons for recurring- and fullday-events. - Added current, hourly (max 48), and daily (max 7) weather forecasts to weather module via OpenWeatherMap One Call API. @@ -136,7 +199,7 @@ Special thanks to the following contributors: @AndreKoepke, @andrezibaia, @bryan - Fix the use of "maxNumberOfDays" in the module "weatherforecast". [#2018](https://github.com/MichMich/MagicMirror/issues/2018) - Throw error when check_config fails. [#1928](https://github.com/MichMich/MagicMirror/issues/1928) - Bug fix related to 'maxEntries' not displaying Calendar events. [#2050](https://github.com/MichMich/MagicMirror/issues/2050) -- Updated ical library to latest version. [#1926](https://github.com/MichMich/MagicMirror/issues/1926) +- Updated ical library to the latest version. [#1926](https://github.com/MichMich/MagicMirror/issues/1926) - Fix config check after merge of prettier [#2109](https://github.com/MichMich/MagicMirror/issues/2109) ## [2.11.0] - 2020-04-01 @@ -430,7 +493,7 @@ A huge, huge, huge thanks to user @fewieden for all his hard work on the new `we ### Fixed - Fixed gzip encoded calendar loading issue #1400. -- Mixup between german and spanish translation for newsfeed. +- Fixed mixup between german and spanish translation for newsfeed. - Fixed close dates to be absolute, if no configured in the config.js - module Calendar - Fixed the updatenotification module message about new commits in the repository, so they can be correctly localized in singular and plural form. - Fix for weatherforecast rainfall rounding [#1374](https://github.com/MichMich/MagicMirror/issues/1374) diff --git a/LICENSE.md b/LICENSE.md index b766bd7a..c511b4cb 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -1,6 +1,6 @@ # The MIT License (MIT) -Copyright © 2016-2020 Michael Teeuw +Copyright © 2016-2021 Michael Teeuw Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation diff --git a/README.md b/README.md index d4ca67ab..299f72c4 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,13 @@  -
+
**MagicMirror²** is an open source modular smart mirror platform. With a growing list of installable modules, the **MagicMirror²** allows you to convert your hallway or bathroom mirror into your personal assistant. **MagicMirror²** is built by the creator of [the original MagicMirror](https://michaelteeuw.nl/tagged/magicmirror) with the incredible help of a [growing community of contributors](https://github.com/MichMich/MagicMirror/graphs/contributors). @@ -38,7 +40,6 @@ If we receive enough donations we might even be able to free up some working hou To donate, please follow [this](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=G5D8E9MR5DTD2&source=url) link. -
-
+
npm run config:check" } }, { diff --git a/js/deprecated.js b/js/deprecated.js index 4f56b41d..6bf4a626 100644 --- a/js/deprecated.js +++ b/js/deprecated.js @@ -6,11 +6,6 @@ * Olex S. original idea this deprecated option */ -var deprecated = { +module.exports = { configs: ["kioskmode"] }; - -/*************** DO NOT EDIT THE LINE BELOW ***************/ -if (typeof module !== "undefined") { - module.exports = deprecated; -} diff --git a/js/electron.js b/js/electron.js index 216dee48..6c2f44a7 100644 --- a/js/electron.js +++ b/js/electron.js @@ -2,10 +2,10 @@ const electron = require("electron"); const core = require("./app.js"); -const Log = require("./logger.js"); +const Log = require("logger"); // Config -var config = process.env.config ? JSON.parse(process.env.config) : {}; +let config = process.env.config ? JSON.parse(process.env.config) : {}; // Module to control application life. const app = electron.app; // Module to create native browser window. @@ -20,13 +20,14 @@ let mainWindow; */ function createWindow() { app.commandLine.appendSwitch("autoplay-policy", "no-user-gesture-required"); - var electronOptionsDefaults = { + let electronOptionsDefaults = { width: 800, height: 600, x: 0, y: 0, darkTheme: true, webPreferences: { + contextIsolation: true, nodeIntegration: false, zoomFactor: config.zoom }, @@ -42,7 +43,7 @@ function createWindow() { electronOptionsDefaults.autoHideMenuBar = true; } - var electronOptions = Object.assign({}, electronOptionsDefaults, config.electronOptions); + const electronOptions = Object.assign({}, electronOptionsDefaults, config.electronOptions); // Create the browser window. mainWindow = new BrowserWindow(electronOptions); @@ -50,14 +51,14 @@ function createWindow() { // and load the index.html of the app. // If config.address is not defined or is an empty string (listening on all interfaces), connect to localhost - var prefix; + let prefix; if (config["tls"] !== null && config["tls"]) { prefix = "https://"; } else { prefix = "http://"; } - var address = (config.address === void 0) | (config.address === "") ? (config.address = "localhost") : config.address; + let address = (config.address === void 0) | (config.address === "") ? (config.address = "localhost") : config.address; mainWindow.loadURL(`${prefix}${address}:${config.port}`); // Open the DevTools if run with "npm start dev" @@ -125,7 +126,7 @@ app.on("before-quit", (event) => { // Start the core application if server is run on localhost // This starts all node helpers and starts the webserver. -if (["localhost", "127.0.0.1", "::1", "::ffff:127.0.0.1", undefined].indexOf(config.address) > -1) { +if (["localhost", "127.0.0.1", "::1", "::ffff:127.0.0.1", undefined].includes(config.address)) { core.start(function (c) { config = c; }); diff --git a/js/loader.js b/js/loader.js index 91a17329..f290ff44 100644 --- a/js/loader.js +++ b/js/loader.js @@ -54,6 +54,14 @@ var Loader = (function () { // Notify core of loaded modules. MM.modulesStarted(moduleObjects); + + // Starting modules also hides any modules that have requested to be initially hidden + for (let thisModule of moduleObjects) { + if (thisModule.data.hiddenOnStartup) { + Log.info("Initially hiding " + thisModule.name); + thisModule.hide(); + } + } }; /** @@ -97,6 +105,7 @@ var Loader = (function () { path: moduleFolder + "/", file: moduleName + ".js", position: moduleData.position, + hiddenOnStartup: moduleData.hiddenOnStartup, header: moduleData.header, configDeepMerge: typeof moduleData.configDeepMerge === "boolean" ? moduleData.configDeepMerge : false, config: moduleData.config, @@ -114,7 +123,7 @@ var Loader = (function () { * @param {Function} callback Function called when done. */ var loadModule = function (module, callback) { - var url = module.path + "/" + module.file; + var url = module.path + module.file; var afterLoad = function () { var moduleObject = Module.create(module.name); diff --git a/js/main.js b/js/main.js index b7138d99..6f5d9484 100644 --- a/js/main.js +++ b/js/main.js @@ -295,6 +295,9 @@ var MM = (function () { // Otherwise cancel show action. if (module.lockStrings.length !== 0 && options.force !== true) { Log.log("Will not show " + module.name + ". LockStrings active: " + module.lockStrings.join(",")); + if (typeof options.onError === "function") { + options.onError(new Error("LOCK_STRING_ACTIVE")); + } return; } @@ -440,7 +443,6 @@ var MM = (function () { * Removes a module instance from the collection. * * @param {object} module The module instance to remove from the collection. - * * @returns {Module[]} Filtered collection of modules. */ var exceptModule = function (module) { @@ -547,6 +549,11 @@ var MM = (function () { return; } + if (!module.data.position) { + Log.warn("module tries to update the DOM without being displayed."); + return; + } + // Further implementation is done in the private method. updateDom(module, speed); }, diff --git a/js/module.js b/js/module.js index d52eb02b..042b674b 100644 --- a/js/module.js +++ b/js/module.js @@ -175,8 +175,8 @@ var Module = Class.extend({ lstripBlocks: true }); - this._nunjucksEnvironment.addFilter("translate", function (str) { - return self.translate(str); + this._nunjucksEnvironment.addFilter("translate", function (str, variables) { + return nunjucks.runtime.markSafe(self.translate(str, variables)); }); return this._nunjucksEnvironment; @@ -311,33 +311,33 @@ var Module = Class.extend({ * * @param {Function} callback Function called when done. */ - loadTranslations: function (callback) { - var self = this; - var translations = this.getTranslations(); - var lang = config.language.toLowerCase(); + loadTranslations(callback) { + const translations = this.getTranslations() || {}; + const language = config.language.toLowerCase(); - // The variable `first` will contain the first - // defined translation after the following line. - for (var first in translations) { - break; - } + const languages = Object.keys(translations); + const fallbackLanguage = languages[0]; - if (translations) { - var translationFile = translations[lang] || undefined; - var translationsFallbackFile = translations[first]; - - // If a translation file is set, load it and then also load the fallback translation file. - // Otherwise only load the fallback translation file. - if (translationFile !== undefined && translationFile !== translationsFallbackFile) { - Translator.load(self, translationFile, false, function () { - Translator.load(self, translationsFallbackFile, true, callback); - }); - } else { - Translator.load(self, translationsFallbackFile, true, callback); - } - } else { + if (languages.length === 0) { callback(); + return; } + + const translationFile = translations[language]; + const translationsFallbackFile = translations[fallbackLanguage]; + + if (!translationFile) { + Translator.load(this, translationsFallbackFile, true, callback); + return; + } + + Translator.load(this, translationFile, false, () => { + if (translationFile !== translationsFallbackFile) { + Translator.load(this, translationsFallbackFile, true, callback); + } else { + callback(); + } + }); }, /** @@ -428,12 +428,11 @@ var Module = Class.extend({ callback = callback || function () {}; options = options || {}; - var self = this; MM.showModule( this, speed, - function () { - self.resume(); + () => { + this.resume(); callback(); }, options @@ -510,7 +509,7 @@ Module.register = function (name, moduleDefinition) { if (cmpVersions(window.version, moduleDefinition.requiresVersion) >= 0) { Log.log("Version is ok!"); } else { - Log.log("Version is incorrect. Skip module: '" + name + "'"); + Log.warn("Version is incorrect. Skip module: '" + name + "'"); return; } } diff --git a/js/node_helper.js b/js/node_helper.js index 4a31fdee..81d2d9d5 100644 --- a/js/node_helper.js +++ b/js/node_helper.js @@ -5,21 +5,21 @@ * MIT Licensed. */ const Class = require("./class.js"); -const Log = require("./logger.js"); +const Log = require("logger"); const express = require("express"); -var NodeHelper = Class.extend({ - init: function () { +const NodeHelper = Class.extend({ + init() { Log.log("Initializing new module helper ..."); }, - loaded: function (callback) { - Log.log("Module helper loaded: " + this.name); + loaded(callback) { + Log.log(`Module helper loaded: ${this.name}`); callback(); }, - start: function () { - Log.log("Starting module helper: " + this.name); + start() { + Log.log(`Starting module helper: ${this.name}`); }, /* stop() @@ -28,8 +28,8 @@ var NodeHelper = Class.extend({ * gracefully exit the module. * */ - stop: function () { - Log.log("Stopping module helper: " + this.name); + stop() { + Log.log(`Stopping module helper: ${this.name}`); }, /* socketNotificationReceived(notification, payload) @@ -38,8 +38,8 @@ var NodeHelper = Class.extend({ * argument notification string - The identifier of the notification. * argument payload mixed - The payload of the notification. */ - socketNotificationReceived: function (notification, payload) { - Log.log(this.name + " received a socket notification: " + notification + " - Payload: " + payload); + socketNotificationReceived(notification, payload) { + Log.log(`${this.name} received a socket notification: ${notification} - Payload: ${payload}`); }, /* setName(name) @@ -47,7 +47,7 @@ var NodeHelper = Class.extend({ * * argument name string - Module name. */ - setName: function (name) { + setName(name) { this.name = name; }, @@ -56,7 +56,7 @@ var NodeHelper = Class.extend({ * * argument path string - Module path. */ - setPath: function (path) { + setPath(path) { this.path = path; }, @@ -66,7 +66,7 @@ var NodeHelper = Class.extend({ * argument notification string - The identifier of the notification. * argument payload mixed - The payload of the notification. */ - sendSocketNotification: function (notification, payload) { + sendSocketNotification(notification, payload) { this.io.of(this.name).emit(notification, payload); }, @@ -76,11 +76,10 @@ var NodeHelper = Class.extend({ * * argument app Express app - The Express app object. */ - setExpressApp: function (app) { + setExpressApp(app) { this.expressApp = app; - var publicPath = this.path + "/public"; - app.use("/" + this.name, express.static(publicPath)); + app.use(`/${this.name}`, express.static(`${this.path}/public`)); }, /* setSocketIO(io) @@ -89,27 +88,25 @@ var NodeHelper = Class.extend({ * * argument io Socket.io - The Socket io object. */ - setSocketIO: function (io) { - var self = this; - self.io = io; + setSocketIO(io) { + this.io = io; - Log.log("Connecting socket for: " + this.name); - var namespace = this.name; - io.of(namespace).on("connection", function (socket) { + Log.log(`Connecting socket for: ${this.name}`); + + io.of(this.name).on("connection", (socket) => { // add a catch all event. - var onevent = socket.onevent; + const onevent = socket.onevent; socket.onevent = function (packet) { - var args = packet.data || []; + const args = packet.data || []; onevent.call(this, packet); // original call packet.data = ["*"].concat(args); onevent.call(this, packet); // additional call to catch-all }; // register catch all. - socket.on("*", function (notification, payload) { + socket.on("*", (notification, payload) => { if (notification !== "*") { - //Log.log('received message in namespace: ' + namespace); - self.socketNotificationReceived(notification, payload); + this.socketNotificationReceived(notification, payload); } }); }); @@ -120,7 +117,4 @@ NodeHelper.create = function (moduleDefinition) { return NodeHelper.extend(moduleDefinition); }; -/*************** DO NOT EDIT THE LINE BELOW ***************/ -if (typeof module !== "undefined") { - module.exports = NodeHelper; -} +module.exports = NodeHelper; diff --git a/js/server.js b/js/server.js index cd26190b..5ce9435d 100644 --- a/js/server.js +++ b/js/server.js @@ -4,25 +4,29 @@ * By Michael Teeuw https://michaelteeuw.nl * MIT Licensed. */ -var express = require("express"); -var app = require("express")(); -var path = require("path"); -var ipfilter = require("express-ipfilter").IpFilter; -var fs = require("fs"); -var helmet = require("helmet"); +const express = require("express"); +const app = require("express")(); +const path = require("path"); +const ipfilter = require("express-ipfilter").IpFilter; +const fs = require("fs"); +const helmet = require("helmet"); -var Log = require("./logger.js"); -var Utils = require("./utils.js"); +const Log = require("logger"); +const Utils = require("./utils.js"); -var Server = function (config, callback) { - var port = config.port; - if (process.env.MM_PORT) { - port = process.env.MM_PORT; - } +/** + * Server + * + * @param {object} config The MM config + * @param {Function} callback Function called when done. + * @class + */ +function Server(config, callback) { + const port = process.env.MM_PORT || config.port; - var server = null; + let server = null; if (config.useHttps) { - var options = { + const options = { key: fs.readFileSync(config.httpsPrivateKey), cert: fs.readFileSync(config.httpsCertificate) }; @@ -30,18 +34,24 @@ var Server = function (config, callback) { } else { server = require("http").Server(app); } - var io = require("socket.io")(server); + const io = require("socket.io")(server, { + cors: { + origin: /.*$/, + credentials: true + }, + allowEIO3: true + }); - Log.log("Starting server on port " + port + " ... "); + Log.log(`Starting server on port ${port} ... `); - server.listen(port, config.address ? config.address : "localhost"); + server.listen(port, config.address || "localhost"); if (config.ipWhitelist instanceof Array && config.ipWhitelist.length === 0) { Log.warn(Utils.colors.warn("You're using a full whitelist configuration to allow for all IPs")); } app.use(function (req, res, next) { - var result = ipfilter(config.ipWhitelist, { mode: config.ipWhitelist.length === 0 ? "deny" : "allow", log: false })(req, res, function (err) { + ipfilter(config.ipWhitelist, { mode: config.ipWhitelist.length === 0 ? "deny" : "allow", log: false })(req, res, function (err) { if (err === undefined) { return next(); } @@ -52,10 +62,9 @@ var Server = function (config, callback) { app.use(helmet({ contentSecurityPolicy: false })); app.use("/js", express.static(__dirname)); - var directories = ["/config", "/css", "/fonts", "/modules", "/vendor", "/translations", "/tests/configs"]; - var directory; - for (var i in directories) { - directory = directories[i]; + + const directories = ["/config", "/css", "/fonts", "/modules", "/vendor", "/translations", "/tests/configs"]; + for (const directory of directories) { app.use(directory, express.static(path.resolve(global.root_path + directory))); } @@ -68,10 +77,10 @@ var Server = function (config, callback) { }); app.get("/", function (req, res) { - var html = fs.readFileSync(path.resolve(global.root_path + "/index.html"), { encoding: "utf8" }); + let html = fs.readFileSync(path.resolve(`${global.root_path}/index.html`), { encoding: "utf8" }); html = html.replace("#VERSION#", global.version); - var configFile = "config/config.js"; + let configFile = "config/config.js"; if (typeof global.configuration_file !== "undefined") { configFile = global.configuration_file; } @@ -83,6 +92,6 @@ var Server = function (config, callback) { if (typeof callback === "function") { callback(app, io); } -}; +} module.exports = Server; diff --git a/js/translator.js b/js/translator.js index 5dc50045..841adb66 100644 --- a/js/translator.js +++ b/js/translator.js @@ -68,7 +68,7 @@ var Translator = (function () { template = variables.fallback; } return template.replace(new RegExp("{([^}]+)}", "g"), function (_unused, varName) { - return variables[varName] || "{" + varName + "}"; + return varName in variables ? variables[varName] : "{" + varName + "}"; }); } @@ -103,26 +103,19 @@ var Translator = (function () { * @param {boolean} isFallback Flag to indicate fallback translations. * @param {Function} callback Function called when done. */ - load: function (module, file, isFallback, callback) { - if (!isFallback) { - Log.log(module.name + " - Load translation: " + file); - } else { - Log.log(module.name + " - Load translation fallback: " + file); + load(module, file, isFallback, callback) { + Log.log(`${module.name} - Load translation${isFallback && " fallback"}: ${file}`); + + if (this.translationsFallback[module.name]) { + callback(); + return; } - var self = this; - if (!this.translationsFallback[module.name]) { - loadJSON(module.file(file), function (json) { - if (!isFallback) { - self.translations[module.name] = json; - } else { - self.translationsFallback[module.name] = json; - } - callback(); - }); - } else { + loadJSON(module.file(file), (json) => { + const property = isFallback ? "translationsFallback" : "translations"; + this[property][module.name] = json; callback(); - } + }); }, /** diff --git a/js/utils.js b/js/utils.js index 5044447d..1043ae3e 100644 --- a/js/utils.js +++ b/js/utils.js @@ -4,9 +4,9 @@ * By Rodrigo Ramírez Norambuena https://rodrigoramirez.com * MIT Licensed. */ -var colors = require("colors/safe"); +const colors = require("colors/safe"); -var Utils = { +module.exports = { colors: { warn: colors.yellow, error: colors.red, @@ -14,7 +14,3 @@ var Utils = { pass: colors.green } }; - -if (typeof module !== "undefined") { - module.exports = Utils; -} diff --git a/modules/default/calendar/calendar.js b/modules/default/calendar/calendar.js index 73af9ecd..4a485e4f 100755 --- a/modules/default/calendar/calendar.js +++ b/modules/default/calendar/calendar.js @@ -36,6 +36,7 @@ Module.register("calendar", { fadePoint: 0.25, // Start on 1/4th of the list. hidePrivate: false, hideOngoing: false, + hideTime: false, colored: false, coloredSymbolOnly: false, customEvents: [], // Array of {keyword: "", symbol: "", color: ""} where Keyword is a regexp and symbol/color are to be applied for matched @@ -57,7 +58,8 @@ Module.register("calendar", { excludedEvents: [], sliceMultiDayEvents: false, broadcastPastEvents: false, - nextDaysRelative: false + nextDaysRelative: false, + selfSignedCert: false }, requiresVersion: "2.1.0", @@ -75,7 +77,7 @@ Module.register("calendar", { // Define required translations. getTranslations: function () { // The translations for the default modules are defined in the core translation files. - // Therefor we can just return false. Otherwise we should have returned a dictionary. + // 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; }, @@ -93,15 +95,16 @@ Module.register("calendar", { // indicate no data available yet this.loaded = false; - for (var c in this.config.calendars) { - var calendar = this.config.calendars[c]; + this.config.calendars.forEach((calendar) => { calendar.url = calendar.url.replace("webcal://", "http://"); - var calendarConfig = { + const calendarConfig = { maximumEntries: calendar.maximumEntries, maximumNumberOfDays: calendar.maximumNumberOfDays, - broadcastPastEvents: calendar.broadcastPastEvents + broadcastPastEvents: calendar.broadcastPastEvents, + selfSignedCert: calendar.selfSignedCert }; + if (calendar.symbolClass === "undefined" || calendar.symbolClass === null) { calendarConfig.symbolClass = ""; } @@ -125,7 +128,7 @@ Module.register("calendar", { // tell helper to start a fetcher for this calendar // fetcher till cycle this.addCalendar(calendar.url, calendar.auth, calendarConfig); - } + }); }, // Override socket notification handler. @@ -161,8 +164,8 @@ Module.register("calendar", { const oneHour = oneMinute * 60; const oneDay = oneHour * 24; - var events = this.createEventList(); - var wrapper = document.createElement("table"); + const events = this.createEventList(); + const wrapper = document.createElement("table"); wrapper.className = this.config.tableClass; if (events.length === 0) { @@ -171,37 +174,37 @@ Module.register("calendar", { 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; } - var startFade = events.length * this.config.fadePoint; - var fadeSteps = events.length - startFade; + startFade = events.length * this.config.fadePoint; + fadeSteps = events.length - startFade; } - var currentFadeStep = 0; - var lastSeenDate = ""; - var ev; - var needle; + let lastSeenDate = ""; - for (var e in events) { - var event = events[e]; - var dateAsString = moment(event.startDate, "x").format(this.config.dateFormat); + events.forEach((event, index) => { + const dateAsString = moment(event.startDate, "x").format(this.config.dateFormat); if (this.config.timeFormat === "dateheaders") { if (lastSeenDate !== dateAsString) { - var dateRow = document.createElement("tr"); + const dateRow = document.createElement("tr"); dateRow.className = "normal"; - var dateCell = document.createElement("td"); + const dateCell = document.createElement("td"); dateCell.colSpan = "3"; dateCell.innerHTML = dateAsString; dateCell.style.paddingTop = "10px"; dateRow.appendChild(dateCell); wrapper.appendChild(dateRow); - if (e >= startFade) { + if (this.config.fade && index >= startFade) { //fading - currentFadeStep = e - startFade; + currentFadeStep = index - startFade; dateRow.style.opacity = 1 - (1 / fadeSteps) * currentFadeStep; } @@ -209,7 +212,7 @@ Module.register("calendar", { } } - var eventWrapper = document.createElement("tr"); + const eventWrapper = document.createElement("tr"); if (this.config.colored && !this.config.coloredSymbolOnly) { eventWrapper.style.cssText = "color:" + this.colorForUrl(event.url); @@ -217,22 +220,22 @@ Module.register("calendar", { eventWrapper.className = "normal event"; - if (this.config.displaySymbol) { - var symbolWrapper = document.createElement("td"); + const symbolWrapper = document.createElement("td"); + if (this.config.displaySymbol) { if (this.config.colored && this.config.coloredSymbolOnly) { symbolWrapper.style.cssText = "color:" + this.colorForUrl(event.url); } - var symbolClass = this.symbolClassForUrl(event.url); + const symbolClass = this.symbolClassForUrl(event.url); symbolWrapper.className = "symbol align-right " + symbolClass; - var symbols = this.symbolsForEvent(event); + const symbols = this.symbolsForEvent(event); // If symbols are displayed and custom symbol is set, replace event symbol if (this.config.displaySymbol && this.config.customEvents.length > 0) { - for (ev in this.config.customEvents) { + for (let ev in this.config.customEvents) { if (typeof this.config.customEvents[ev].symbol !== "undefined" && this.config.customEvents[ev].symbol !== "") { - needle = new RegExp(this.config.customEvents[ev].keyword, "gi"); + let needle = new RegExp(this.config.customEvents[ev].keyword, "gi"); if (needle.test(event.title)) { symbols[0] = this.config.customEvents[ev].symbol; break; @@ -240,31 +243,29 @@ Module.register("calendar", { } } } - - for (var i = 0; i < symbols.length; i++) { - var symbol = document.createElement("span"); - symbol.className = "fa fa-fw fa-" + symbols[i]; - if (i > 0) { + symbols.forEach((s, index) => { + const symbol = document.createElement("span"); + symbol.className = "fa fa-fw fa-" + s; + if (index > 0) { symbol.style.paddingLeft = "5px"; } symbolWrapper.appendChild(symbol); - } - + }); eventWrapper.appendChild(symbolWrapper); } else if (this.config.timeFormat === "dateheaders") { - var blankCell = document.createElement("td"); + const blankCell = document.createElement("td"); blankCell.innerHTML = " "; eventWrapper.appendChild(blankCell); } - var titleWrapper = document.createElement("td"), - repeatingCountTitle = ""; + const titleWrapper = document.createElement("td"); + let repeatingCountTitle = ""; if (this.config.displayRepeatingCountTitle && event.firstYear !== undefined) { repeatingCountTitle = this.countTitleForUrl(event.url); if (repeatingCountTitle !== "") { - var thisYear = new Date(parseInt(event.startDate)).getFullYear(), + const thisYear = new Date(parseInt(event.startDate)).getFullYear(), yearDiff = thisYear - event.firstYear; repeatingCountTitle = ", " + yearDiff + ". " + repeatingCountTitle; @@ -273,12 +274,15 @@ Module.register("calendar", { // Color events if custom color is specified if (this.config.customEvents.length > 0) { - for (ev in this.config.customEvents) { + for (let ev in this.config.customEvents) { if (typeof this.config.customEvents[ev].color !== "undefined" && this.config.customEvents[ev].color !== "") { - needle = new RegExp(this.config.customEvents[ev].keyword, "gi"); + let needle = new RegExp(this.config.customEvents[ev].keyword, "gi"); if (needle.test(event.title)) { - eventWrapper.style.cssText = "color:" + this.config.customEvents[ev].color; - titleWrapper.style.cssText = "color:" + this.config.customEvents[ev].color; + // Respect parameter ColoredSymbolOnly also for custom events + if (!this.config.coloredSymbolOnly) { + eventWrapper.style.cssText = "color:" + this.config.customEvents[ev].color; + titleWrapper.style.cssText = "color:" + this.config.customEvents[ev].color; + } if (this.config.displaySymbol) { symbolWrapper.style.cssText = "color:" + this.config.customEvents[ev].color; } @@ -290,7 +294,7 @@ Module.register("calendar", { titleWrapper.innerHTML = this.titleTransform(event.title, this.config.titleReplace, this.config.wrapEvents, this.config.maxTitleLength, this.config.maxTitleLines) + repeatingCountTitle; - var titleClass = this.titleClassForUrl(event.url); + const titleClass = this.titleClassForUrl(event.url); if (!this.config.colored) { titleWrapper.className = "title bright " + titleClass; @@ -298,14 +302,12 @@ Module.register("calendar", { titleWrapper.className = "title " + titleClass; } - var timeWrapper; - if (this.config.timeFormat === "dateheaders") { if (event.fullDayEvent) { titleWrapper.colSpan = "2"; titleWrapper.align = "left"; } else { - timeWrapper = document.createElement("td"); + const timeWrapper = document.createElement("td"); timeWrapper.className = "time light " + this.timeClassForUrl(event.url); timeWrapper.align = "left"; timeWrapper.style.paddingLeft = "2px"; @@ -316,10 +318,10 @@ Module.register("calendar", { eventWrapper.appendChild(titleWrapper); } else { - timeWrapper = document.createElement("td"); + const timeWrapper = document.createElement("td"); eventWrapper.appendChild(titleWrapper); - var now = new Date(); + const now = new Date(); if (this.config.timeFormat === "absolute") { // Use dateFormat @@ -363,7 +365,17 @@ Module.register("calendar", { // Show relative times if (event.startDate >= now) { // Use relative time - timeWrapper.innerHTML = this.capFirst(moment(event.startDate, "x").calendar()); + if (!this.config.hideTime) { + timeWrapper.innerHTML = this.capFirst(moment(event.startDate, "x").calendar()); + } else { + timeWrapper.innerHTML = this.capFirst( + moment(event.startDate, "x").calendar(null, { + sameDay: "[" + this.translate("TODAY") + "]", + nextDay: "[" + this.translate("TOMORROW") + "]", + nextWeek: "dddd" + }) + ); + } if (event.startDate - now < this.config.getRelative * oneHour) { // If event is within getRelative hours, display 'in xxx' time format or moment.fromNow() timeWrapper.innerHTML = this.capFirst(moment(event.startDate, "x").fromNow()); @@ -385,22 +397,22 @@ Module.register("calendar", { wrapper.appendChild(eventWrapper); // Create fade effect. - if (e >= startFade) { - currentFadeStep = e - startFade; + if (index >= startFade) { + currentFadeStep = index - startFade; eventWrapper.style.opacity = 1 - (1 / fadeSteps) * currentFadeStep; } if (this.config.showLocation) { if (event.location !== false) { - var locationRow = document.createElement("tr"); + const locationRow = document.createElement("tr"); locationRow.className = "normal xsmall light"; if (this.config.displaySymbol) { - var symbolCell = document.createElement("td"); + const symbolCell = document.createElement("td"); locationRow.appendChild(symbolCell); } - var descCell = document.createElement("td"); + const descCell = document.createElement("td"); descCell.className = "location"; descCell.colSpan = "2"; descCell.innerHTML = this.titleTransform(event.location, this.config.locationTitleReplace, this.config.wrapLocationEvents, this.config.maxLocationTitleLength, this.config.maxEventTitleLines); @@ -408,13 +420,13 @@ Module.register("calendar", { wrapper.appendChild(locationRow); - if (e >= startFade) { - currentFadeStep = e - startFade; + if (index >= startFade) { + currentFadeStep = index - startFade; locationRow.style.opacity = 1 - (1 / fadeSteps) * currentFadeStep; } } } - } + }); return wrapper; }, @@ -448,8 +460,7 @@ Module.register("calendar", { * @returns {boolean} True if the calendar config contains the url, False otherwise */ hasCalendarURL: function (url) { - for (var c in this.config.calendars) { - var calendar = this.config.calendars[c]; + for (const calendar of this.config.calendars) { if (calendar.url === url) { return true; } @@ -464,14 +475,16 @@ Module.register("calendar", { * @returns {object[]} Array with events. */ createEventList: function () { - var events = []; - var today = moment().startOf("day"); - var now = new Date(); - var future = moment().startOf("day").add(this.config.maximumNumberOfDays, "days").toDate(); - for (var c in this.calendarData) { - var calendar = this.calendarData[c]; - for (var e in calendar) { - var event = JSON.parse(JSON.stringify(calendar[e])); // clone object + const now = new Date(); + const today = moment().startOf("day"); + const future = moment().startOf("day").add(this.config.maximumNumberOfDays, "days").toDate(); + let events = []; + + for (const calendarUrl in this.calendarData) { + const calendar = this.calendarData[calendarUrl]; + console.log(calendar); + for (const e in calendar) { + const event = JSON.parse(JSON.stringify(calendar[e])); // clone object if (event.endDate < now) { continue; @@ -490,19 +503,19 @@ Module.register("calendar", { if (this.listContainsEvent(events, event)) { continue; } - event.url = c; + event.url = calendarUrl; event.today = event.startDate >= today && event.startDate < today + 24 * 60 * 60 * 1000; /* 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. */ - var maxCount = Math.ceil((event.endDate - 1 - moment(event.startDate, "x").endOf("day").format("x")) / (1000 * 60 * 60 * 24)) + 1; + const maxCount = Math.ceil((event.endDate - 1 - moment(event.startDate, "x").endOf("day").format("x")) / (1000 * 60 * 60 * 24)) + 1; if (this.config.sliceMultiDayEvents && maxCount > 1) { - var splitEvents = []; - var midnight = moment(event.startDate, "x").clone().startOf("day").add(1, "day").format("x"); - var count = 1; + const splitEvents = []; + let midnight = moment(event.startDate, "x").clone().startOf("day").add(1, "day").format("x"); + let count = 1; while (event.endDate > midnight) { - var thisEvent = JSON.parse(JSON.stringify(event)); // clone object + const thisEvent = JSON.parse(JSON.stringify(event)); // clone object thisEvent.today = thisEvent.startDate >= today && thisEvent.startDate < today + 24 * 60 * 60 * 1000; thisEvent.endDate = midnight; thisEvent.title += " (" + count + "/" + maxCount + ")"; @@ -516,9 +529,9 @@ Module.register("calendar", { event.title += " (" + count + "/" + maxCount + ")"; splitEvents.push(event); - for (event of splitEvents) { - if (event.endDate > now && event.endDate <= future) { - events.push(event); + for (let splitEvent of splitEvents) { + if (splitEvent.endDate > now && splitEvent.endDate <= future) { + events.push(splitEvent); } } } else { @@ -534,12 +547,11 @@ Module.register("calendar", { // Limit the number of days displayed // If limitDays is set > 0, limit display to that number of days if (this.config.limitDays > 0) { - var newEvents = []; - var lastDate = today.clone().subtract(1, "days").format("YYYYMMDD"); - var days = 0; - var eventDate; - for (var ev of events) { - eventDate = moment(ev.startDate, "x").format("YYYYMMDD"); + let newEvents = []; + let lastDate = today.clone().subtract(1, "days").format("YYYYMMDD"); + let days = 0; + for (const ev of events) { + let eventDate = moment(ev.startDate, "x").format("YYYYMMDD"); // if date of event is later than lastdate // check if we already are showing max unique days if (eventDate > lastDate) { @@ -563,7 +575,7 @@ Module.register("calendar", { }, listContainsEvent: function (eventList, event) { - for (var evt of eventList) { + for (const evt of eventList) { if (evt.title === event.title && parseInt(evt.startDate) === parseInt(event.startDate)) { return true; } @@ -579,8 +591,6 @@ Module.register("calendar", { * @param {object} calendarConfig The config of the specific calendar */ addCalendar: function (url, auth, calendarConfig) { - var self = this; - this.sendSocketNotification("ADD_CALENDAR", { id: this.identifier, url: url, @@ -592,7 +602,8 @@ Module.register("calendar", { titleClass: calendarConfig.titleClass, timeClass: calendarConfig.timeClass, auth: auth, - broadcastPastEvents: calendarConfig.broadcastPastEvents || this.config.broadcastPastEvents + broadcastPastEvents: calendarConfig.broadcastPastEvents || this.config.broadcastPastEvents, + selfSignedCert: calendarConfig.selfSignedCert || this.config.selfSignedCert }); }, @@ -693,8 +704,7 @@ Module.register("calendar", { * @returns {*} The property */ getCalendarProperty: function (url, property, defaultValue) { - for (var c in this.config.calendars) { - var calendar = this.config.calendars[c]; + for (const calendar of this.config.calendars) { if (calendar.url === url && calendar.hasOwnProperty(property)) { return calendar[property]; } @@ -728,13 +738,13 @@ Module.register("calendar", { } if (wrapEvents === true) { - var temp = ""; - var currentLine = ""; - var words = string.split(" "); - var line = 0; + const words = string.split(" "); + let temp = ""; + let currentLine = ""; + let line = 0; - for (var i = 0; i < words.length; i++) { - var word = words[i]; + 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 + " "; @@ -789,10 +799,10 @@ Module.register("calendar", { * @returns {string} The transformed title. */ titleTransform: function (title, titleReplace, wrapEvents, maxTitleLength, maxTitleLines) { - for (var needle in titleReplace) { - var replacement = titleReplace[needle]; + for (let needle in titleReplace) { + const replacement = titleReplace[needle]; - var regParts = needle.match(/^\/(.+)\/([gim]*)$/); + const regParts = needle.match(/^\/(.+)\/([gim]*)$/); if (regParts) { // the parsed pattern is a regexp. needle = new RegExp(regParts[1], regParts[2]); @@ -810,11 +820,10 @@ Module.register("calendar", { * The all events available in one array, sorted on startdate. */ broadcastEvents: function () { - var eventList = []; - for (var url in this.calendarData) { - var calendar = this.calendarData[url]; - for (var e in calendar) { - var event = cloneObject(calendar[e]); + const eventList = []; + for (const url in this.calendarData) { + for (const ev of this.calendarData[url]) { + const event = cloneObject(ev); event.symbol = this.symbolsForEvent(event); event.calendarName = this.calendarNameForUrl(url); event.color = this.colorForUrl(url); diff --git a/modules/default/calendar/calendarfetcher.js b/modules/default/calendar/calendarfetcher.js index 6c268c46..d43e2304 100644 --- a/modules/default/calendar/calendarfetcher.js +++ b/modules/default/calendar/calendarfetcher.js @@ -4,17 +4,12 @@ * By Michael Teeuw https://michaelteeuw.nl * MIT Licensed. */ -const Log = require("../../../js/logger.js"); +const CalendarUtils = require("./calendarutils"); +const Log = require("logger"); const ical = require("node-ical"); -const request = require("request"); - -/** - * Moment date - * - * @external Moment - * @see {@link http://momentjs.com} - */ -const moment = require("moment"); +const fetch = require("node-fetch"); +const digest = require("digest-fetch"); +const https = require("https"); /** * @@ -25,11 +20,10 @@ const moment = require("moment"); * @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) { - const self = this; - +const CalendarFetcher = function (url, reloadInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, includePastEvents, selfSignedCert) { let reloadTimer = null; let events = []; @@ -39,492 +33,67 @@ const CalendarFetcher = function (url, reloadInterval, excludedEvents, maximumEn /** * Initiates calendar fetch. */ - const fetchCalendar = function () { + const fetchCalendar = () => { clearTimeout(reloadTimer); reloadTimer = null; - const nodeVersion = Number(process.version.match(/^v(\d+\.\d+)/)[1]); - const opts = { - headers: { - "User-Agent": "Mozilla/5.0 (Node.js " + nodeVersion + ") MagicMirror/" + global.version + " (https://github.com/MichMich/MagicMirror/)" - }, - gzip: true + let fetcher = null; + let httpsAgent = null; + let headers = { + "User-Agent": "Mozilla/5.0 (Node.js " + nodeVersion + ") MagicMirror/" + global.version + " (https://github.com/MichMich/MagicMirror/)" }; + if (selfSignedCert) { + httpsAgent = new https.Agent({ + rejectUnauthorized: false + }); + } if (auth) { if (auth.method === "bearer") { - opts.auth = { - bearer: auth.pass - }; + headers.Authorization = "Bearer " + auth.pass; + } else if (auth.method === "digest") { + fetcher = new digest(auth.user, auth.pass).fetch(url, { headers: headers, httpsAgent: httpsAgent }); } else { - opts.auth = { - user: auth.user, - pass: auth.pass, - sendImmediately: auth.method !== "digest" - }; + headers.Authorization = "Basic " + Buffer.from(auth.user + ":" + auth.pass).toString("base64"); } } + if (fetcher === null) { + fetcher = fetch(url, { headers: headers, httpsAgent: httpsAgent }); + } - request(url, opts, function (err, r, requestData) { - if (err) { - fetchFailedCallback(self, err); + fetcher + .catch((error) => { + fetchFailedCallback(this, error); scheduleTimer(); - return; - } else if (r.statusCode !== 200) { - fetchFailedCallback(self, r.statusCode + ": " + r.statusMessage); + }) + .then((response) => { + if (response.status !== 200) { + fetchFailedCallback(this, response.statusText); + scheduleTimer(); + } + return response; + }) + .then((response) => response.text()) + .then((responseData) => { + let data = []; + + try { + data = ical.parseICS(responseData); + Log.debug("parsed data=" + JSON.stringify(data)); + events = CalendarUtils.filterEvents(data, { + excludedEvents, + includePastEvents, + maximumEntries, + maximumNumberOfDays + }); + } catch (error) { + fetchFailedCallback(self, error.message); + scheduleTimer(); + return; + } + this.broadcastEvents(); scheduleTimer(); - return; - } - - let data = []; - - try { - data = ical.parseICS(requestData); - } catch (error) { - fetchFailedCallback(self, error.message); - scheduleTimer(); - return; - } - - Log.debug(" parsed data=" + JSON.stringify(data)); - - const newEvents = []; - - // limitFunction doesn't do much limiting, see comment re: the dates array in rrule section below as to why we need to do the filtering ourselves - const limitFunction = function (date, i) { - return true; - }; - - const eventDate = function (event, time) { - return isFullDayEvent(event) ? moment(event[time], "YYYYMMDD") : moment(new Date(event[time])); - }; - Log.debug("there are " + Object.entries(data).length + " calendar entries"); - Object.entries(data).forEach(([key, event]) => { - const now = new Date(); - const today = moment().startOf("day").toDate(); - const future = moment().startOf("day").add(maximumNumberOfDays, "days").subtract(1, "seconds").toDate(); // Subtract 1 second so that events that start on the middle of the night will not repeat. - let past = today; - Log.debug("have entries "); - if (includePastEvents) { - past = moment().startOf("day").subtract(maximumNumberOfDays, "days").toDate(); - } - - // FIXME: Ugly fix to solve the facebook birthday issue. - // Otherwise, the recurring events only show the birthday for next year. - let isFacebookBirthday = false; - if (typeof event.uid !== "undefined") { - if (event.uid.indexOf("@facebook.com") !== -1) { - isFacebookBirthday = true; - } - } - - if (event.type === "VEVENT") { - let startDate = eventDate(event, "start"); - let endDate; - - Log.debug("\nevent=" + JSON.stringify(event)); - if (typeof event.end !== "undefined") { - endDate = eventDate(event, "end"); - } else if (typeof event.duration !== "undefined") { - endDate = startDate.clone().add(moment.duration(event.duration)); - } else { - if (!isFacebookBirthday) { - // make copy of start date, separate storage area - endDate = moment(startDate.format("x"), "x"); - } else { - endDate = moment(startDate).add(1, "days"); - } - } - - Log.debug(" start=" + startDate.toDate() + " end=" + endDate.toDate()); - - // calculate the duration of the event for use with recurring events. - let duration = parseInt(endDate.format("x")) - parseInt(startDate.format("x")); - - if (event.start.length === 8) { - startDate = startDate.startOf("day"); - } - - const title = getTitleFromEvent(event); - - let excluded = false, - dateFilter = null; - - for (let f in excludedEvents) { - let filter = 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 (testTitleByFilter(testTitle, filter, useRegex, regexFlags)) { - if (until) { - dateFilter = until; - } else { - excluded = true; - } - break; - } - } - - if (excluded) { - return; - } - - const location = event.location || false; - const geo = event.geo || false; - const description = event.description || false; - - if (typeof event.rrule !== "undefined" && event.rrule !== null && !isFacebookBirthday) { - const rule = event.rrule; - let addedEvents = 0; - - const pastMoment = moment(past); - const futureMoment = moment(future); - - // can cause problems with e.g. birthdays before 1900 - if ((rule.options && rule.origOptions && rule.origOptions.dtstart && rule.origOptions.dtstart.getFullYear() < 1900) || (rule.options && rule.options.dtstart && rule.options.dtstart.getFullYear() < 1900)) { - rule.origOptions.dtstart.setYear(1900); - rule.options.dtstart.setYear(1900); - } - - // For recurring events, get the set of start dates that fall within the range - // of dates we're looking for. - // kblankenship1989 - to fix issue #1798, converting all dates to locale time first, then converting back to UTC time - let pastLocal = 0; - let futureLocal = 0; - if (isFullDayEvent(event)) { - // if full day event, only use the date part of the ranges - pastLocal = pastMoment.toDate(); - futureLocal = futureMoment.toDate(); - } else { - // if we want past events - if (includePastEvents) { - // use the calculated past time for the between from - pastLocal = pastMoment.toDate(); - } else { - // otherwise use NOW.. cause we shouldnt use any before now - pastLocal = moment().toDate(); //now - } - futureLocal = futureMoment.toDate(); // future - } - Log.debug(" between=" + pastLocal + " to " + futureLocal); - const dates = rule.between(pastLocal, futureLocal, true, limitFunction); - Log.debug("title=" + event.summary + " dates=" + JSON.stringify(dates)); - // The "dates" array contains the set of dates within our desired date range range that are valid - // for the recurrence rule. *However*, it's possible for us to have a specific recurrence that - // had its date changed from outside the range to inside the range. For the time being, - // we'll handle this by adding *all* recurrence entries into the set of dates that we check, - // because the logic below will filter out any recurrences that don't actually belong within - // our display range. - // Would be great if there was a better way to handle this. - if (event.recurrences !== undefined) { - for (let r in event.recurrences) { - // Only add dates that weren't already in the range we added from the rrule so that - // we don"t double-add those events. - if (moment(new Date(r)).isBetween(pastMoment, futureMoment) !== true) { - dates.push(new Date(r)); - } - } - } - // Loop through the set of date entries to see which recurrences should be added to our event list. - for (let d in dates) { - let date = dates[d]; - // ical.js started returning recurrences and exdates as ISOStrings without time information. - // .toISOString().substring(0,10) is the method they use to calculate keys, so we'll do the same - // (see https://github.com/peterbraden/ical.js/pull/84 ) - const dateKey = date.toISOString().substring(0, 10); - let curEvent = event; - let showRecurrence = true; - - // for full day events, the time might be off from RRULE/Luxon problem - if (isFullDayEvent(event)) { - Log.debug("fullday"); - // if the offset is negative, east of GMT where the problem is - if (date.getTimezoneOffset() < 0) { - // get the offset of today where we are processing - // this will be the correction we need to apply - let nowOffset = new Date().getTimezoneOffset(); - Log.debug("now offset is " + nowOffset); - // reduce the time by the offset - Log.debug(" recurring date is " + date + " offset is " + date.getTimezoneOffset()); - // apply the correction to the date/time to get it UTC relative - date = new Date(date.getTime() - Math.abs(nowOffset) * 60000); - // the duration was calculated way back at the top before we could correct the start time.. - // fix it for this event entry - duration = 24 * 60 * 60 * 1000; - Log.debug("new recurring date is " + date); - } - } - startDate = moment(date); - - let adjustDays = getCorrection(event, date); - - // For each date that we're checking, it's possible that there is a recurrence override for that one day. - if (curEvent.recurrences !== undefined && curEvent.recurrences[dateKey] !== undefined) { - // We found an override, so for this recurrence, use a potentially different title, start date, and duration. - curEvent = curEvent.recurrences[dateKey]; - startDate = moment(curEvent.start); - duration = parseInt(moment(curEvent.end).format("x")) - parseInt(startDate.format("x")); - } - // If there's no recurrence override, check for an exception date. Exception dates represent exceptions to the rule. - else if (curEvent.exdate !== undefined && curEvent.exdate[dateKey] !== undefined) { - // This date is an exception date, which means we should skip it in the recurrence pattern. - showRecurrence = false; - } - Log.debug("duration=" + duration); - - endDate = moment(parseInt(startDate.format("x")) + duration, "x"); - if (startDate.format("x") === endDate.format("x")) { - endDate = endDate.endOf("day"); - } - - const recurrenceTitle = getTitleFromEvent(curEvent); - - // If this recurrence ends before the start of the date range, or starts after the end of the date range, don"t add - // it to the event list. - if (endDate.isBefore(past) || startDate.isAfter(future)) { - showRecurrence = false; - } - - if (timeFilterApplies(now, endDate, dateFilter)) { - showRecurrence = false; - } - - if (showRecurrence === true) { - Log.debug("saving event =" + description); - addedEvents++; - newEvents.push({ - title: recurrenceTitle, - startDate: (adjustDays ? (adjustDays > 0 ? startDate.add(adjustDays, "hours") : startDate.subtract(Math.abs(adjustDays), "hours")) : startDate).format("x"), - endDate: (adjustDays ? (adjustDays > 0 ? endDate.add(adjustDays, "hours") : endDate.subtract(Math.abs(adjustDays), "hours")) : endDate).format("x"), - fullDayEvent: isFullDayEvent(event), - recurringEvent: true, - class: event.class, - firstYear: event.start.getFullYear(), - location: location, - geo: geo, - description: description - }); - } - } - // end recurring event parsing - } else { - // Single event. - const fullDayEvent = isFacebookBirthday ? true : isFullDayEvent(event); - // Log.debug("full day event") - - if (includePastEvents) { - // Past event is too far in the past, so skip. - if (endDate < past) { - return; - } - } else { - // It's not a fullday event, and it is in the past, so skip. - if (!fullDayEvent && endDate < new Date()) { - return; - } - - // It's a fullday event, and it is before today, So skip. - if (fullDayEvent && endDate <= today) { - return; - } - } - - // It exceeds the maximumNumberOfDays limit, so skip. - if (startDate > future) { - return; - } - - if (timeFilterApplies(now, endDate, dateFilter)) { - return; - } - - // Adjust start date so multiple day events will be displayed as happening today even though they started some days ago already - if (fullDayEvent && startDate <= today) { - startDate = moment(today); - } - // if the start and end are the same, then make end the 'end of day' value (start is at 00:00:00) - if (fullDayEvent && startDate.format("x") === endDate.format("x")) { - endDate = endDate.endOf("day"); - } - // get correction for date saving and dst change between now and then - let adjustDays = getCorrection(event, startDate.toDate()); - // Every thing is good. Add it to the list. - newEvents.push({ - title: title, - startDate: (adjustDays ? (adjustDays > 0 ? startDate.add(adjustDays, "hours") : startDate.subtract(Math.abs(adjustDays), "hours")) : startDate).format("x"), - endDate: (adjustDays ? (adjustDays > 0 ? endDate.add(adjustDays, "hours") : endDate.subtract(Math.abs(adjustDays), "hours")) : endDate).format("x"), - fullDayEvent: fullDayEvent, - class: event.class, - location: location, - geo: geo, - description: description - }); - } - } }); - - newEvents.sort(function (a, b) { - return a.startDate - b.startDate; - }); - - // include up to maximumEntries current or upcoming events - // If past events should be included, include all past events - const now = moment(); - var entries = 0; - events = []; - for (let ne of newEvents) { - if (moment(ne.endDate, "x").isBefore(now)) { - if (includePastEvents) events.push(ne); - continue; - } - entries++; - // If max events has been saved, skip the rest - if (entries > maximumEntries) break; - events.push(ne); - } - - self.broadcastEvents(); - scheduleTimer(); - }); - }; - - /* - * - * get the time correction, either dst/std or full day in cases where utc time is day before plus offset - * - */ - const getCorrection = function (event, date) { - let adjustHours = 0; - // if a timezone was specified - if (!event.start.tz) { - Log.debug(" if no tz, guess based on now"); - event.start.tz = moment.tz.guess(); - } - Log.debug("initial tz=" + event.start.tz); - - // if there is a start date specified - if (event.start.tz) { - // if this is a windows timezone - if (event.start.tz.includes(" ")) { - // use the lookup table to get theIANA name as moment and date don't know MS timezones - let tz = getIanaTZFromMS(event.start.tz); - Log.debug("corrected TZ=" + tz); - // watch out for unregistered windows timezone names - // if we had a successfule lookup - if (tz) { - // change the timezone to the IANA name - event.start.tz = tz; - // Log.debug("corrected timezone="+event.start.tz) - } - } - Log.debug("corrected tz=" + event.start.tz); - let current_offset = 0; // offset from TZ string or calculated - let mm = 0; // date with tz or offset - let start_offset = 0; // utc offset of created with tz - // if there is still an offset, lookup failed, use it - if (event.start.tz.startsWith("(")) { - const regex = /[+|-]\d*:\d*/; - const start_offsetString = event.start.tz.match(regex).toString().split(":"); - let start_offset = parseInt(start_offsetString[0]); - start_offset *= event.start.tz[1] === "-" ? -1 : 1; - adjustHours = start_offset; - Log.debug("defined offset=" + start_offset + " hours"); - current_offset = start_offset; - event.start.tz = ""; - Log.debug("ical offset=" + current_offset + " date=" + date); - mm = moment(date); - let x = parseInt(moment(new Date()).utcOffset()); - Log.debug("net mins=" + (current_offset * 60 - x)); - - mm = mm.add(x - current_offset * 60, "minutes"); - adjustHours = (current_offset * 60 - x) / 60; - event.start = mm.toDate(); - Log.debug("adjusted date=" + event.start); - } else { - // get the start time in that timezone - Log.debug("start date/time=" + moment(event.start).toDate()); - start_offset = moment.tz(moment(event.start), event.start.tz).utcOffset(); - Log.debug("start offset=" + start_offset); - - Log.debug("start date/time w tz =" + moment.tz(moment(event.start), event.start.tz).toDate()); - - // get the specified date in that timezone - mm = moment.tz(moment(date), event.start.tz); - Log.debug("event date=" + mm.toDate()); - current_offset = mm.utcOffset(); - } - Log.debug("event offset=" + current_offset + " hour=" + mm.format("H") + " event date=" + mm.toDate()); - - // if the offset is greater than 0, east of london - if (current_offset !== start_offset) { - // big offset - Log.debug("offset"); - let h = parseInt(mm.format("H")); - // check if the event time is less than the offset - if (h > 0 && h < Math.abs(current_offset) / 60) { - // if so, rrule created a wrong date (utc day, oops, with utc yesterday adjusted time) - // we need to fix that - adjustHours = 24; - // Log.debug("adjusting date") - } - //-300 > -240 - //if (Math.abs(current_offset) > Math.abs(start_offset)){ - if (current_offset > start_offset) { - adjustHours -= 1; - Log.debug("adjust down 1 hour dst change"); - //} else if (Math.abs(current_offset) < Math.abs(start_offset)) { - } else if (current_offset < start_offset) { - adjustHours += 1; - Log.debug("adjust up 1 hour dst change"); - } - } - } - Log.debug("adjustHours=" + adjustHours); - return adjustHours; - }; - - /** - * - * lookup iana tz from windows - */ - let zoneTable = null; - const getIanaTZFromMS = function (msTZName) { - if (!zoneTable) { - const p = require("path"); - zoneTable = require(p.join(__dirname, "windowsZones.json")); - } - // Get hash entry - const he = zoneTable[msTZName]; - // If found return iana name, else null - return he ? he.iana[0] : null; }; /** @@ -537,82 +106,6 @@ const CalendarFetcher = function (url, reloadInterval, excludedEvents, maximumEn }, reloadInterval); }; - /** - * 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 - */ - const isFullDayEvent = function (event) { - if (event.start.length === 8 || event.start.dateOnly || event.datetype === "date") { - return true; - } - - const start = event.start || 0; - const startDate = new Date(start); - const end = event.end || 0; - if ((end - start) % (24 * 60 * 60 * 1000) === 0 && startDate.getHours() === 0 && startDate.getMinutes() === 0) { - // Is 24 hours, and starts on the middle of the night. - return true; - } - - return false; - }; - - /** - * Determines if the user defined time filter should apply - * - * @param {Date} now Date object using previously created object for consistency - * @param {Moment} endDate Moment object representing the event end date - * @param {string} filter The time to subtract from the end date to determine if an event should be shown - * @returns {boolean} True if the event should be filtered out, false otherwise - */ - const timeFilterApplies = function (now, endDate, filter) { - if (filter) { - const until = filter.split(" "), - value = parseInt(until[0]), - increment = until[1].slice(-1) === "s" ? until[1] : until[1] + "s", // Massage the data for moment js - filterUntil = moment(endDate.format()).subtract(value, increment); - - return now < filterUntil.format("x"); - } - - return false; - }; - - /** - * 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. - */ - const getTitleFromEvent = function (event) { - let title = "Event"; - if (event.summary) { - title = typeof event.summary.val !== "undefined" ? event.summary.val : event.summary; - } else if (event.description) { - title = event.description; - } - - return title; - }; - - const testTitleByFilter = function (title, filter, useRegex, regexFlags) { - if (useRegex) { - // Assume if leading slash, there is also trailing slash - if (filter[0] === "/") { - // Strip leading and trailing slashes - filter = filter.substr(1).slice(0, -1); - } - - filter = new RegExp(filter, regexFlags); - - return filter.test(title); - } else { - return title.includes(filter); - } - }; - /* public methods */ /** @@ -627,7 +120,7 @@ const CalendarFetcher = function (url, reloadInterval, excludedEvents, maximumEn */ this.broadcastEvents = function () { Log.info("Calendar-Fetcher: Broadcasting " + events.length + " events."); - eventsReceivedCallback(self); + eventsReceivedCallback(this); }; /** diff --git a/modules/default/calendar/calendarutils.js b/modules/default/calendar/calendarutils.js new file mode 100644 index 00000000..aaf51a12 --- /dev/null +++ b/modules/default/calendar/calendarutils.js @@ -0,0 +1,545 @@ +/* Magic Mirror + * Calendar Util Methods + * + * By Michael Teeuw https://michaelteeuw.nl + * MIT Licensed. + */ + +/** + * @external Moment + */ +const moment = require("moment"); +const path = require("path"); +const zoneTable = require(path.join(__dirname, "windowsZones.json")); +const Log = require("../../../js/logger.js"); + +const CalendarUtils = { + /** + * Calculate the time correction, either dst/std or full day in cases where + * utc time is day before plus offset + * + * @param {object} event + * @param {Date} date + * @returns {number} the necessary adjustment in hours + */ + calculateTimezoneAdjustment: function (event, date) { + let adjustHours = 0; + // if a timezone was specified + if (!event.start.tz) { + Log.debug(" if no tz, guess based on now"); + event.start.tz = moment.tz.guess(); + } + Log.debug("initial tz=" + event.start.tz); + + // if there is a start date specified + if (event.start.tz) { + // if this is a windows timezone + if (event.start.tz.includes(" ")) { + // use the lookup table to get theIANA name as moment and date don't know MS timezones + let tz = CalendarUtils.getIanaTZFromMS(event.start.tz); + Log.debug("corrected TZ=" + tz); + // watch out for unregistered windows timezone names + // if we had a successful lookup + if (tz) { + // change the timezone to the IANA name + event.start.tz = tz; + // Log.debug("corrected timezone="+event.start.tz) + } + } + Log.debug("corrected tz=" + event.start.tz); + let current_offset = 0; // offset from TZ string or calculated + let mm = 0; // date with tz or offset + let start_offset = 0; // utc offset of created with tz + // if there is still an offset, lookup failed, use it + if (event.start.tz.startsWith("(")) { + const regex = /[+|-]\d*:\d*/; + const start_offsetString = event.start.tz.match(regex).toString().split(":"); + let start_offset = parseInt(start_offsetString[0]); + start_offset *= event.start.tz[1] === "-" ? -1 : 1; + adjustHours = start_offset; + Log.debug("defined offset=" + start_offset + " hours"); + current_offset = start_offset; + event.start.tz = ""; + Log.debug("ical offset=" + current_offset + " date=" + date); + mm = moment(date); + let x = parseInt(moment(new Date()).utcOffset()); + Log.debug("net mins=" + (current_offset * 60 - x)); + + mm = mm.add(x - current_offset * 60, "minutes"); + adjustHours = (current_offset * 60 - x) / 60; + event.start = mm.toDate(); + Log.debug("adjusted date=" + event.start); + } else { + // get the start time in that timezone + Log.debug("start date/time=" + moment(event.start).toDate()); + start_offset = moment.tz(moment(event.start), event.start.tz).utcOffset(); + Log.debug("start offset=" + start_offset); + + Log.debug("start date/time w tz =" + moment.tz(moment(event.start), event.start.tz).toDate()); + + // get the specified date in that timezone + mm = moment.tz(moment(date), event.start.tz); + Log.debug("event date=" + mm.toDate()); + current_offset = mm.utcOffset(); + } + Log.debug("event offset=" + current_offset + " hour=" + mm.format("H") + " event date=" + mm.toDate()); + + // if the offset is greater than 0, east of london + if (current_offset !== start_offset) { + // big offset + Log.debug("offset"); + let h = parseInt(mm.format("H")); + // check if the event time is less than the offset + if (h > 0 && h < Math.abs(current_offset) / 60) { + // if so, rrule created a wrong date (utc day, oops, with utc yesterday adjusted time) + // we need to fix that + adjustHours = 24; + // Log.debug("adjusting date") + } + //-300 > -240 + //if (Math.abs(current_offset) > Math.abs(start_offset)){ + if (current_offset > start_offset) { + adjustHours -= 1; + Log.debug("adjust down 1 hour dst change"); + //} else if (Math.abs(current_offset) < Math.abs(start_offset)) { + } else if (current_offset < start_offset) { + adjustHours += 1; + Log.debug("adjust up 1 hour dst change"); + } + } + } + Log.debug("adjustHours=" + adjustHours); + return adjustHours; + }, + + filterEvents: function (data, config) { + const newEvents = []; + + // limitFunction doesn't do much limiting, see comment re: the dates + // array in rrule section below as to why we need to do the filtering + // ourselves + const limitFunction = function (date, i) { + return true; + }; + + const eventDate = function (event, time) { + return CalendarUtils.isFullDayEvent(event) ? moment(event[time], "YYYYMMDD") : moment(new Date(event[time])); + }; + + Log.debug("there are " + Object.entries(data).length + " calendar entries"); + Object.entries(data).forEach(([key, event]) => { + const now = new Date(); + const today = moment().startOf("day").toDate(); + const future = moment().startOf("day").add(config.maximumNumberOfDays, "days").subtract(1, "seconds").toDate(); // Subtract 1 second so that events that start on the middle of the night will not repeat. + let past = today; + Log.debug("have entries "); + if (config.includePastEvents) { + past = moment().startOf("day").subtract(config.maximumNumberOfDays, "days").toDate(); + } + + // FIXME: Ugly fix to solve the facebook birthday issue. + // Otherwise, the recurring events only show the birthday for next year. + let isFacebookBirthday = false; + if (typeof event.uid !== "undefined") { + if (event.uid.indexOf("@facebook.com") !== -1) { + isFacebookBirthday = true; + } + } + + if (event.type === "VEVENT") { + let startDate = eventDate(event, "start"); + let endDate; + + Log.debug("\nevent=" + JSON.stringify(event)); + if (typeof event.end !== "undefined") { + endDate = eventDate(event, "end"); + } else if (typeof event.duration !== "undefined") { + endDate = startDate.clone().add(moment.duration(event.duration)); + } else { + if (!isFacebookBirthday) { + // make copy of start date, separate storage area + endDate = moment(startDate.format("x"), "x"); + } else { + endDate = moment(startDate).add(1, "days"); + } + } + + Log.debug(" start=" + startDate.toDate() + " end=" + endDate.toDate()); + + // calculate the duration of the event for use with recurring events. + let duration = parseInt(endDate.format("x")) - parseInt(startDate.format("x")); + + if (event.start.length === 8) { + startDate = startDate.startOf("day"); + } + + const title = CalendarUtils.getTitleFromEvent(event); + + let excluded = false, + dateFilter = null; + + for (let f in config.excludedEvents) { + let filter = config.excludedEvents[f], + testTitle = title.toLowerCase(), + until = null, + useRegex = false, + regexFlags = "g"; + + if (filter instanceof Object) { + if (typeof filter.until !== "undefined") { + until = filter.until; + } + + if (typeof filter.regex !== "undefined") { + useRegex = filter.regex; + } + + // If additional advanced filtering is added in, this section + // must remain last as we overwrite the filter object with the + // filterBy string + if (filter.caseSensitive) { + filter = filter.filterBy; + testTitle = title; + } else if (useRegex) { + filter = filter.filterBy; + testTitle = title; + regexFlags += "i"; + } else { + filter = filter.filterBy.toLowerCase(); + } + } else { + filter = filter.toLowerCase(); + } + + if (CalendarUtils.titleFilterApplies(testTitle, filter, useRegex, regexFlags)) { + if (until) { + dateFilter = until; + } else { + excluded = true; + } + break; + } + } + + if (excluded) { + return; + } + + const location = event.location || false; + const geo = event.geo || false; + const description = event.description || false; + + if (typeof event.rrule !== "undefined" && event.rrule !== null && !isFacebookBirthday) { + const rule = event.rrule; + let addedEvents = 0; + + const pastMoment = moment(past); + const futureMoment = moment(future); + + // can cause problems with e.g. birthdays before 1900 + if ((rule.options && rule.origOptions && rule.origOptions.dtstart && rule.origOptions.dtstart.getFullYear() < 1900) || (rule.options && rule.options.dtstart && rule.options.dtstart.getFullYear() < 1900)) { + rule.origOptions.dtstart.setYear(1900); + rule.options.dtstart.setYear(1900); + } + + // For recurring events, get the set of start dates that fall within the range + // of dates we're looking for. + // kblankenship1989 - to fix issue #1798, converting all dates to locale time first, then converting back to UTC time + let pastLocal = 0; + let futureLocal = 0; + if (CalendarUtils.isFullDayEvent(event)) { + // if full day event, only use the date part of the ranges + pastLocal = pastMoment.toDate(); + futureLocal = futureMoment.toDate(); + } else { + // if we want past events + if (config.includePastEvents) { + // use the calculated past time for the between from + pastLocal = pastMoment.toDate(); + } else { + // otherwise use NOW.. cause we shouldn't use any before now + pastLocal = moment().toDate(); //now + } + futureLocal = futureMoment.toDate(); // future + } + Log.debug(" between=" + pastLocal + " to " + futureLocal); + const dates = rule.between(pastLocal, futureLocal, true, limitFunction); + Log.debug("title=" + event.summary + " dates=" + JSON.stringify(dates)); + // The "dates" array contains the set of dates within our desired date range range that are valid + // for the recurrence rule. *However*, it's possible for us to have a specific recurrence that + // had its date changed from outside the range to inside the range. For the time being, + // we'll handle this by adding *all* recurrence entries into the set of dates that we check, + // because the logic below will filter out any recurrences that don't actually belong within + // our display range. + // Would be great if there was a better way to handle this. + if (event.recurrences !== undefined) { + for (let r in event.recurrences) { + // Only add dates that weren't already in the range we added from the rrule so that + // we don"t double-add those events. + if (moment(new Date(r)).isBetween(pastMoment, futureMoment) !== true) { + dates.push(new Date(r)); + } + } + } + // Loop through the set of date entries to see which recurrences should be added to our event list. + for (let d in dates) { + let date = dates[d]; + // ical.js started returning recurrences and exdates as ISOStrings without time information. + // .toISOString().substring(0,10) is the method they use to calculate keys, so we'll do the same + // (see https://github.com/peterbraden/ical.js/pull/84 ) + const dateKey = date.toISOString().substring(0, 10); + let curEvent = event; + let showRecurrence = true; + + // for full day events, the time might be off from RRULE/Luxon problem + if (CalendarUtils.isFullDayEvent(event)) { + Log.debug("fullday"); + // if the offset is negative, east of GMT where the problem is + if (date.getTimezoneOffset() < 0) { + // get the offset of today where we are processing + // this will be the correction we need to apply + let nowOffset = new Date().getTimezoneOffset(); + Log.debug("now offset is " + nowOffset); + // reduce the time by the offset + Log.debug(" recurring date is " + date + " offset is " + date.getTimezoneOffset()); + // apply the correction to the date/time to get it UTC relative + date = new Date(date.getTime() - Math.abs(nowOffset) * 60000); + // the duration was calculated way back at the top before we could correct the start time.. + // fix it for this event entry + duration = 24 * 60 * 60 * 1000; + Log.debug("new recurring date is " + date); + } + } + startDate = moment(date); + + let adjustDays = CalendarUtils.calculateTimezoneAdjustment(event, date); + + // For each date that we're checking, it's possible that there is a recurrence override for that one day. + if (curEvent.recurrences !== undefined && curEvent.recurrences[dateKey] !== undefined) { + // We found an override, so for this recurrence, use a potentially different title, start date, and duration. + curEvent = curEvent.recurrences[dateKey]; + startDate = moment(curEvent.start); + duration = parseInt(moment(curEvent.end).format("x")) - parseInt(startDate.format("x")); + } + // If there's no recurrence override, check for an exception date. Exception dates represent exceptions to the rule. + else if (curEvent.exdate !== undefined && curEvent.exdate[dateKey] !== undefined) { + // This date is an exception date, which means we should skip it in the recurrence pattern. + showRecurrence = false; + } + Log.debug("duration=" + duration); + + endDate = moment(parseInt(startDate.format("x")) + duration, "x"); + if (startDate.format("x") === endDate.format("x")) { + endDate = endDate.endOf("day"); + } + + const recurrenceTitle = CalendarUtils.getTitleFromEvent(curEvent); + + // If this recurrence ends before the start of the date range, or starts after the end of the date range, don"t add + // it to the event list. + if (endDate.isBefore(past) || startDate.isAfter(future)) { + showRecurrence = false; + } + + if (CalendarUtils.timeFilterApplies(now, endDate, dateFilter)) { + showRecurrence = false; + } + + if (showRecurrence === true) { + Log.debug("saving event =" + description); + addedEvents++; + newEvents.push({ + title: recurrenceTitle, + startDate: (adjustDays ? (adjustDays > 0 ? startDate.add(adjustDays, "hours") : startDate.subtract(Math.abs(adjustDays), "hours")) : startDate).format("x"), + endDate: (adjustDays ? (adjustDays > 0 ? endDate.add(adjustDays, "hours") : endDate.subtract(Math.abs(adjustDays), "hours")) : endDate).format("x"), + fullDayEvent: CalendarUtils.isFullDayEvent(event), + recurringEvent: true, + class: event.class, + firstYear: event.start.getFullYear(), + location: location, + geo: geo, + description: description + }); + } + } + // end recurring event parsing + } else { + // Single event. + const fullDayEvent = isFacebookBirthday ? true : CalendarUtils.isFullDayEvent(event); + // Log.debug("full day event") + + if (config.includePastEvents) { + // Past event is too far in the past, so skip. + if (endDate < past) { + return; + } + } else { + // It's not a fullday event, and it is in the past, so skip. + if (!fullDayEvent && endDate < new Date()) { + return; + } + + // It's a fullday event, and it is before today, So skip. + if (fullDayEvent && endDate <= today) { + return; + } + } + + // It exceeds the maximumNumberOfDays limit, so skip. + if (startDate > future) { + return; + } + + if (CalendarUtils.timeFilterApplies(now, endDate, dateFilter)) { + return; + } + + // Adjust start date so multiple day events will be displayed as happening today even though they started some days ago already + if (fullDayEvent && startDate <= today) { + startDate = moment(today); + } + // if the start and end are the same, then make end the 'end of day' value (start is at 00:00:00) + if (fullDayEvent && startDate.format("x") === endDate.format("x")) { + endDate = endDate.endOf("day"); + } + // get correction for date saving and dst change between now and then + let adjustDays = CalendarUtils.calculateTimezoneAdjustment(event, startDate.toDate()); + // Every thing is good. Add it to the list. + newEvents.push({ + title: title, + startDate: (adjustDays ? (adjustDays > 0 ? startDate.add(adjustDays, "hours") : startDate.subtract(Math.abs(adjustDays), "hours")) : startDate).format("x"), + endDate: (adjustDays ? (adjustDays > 0 ? endDate.add(adjustDays, "hours") : endDate.subtract(Math.abs(adjustDays), "hours")) : endDate).format("x"), + fullDayEvent: fullDayEvent, + class: event.class, + location: location, + geo: geo, + description: description + }); + } + } + }); + + newEvents.sort(function (a, b) { + return a.startDate - b.startDate; + }); + + // include up to maximumEntries current or upcoming events + // If past events should be included, include all past events + const now = moment(); + let entries = 0; + let events = []; + for (let ne of newEvents) { + if (moment(ne.endDate, "x").isBefore(now)) { + if (config.includePastEvents) events.push(ne); + continue; + } + entries++; + // If max events has been saved, skip the rest + if (entries > config.maximumEntries) break; + events.push(ne); + } + + return events; + }, + + /** + * Lookup iana tz from windows + * + * @param msTZName + * @returns {*|null} + */ + getIanaTZFromMS: function (msTZName) { + // Get hash entry + const he = zoneTable[msTZName]; + // If found return iana name, else null + return he ? he.iana[0] : null; + }, + + /** + * Gets the title from the event. + * + * @param {object} event The event object to check. + * @returns {string} The title of the event, or "Event" if no title is found. + */ + getTitleFromEvent: function (event) { + let title = "Event"; + if (event.summary) { + title = typeof event.summary.val !== "undefined" ? event.summary.val : event.summary; + } else if (event.description) { + title = event.description; + } + + return title; + }, + + /** + * Checks if an event is a fullday event. + * + * @param {object} event The event object to check. + * @returns {boolean} True if the event is a fullday event, false otherwise + */ + isFullDayEvent: function (event) { + if (event.start.length === 8 || event.start.dateOnly || event.datetype === "date") { + return true; + } + + const start = event.start || 0; + const startDate = new Date(start); + const end = event.end || 0; + if ((end - start) % (24 * 60 * 60 * 1000) === 0 && startDate.getHours() === 0 && startDate.getMinutes() === 0) { + // Is 24 hours, and starts on the middle of the night. + return true; + } + + return false; + }, + + /** + * Determines if the user defined time filter should apply + * + * @param {Date} now Date object using previously created object for consistency + * @param {Moment} endDate Moment object representing the event end date + * @param {string} filter The time to subtract from the end date to determine if an event should be shown + * @returns {boolean} True if the event should be filtered out, false otherwise + */ + timeFilterApplies: function (now, endDate, filter) { + if (filter) { + const until = filter.split(" "), + value = parseInt(until[0]), + increment = until[1].slice(-1) === "s" ? until[1] : until[1] + "s", // Massage the data for moment js + filterUntil = moment(endDate.format()).subtract(value, increment); + + return now < filterUntil.format("x"); + } + + return false; + }, + + /** + * + * @param title + * @param filter + * @param useRegex + * @param regexFlags + * @returns {boolean|*} + */ + titleFilterApplies: function (title, filter, useRegex, regexFlags) { + if (useRegex) { + // Assume if leading slash, there is also trailing slash + if (filter[0] === "/") { + // Strip leading and trailing slashes + filter = filter.substr(1).slice(0, -1); + } + + filter = new RegExp(filter, regexFlags); + + return filter.test(title); + } else { + return title.includes(filter); + } + } +}; + +if (typeof module !== "undefined") { + module.exports = CalendarUtils; +} diff --git a/modules/default/calendar/node_helper.js b/modules/default/calendar/node_helper.js index 06fa28ec..d544878b 100644 --- a/modules/default/calendar/node_helper.js +++ b/modules/default/calendar/node_helper.js @@ -5,9 +5,8 @@ * MIT Licensed. */ const NodeHelper = require("node_helper"); -const validUrl = require("valid-url"); const CalendarFetcher = require("./calendarfetcher.js"); -const Log = require("../../../js/logger"); +const Log = require("logger"); module.exports = NodeHelper.create({ // Override start method. @@ -19,7 +18,7 @@ module.exports = NodeHelper.create({ // Override socketNotificationReceived method. socketNotificationReceived: function (notification, payload) { if (notification === "ADD_CALENDAR") { - this.createFetcher(payload.url, payload.fetchInterval, payload.excludedEvents, payload.maximumEntries, payload.maximumNumberOfDays, payload.auth, payload.broadcastPastEvents, payload.id); + this.createFetcher(payload.url, payload.fetchInterval, payload.excludedEvents, payload.maximumEntries, payload.maximumNumberOfDays, payload.auth, payload.broadcastPastEvents, payload.selfSignedCert, payload.id); } }, @@ -34,44 +33,55 @@ module.exports = NodeHelper.create({ * @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: function (url, fetchInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, broadcastPastEvents, identifier) { - var self = this; - - if (!validUrl.isUri(url)) { - self.sendSocketNotification("INCORRECT_URL", { id: identifier, url: url }); + createFetcher: function (url, fetchInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, broadcastPastEvents, selfSignedCert, identifier) { + try { + new URL(url); + } catch (error) { + this.sendSocketNotification("INCORRECT_URL", { id: identifier, url: url }); return; } - var fetcher; - if (typeof self.fetchers[identifier + url] === "undefined") { + let fetcher; + if (typeof this.fetchers[identifier + url] === "undefined") { Log.log("Create new calendar fetcher for url: " + url + " - Interval: " + fetchInterval); - fetcher = new CalendarFetcher(url, fetchInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, broadcastPastEvents); + fetcher = new CalendarFetcher(url, fetchInterval, excludedEvents, maximumEntries, maximumNumberOfDays, auth, broadcastPastEvents, selfSignedCert); - fetcher.onReceive(function (fetcher) { - self.sendSocketNotification("CALENDAR_EVENTS", { - id: identifier, - url: fetcher.url(), - events: fetcher.events() - }); + fetcher.onReceive((fetcher) => { + this.broadcastEvents(fetcher, identifier); }); - fetcher.onError(function (fetcher, error) { + fetcher.onError((fetcher, error) => { Log.error("Calendar Error. Could not fetch calendar: ", fetcher.url(), error); - self.sendSocketNotification("FETCH_ERROR", { + this.sendSocketNotification("FETCH_ERROR", { id: identifier, url: fetcher.url(), error: error }); }); - self.fetchers[identifier + url] = fetcher; + this.fetchers[identifier + url] = fetcher; } else { Log.log("Use existing calendar fetcher for url: " + url); - fetcher = self.fetchers[identifier + 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: function (fetcher, identifier) { + this.sendSocketNotification("CALENDAR_EVENTS", { + id: identifier, + url: fetcher.url(), + events: fetcher.events() + }); } }); diff --git a/modules/default/compliments/compliments.js b/modules/default/compliments/compliments.js index 412fe170..6613a2c8 100644 --- a/modules/default/compliments/compliments.js +++ b/modules/default/compliments/compliments.js @@ -182,34 +182,14 @@ Module.register("compliments", { }, // From data currentweather set weather type - setCurrentWeatherType: function (data) { - var weatherIconTable = { - "01d": "day_sunny", - "02d": "day_cloudy", - "03d": "cloudy", - "04d": "cloudy_windy", - "09d": "showers", - "10d": "rain", - "11d": "thunderstorm", - "13d": "snow", - "50d": "fog", - "01n": "night_clear", - "02n": "night_cloudy", - "03n": "night_cloudy", - "04n": "night_cloudy", - "09n": "night_showers", - "10n": "night_rain", - "11n": "night_thunderstorm", - "13n": "night_snow", - "50n": "night_alt_cloudy_windy" - }; - this.currentWeatherType = weatherIconTable[data.weather[0].icon]; + setCurrentWeatherType: function (type) { + this.currentWeatherType = type; }, // Override notification handler. notificationReceived: function (notification, payload, sender) { - if (notification === "CURRENTWEATHER_DATA") { - this.setCurrentWeatherType(payload.data); + if (notification === "CURRENTWEATHER_TYPE") { + this.setCurrentWeatherType(payload.type); } } }); diff --git a/modules/default/currentweather/README.md b/modules/default/currentweather/README.md index d4db59ea..d2df6d2a 100644 --- a/modules/default/currentweather/README.md +++ b/modules/default/currentweather/README.md @@ -1,5 +1,7 @@ # Module: Current Weather +> :warning: **This module is deprecated in favor of the [weather](https://docs.magicmirror.builders/modules/weather.html) module.** + The `currentweather` module is one of the default modules of the MagicMirror. This module displays the current weather, including the windspeed, the sunset or sunrise time, the temperature and an icon to display the current conditions. diff --git a/modules/default/currentweather/currentweather.js b/modules/default/currentweather/currentweather.js index 0a43b23e..0d22b40a 100644 --- a/modules/default/currentweather/currentweather.js +++ b/modules/default/currentweather/currentweather.js @@ -3,6 +3,8 @@ * * By Michael Teeuw https://michaelteeuw.nl * MIT Licensed. + * + * This module is deprecated. Any additional feature will no longer be merged. */ Module.register("currentweather", { // Default module config. @@ -47,24 +49,24 @@ Module.register("currentweather", { roundTemp: false, iconTable: { - "01d": "wi-day-sunny", - "02d": "wi-day-cloudy", - "03d": "wi-cloudy", - "04d": "wi-cloudy-windy", - "09d": "wi-showers", - "10d": "wi-rain", - "11d": "wi-thunderstorm", - "13d": "wi-snow", - "50d": "wi-fog", - "01n": "wi-night-clear", - "02n": "wi-night-cloudy", - "03n": "wi-night-cloudy", - "04n": "wi-night-cloudy", - "09n": "wi-night-showers", - "10n": "wi-night-rain", - "11n": "wi-night-thunderstorm", - "13n": "wi-night-snow", - "50n": "wi-night-alt-cloudy-windy" + "01d": "day-sunny", + "02d": "day-cloudy", + "03d": "cloudy", + "04d": "cloudy-windy", + "09d": "showers", + "10d": "rain", + "11d": "thunderstorm", + "13d": "snow", + "50d": "fog", + "01n": "night-clear", + "02n": "night-cloudy", + "03n": "night-cloudy", + "04n": "night-cloudy", + "09n": "night-showers", + "10n": "night-rain", + "11n": "night-thunderstorm", + "13n": "night-snow", + "50n": "night-alt-cloudy-windy" } }, @@ -219,7 +221,7 @@ Module.register("currentweather", { if (this.config.hideTemp === false) { var weatherIcon = document.createElement("span"); - weatherIcon.className = "wi weathericon " + this.weatherType; + weatherIcon.className = "wi weathericon wi-" + this.weatherType; large.appendChild(weatherIcon); var temperature = document.createElement("span"); @@ -258,7 +260,9 @@ Module.register("currentweather", { var feelsLike = document.createElement("span"); feelsLike.className = "dimmed"; - feelsLike.innerHTML = this.translate("FEELS") + " " + this.feelsLike + degreeLabel; + feelsLike.innerHTML = this.translate("FEELS", { + DEGREE: this.feelsLike + degreeLabel + }); small.appendChild(feelsLike); wrapper.appendChild(small); @@ -500,6 +504,7 @@ Module.register("currentweather", { this.loaded = true; this.updateDom(this.config.animationSpeed); this.sendNotification("CURRENTWEATHER_DATA", { data: data }); + this.sendNotification("CURRENTWEATHER_TYPE", { type: this.config.iconTable[data.weather[0].icon].replace("-", "_") }); }, /* scheduleUpdate() @@ -587,6 +592,7 @@ Module.register("currentweather", { */ roundValue: function (temperature) { var decimals = this.config.roundTemp ? 0 : 1; - return parseFloat(temperature).toFixed(decimals); + var roundValue = parseFloat(temperature).toFixed(decimals); + return roundValue === "-0" ? 0 : roundValue; } }); diff --git a/modules/default/currentweather/node_helper.js b/modules/default/currentweather/node_helper.js new file mode 100644 index 00000000..d4c7a14c --- /dev/null +++ b/modules/default/currentweather/node_helper.js @@ -0,0 +1,9 @@ +const NodeHelper = require("node_helper"); +const Log = require("logger"); + +module.exports = NodeHelper.create({ + // Override start method. + start: function () { + Log.warn(`The module '${this.name}' is deprecated in favor of the 'weather'-module, please refer to the documentation for a migration path`); + } +}); diff --git a/modules/default/newsfeed/fullarticle.njk b/modules/default/newsfeed/fullarticle.njk new file mode 100644 index 00000000..6570396e --- /dev/null +++ b/modules/default/newsfeed/fullarticle.njk @@ -0,0 +1,3 @@ +