diff --git a/index.html b/index.html index a91bf469bc..d2d96d2951 100644 --- a/index.html +++ b/index.html @@ -51,11 +51,9 @@ - - - - + + diff --git a/js/loader.js b/js/loader.js index a4fe273f01..9fe4dec4ea 100644 --- a/js/loader.js +++ b/js/loader.js @@ -1,298 +1,292 @@ /* global defaultModules, vendor */ -const Loader = (function () { +/* Module state */ - /* Create helper variables */ +const loadedModuleFiles = []; +const loadedFiles = []; +const moduleObjects = []; - const loadedModuleFiles = []; - const loadedFiles = []; - const moduleObjects = []; - - /* Private Methods */ - - /** - * Get environment variables from config. - * @returns {object} Env vars with modulesDir and customCss paths from config. - */ - const getEnvVarsFromConfig = function () { - return { - modulesDir: config.foreignModulesDir || "modules", - defaultModulesDir: config.defaultModulesDir || "defaultmodules", - customCss: config.customCss || "config/custom.css" - }; +/** + * Get environment variables from config. + * @returns {object} Env vars with modulesDir and customCss paths from config. + */ +function getEnvVarsFromConfig () { + return { + modulesDir: config.foreignModulesDir || "modules", + defaultModulesDir: config.defaultModulesDir || "defaultmodules", + customCss: config.customCss || "config/custom.css" }; - - /** - * Retrieve object of env variables. - * @returns {object} with key: values as assembled in js/server_functions.js - */ - const getEnvVars = async function () { - // In test mode, skip server fetch and use config values directly - if (typeof process !== "undefined" && process.env && process.env.mmTestMode === "true") { - return getEnvVarsFromConfig(); - } - - // In production, fetch env vars from server +} + +/** + * Retrieve object of env variables. + * @returns {object} with key: values as assembled in js/server_functions.js + */ +async function getEnvVars () { + // In test mode, skip server fetch and use config values directly + if (typeof process !== "undefined" && process.env && process.env.mmTestMode === "true") { + return getEnvVarsFromConfig(); + } + + // In production, fetch env vars from server + try { + const res = await fetch(new URL("env", `${location.origin}${config.basePath}`)); + return JSON.parse(await res.text()); + } catch (error) { + // Fallback to config values if server fetch fails + Log.error("Unable to retrieve env configuration", error); + return getEnvVarsFromConfig(); + } +} + +/** + * Loops through all modules and requests start for every module. + */ +async function startModules () { + const modulePromises = []; + for (const module of moduleObjects) { try { - const res = await fetch(new URL("env", `${location.origin}${config.basePath}`)); - return JSON.parse(await res.text()); + modulePromises.push(module.start()); } catch (error) { - // Fallback to config values if server fetch fails - Log.error("Unable to retrieve env configuration", error); - return getEnvVarsFromConfig(); - } - }; - - /** - * Loops through all modules and requests start for every module. - */ - const startModules = async function () { - const modulePromises = []; - for (const module of moduleObjects) { - try { - modulePromises.push(module.start()); - } catch (error) { - Log.error(`Error when starting node_helper for module ${module.name}:`); - Log.error(error); - } + Log.error(`Error when starting node_helper for module ${module.name}:`); + Log.error(error); } + } - const results = await Promise.allSettled(modulePromises); + const results = await Promise.allSettled(modulePromises); - // Log errors that happened during async node_helper startup - results.forEach((result) => { - if (result.status === "rejected") { - Log.error(result.reason); - } - }); - - // Notify core of loaded modules. - MM.modulesStarted(moduleObjects); - - // Starting modules also hides any modules that have requested to be initially hidden - for (const thisModule of moduleObjects) { - if (thisModule.data.hiddenOnStartup) { - Log.info(`Initially hiding ${thisModule.name}`); - thisModule.hide(); - } + // Log errors that happened during async node_helper startup + results.forEach((result) => { + if (result.status === "rejected") { + Log.error(result.reason); } - }; - - /** - * Retrieve list of all modules. - * @returns {object[]} module data as configured in config - */ - const getAllModules = function () { - const AllModules = config.modules.filter((module) => (module.module !== undefined) && (MM.getAvailableModulePositions.indexOf(module.position) > -1 || typeof (module.position) === "undefined")); - return AllModules; - }; - - /** - * Generate array with module information including module paths. - * @returns {object[]} Module information. - */ - const getModuleData = async function () { - const modules = getAllModules(); - const moduleFiles = []; - const envVars = await getEnvVars(); - - modules.forEach(function (moduleData, index) { - const module = moduleData.module; + }); - const elements = module.split("/"); - const moduleName = elements[elements.length - 1]; - let moduleFolder = `${envVars.modulesDir}/${module}`; + // Notify core of loaded modules. + MM.modulesStarted(moduleObjects); - if (defaultModules.indexOf(moduleName) !== -1) { - const defaultModuleFolder = `${envVars.defaultModulesDir}/${module}`; - if (window.name !== "jsdom") { + // Starting modules also hides any modules that have requested to be initially hidden + for (const thisModule of moduleObjects) { + if (thisModule.data.hiddenOnStartup) { + Log.info(`Initially hiding ${thisModule.name}`); + thisModule.hide(); + } + } +} + +/** + * Retrieve list of all modules. + * @returns {object[]} module data as configured in config + */ +function getAllModules () { + const AllModules = config.modules.filter((module) => (module.module !== undefined) && (MM.getAvailableModulePositions.indexOf(module.position) > -1 || typeof (module.position) === "undefined")); + return AllModules; +} + +/** + * Generate array with module information including module paths. + * @returns {object[]} Module information. + */ +async function getModuleData () { + const modules = getAllModules(); + const moduleFiles = []; + const envVars = await getEnvVars(); + + modules.forEach(function (moduleData, index) { + const module = moduleData.module; + + const elements = module.split("/"); + const moduleName = elements[elements.length - 1]; + let moduleFolder = `${envVars.modulesDir}/${module}`; + + if (defaultModules.indexOf(moduleName) !== -1) { + const defaultModuleFolder = `${envVars.defaultModulesDir}/${module}`; + if (window.name !== "jsdom") { + moduleFolder = defaultModuleFolder; + } else { + // running in test mode, allow defaultModules placed under moduleDir for testing + if (envVars.modulesDir === "modules") { moduleFolder = defaultModuleFolder; - } else { - // running in test mode, allow defaultModules placed under moduleDir for testing - if (envVars.modulesDir === "modules") { - moduleFolder = defaultModuleFolder; - } } } + } - if (moduleData.disabled === true) { - return; - } + if (moduleData.disabled === true) { + return; + } - moduleFiles.push({ - index: index, - identifier: `module_${index}_${module}`, - name: moduleName, - path: `${moduleFolder}/`, - file: `${moduleName}.js`, - position: moduleData.position, - animateIn: moduleData.animateIn, - animateOut: moduleData.animateOut, - hiddenOnStartup: moduleData.hiddenOnStartup, - header: moduleData.header, - configDeepMerge: typeof moduleData.configDeepMerge === "boolean" ? moduleData.configDeepMerge : false, - config: moduleData.config, - classes: typeof moduleData.classes !== "undefined" ? `${moduleData.classes} ${module}` : module, - order: (typeof moduleData.order === "number" && Number.isInteger(moduleData.order)) ? moduleData.order : 0 - }); + moduleFiles.push({ + index: index, + identifier: `module_${index}_${module}`, + name: moduleName, + path: `${moduleFolder}/`, + file: `${moduleName}.js`, + position: moduleData.position, + animateIn: moduleData.animateIn, + animateOut: moduleData.animateOut, + hiddenOnStartup: moduleData.hiddenOnStartup, + header: moduleData.header, + configDeepMerge: typeof moduleData.configDeepMerge === "boolean" ? moduleData.configDeepMerge : false, + config: moduleData.config, + classes: typeof moduleData.classes !== "undefined" ? `${moduleData.classes} ${module}` : module, + order: (typeof moduleData.order === "number" && Number.isInteger(moduleData.order)) ? moduleData.order : 0 }); + }); - return moduleFiles; - }; + return moduleFiles; +} + +/** + * Load modules via ajax request and create module objects. + * @param {object} module Information about the module we want to load. + * @returns {Promise} resolved when module is loaded + */ +async function loadModule (module) { + const url = module.path + module.file; /** - * Load modules via ajax request and create module objects. - * @param {object} module Information about the module we want to load. - * @returns {Promise} resolved when module is loaded + * @returns {Promise} */ - const loadModule = async function (module) { - const url = module.path + module.file; - - /** - * @returns {Promise} - */ - const afterLoad = async function () { - const moduleObject = Module.create(module.name); - if (moduleObject) { - await bootstrapModule(module, moduleObject); - } - }; - - if (loadedModuleFiles.indexOf(url) !== -1) { - await afterLoad(); - } else { - await loadFile(url); - loadedModuleFiles.push(url); - await afterLoad(); + async function afterLoad () { + const moduleObject = Module.create(module.name); + if (moduleObject) { + await bootstrapModule(module, moduleObject); } - }; + } + + if (loadedModuleFiles.indexOf(url) !== -1) { + await afterLoad(); + } else { + await loadFile(url); + loadedModuleFiles.push(url); + await afterLoad(); + } +} + +/** + * Bootstrap modules by setting the module data and loading the scripts & styles. + * @param {object} module Information about the module we want to load. + * @param {Module} mObj Modules instance. + */ +async function bootstrapModule (module, mObj) { + Log.info(`Bootstrapping module: ${module.name}`); + mObj.setData(module); + + await mObj.loadScripts(); + Log.log(`Scripts loaded for: ${module.name}`); + + await mObj.loadStyles(); + Log.log(`Styles loaded for: ${module.name}`); + + await mObj.loadTranslations(); + Log.log(`Translations loaded for: ${module.name}`); + + moduleObjects.push(mObj); +} + +/** + * Load a script or stylesheet by adding it to the dom. + * @param {string} fileName Path of the file we want to load. + * @returns {Promise} resolved when the file is loaded + */ +function loadFile (fileName) { + const extension = fileName.slice((Math.max(0, fileName.lastIndexOf(".")) || Infinity) + 1); + let script, stylesheet; + + switch (extension.toLowerCase()) { + case "js": + return new Promise((resolve) => { + Log.log(`Load script: ${fileName}`); + script = document.createElement("script"); + script.type = "text/javascript"; + script.src = fileName; + script.onload = function () { + resolve(); + }; + script.onerror = function () { + Log.error("Error on loading script:", fileName); + script.remove(); + resolve(); + }; + document.getElementsByTagName("body")[0].appendChild(script); + }); + case "css": + return new Promise((resolve) => { + Log.log(`Load stylesheet: ${fileName}`); + + stylesheet = document.createElement("link"); + stylesheet.rel = "stylesheet"; + stylesheet.type = "text/css"; + stylesheet.href = fileName; + stylesheet.onload = function () { + resolve(); + }; + stylesheet.onerror = function () { + Log.error("Error on loading stylesheet:", fileName); + stylesheet.remove(); + resolve(); + }; + document.getElementsByTagName("head")[0].appendChild(stylesheet); + }); + } +} + +/* Public Methods */ + +export const Loader = { /** - * Bootstrap modules by setting the module data and loading the scripts & styles. - * @param {object} module Information about the module we want to load. - * @param {Module} mObj Modules instance. + * Load all modules as defined in the config. */ - const bootstrapModule = async function (module, mObj) { - Log.info(`Bootstrapping module: ${module.name}`); - mObj.setData(module); - - await mObj.loadScripts(); - Log.log(`Scripts loaded for: ${module.name}`); + async loadModules () { + const moduleData = await getModuleData(); + const envVars = await getEnvVars(); + const customCss = envVars.customCss; - await mObj.loadStyles(); - Log.log(`Styles loaded for: ${module.name}`); + // Load all modules + for (const module of moduleData) { + await loadModule(module); + } - await mObj.loadTranslations(); - Log.log(`Translations loaded for: ${module.name}`); + // Load custom.css + // Since this happens after loading the modules, + // it overwrites the default styles. + await loadFile(customCss); - moduleObjects.push(mObj); - }; + // Start all modules. + await startModules(); + }, /** - * Load a script or stylesheet by adding it to the dom. + * Load a file (script or stylesheet). + * Prevent double loading and search for files defined in js/vendor.js. * @param {string} fileName Path of the file we want to load. + * @param {Module} module The module that calls the loadFile function. * @returns {Promise} resolved when the file is loaded */ - const loadFile = function (fileName) { - const extension = fileName.slice((Math.max(0, fileName.lastIndexOf(".")) || Infinity) + 1); - let script, stylesheet; - - switch (extension.toLowerCase()) { - case "js": - return new Promise((resolve) => { - Log.log(`Load script: ${fileName}`); - script = document.createElement("script"); - script.type = "text/javascript"; - script.src = fileName; - script.onload = function () { - resolve(); - }; - script.onerror = function () { - Log.error("Error on loading script:", fileName); - script.remove(); - resolve(); - }; - document.getElementsByTagName("body")[0].appendChild(script); - }); - case "css": - return new Promise((resolve) => { - Log.log(`Load stylesheet: ${fileName}`); - - stylesheet = document.createElement("link"); - stylesheet.rel = "stylesheet"; - stylesheet.type = "text/css"; - stylesheet.href = fileName; - stylesheet.onload = function () { - resolve(); - }; - stylesheet.onerror = function () { - Log.error("Error on loading stylesheet:", fileName); - stylesheet.remove(); - resolve(); - }; - document.getElementsByTagName("head")[0].appendChild(stylesheet); - }); + loadFileForModule (fileName, module) { + if (loadedFiles.indexOf(fileName.toLowerCase()) !== -1) { + Log.log(`File already loaded: ${fileName}`); + return Promise.resolve(); } - }; - - /* Public Methods */ - return { - - /** - * Load all modules as defined in the config. - */ - async loadModules () { - const moduleData = await getModuleData(); - const envVars = await getEnvVars(); - const customCss = envVars.customCss; - - // Load all modules - for (const module of moduleData) { - await loadModule(module); - } - // Load custom.css - // Since this happens after loading the modules, - // it overwrites the default styles. - await loadFile(customCss); - - // Start all modules. - await startModules(); - }, - - /** - * Load a file (script or stylesheet). - * Prevent double loading and search for files defined in js/vendor.js. - * @param {string} fileName Path of the file we want to load. - * @param {Module} module The module that calls the loadFile function. - * @returns {Promise} resolved when the file is loaded - */ - loadFileForModule (fileName, module) { - if (loadedFiles.indexOf(fileName.toLowerCase()) !== -1) { - Log.log(`File already loaded: ${fileName}`); - return Promise.resolve(); - } - - if (fileName.indexOf("http://") === 0 || fileName.indexOf("https://") === 0 || fileName.indexOf("/") !== -1) { - // This is an absolute or relative path. - // Load it and then return. - loadedFiles.push(fileName.toLowerCase()); - return loadFile(fileName); - } - - if (vendor[fileName] !== undefined) { - // This file is defined in js/vendor.js. - // Load it from its location. - loadedFiles.push(fileName.toLowerCase()); - return loadFile(`${vendor[fileName]}`); - } + if (fileName.indexOf("http://") === 0 || fileName.indexOf("https://") === 0 || fileName.indexOf("/") !== -1) { + // This is an absolute or relative path. + // Load it and then return. + loadedFiles.push(fileName.toLowerCase()); + return loadFile(fileName); + } - // File not loaded yet. - // Load it based on the module path. + if (vendor[fileName] !== undefined) { + // This file is defined in js/vendor.js. + // Load it from its location. loadedFiles.push(fileName.toLowerCase()); - return loadFile(module.file(fileName)); + return loadFile(`${vendor[fileName]}`); } - }; -}()); -globalThis.Loader = Loader; + // File not loaded yet. + // Load it based on the module path. + loadedFiles.push(fileName.toLowerCase()); + return loadFile(module.file(fileName)); + } +}; diff --git a/js/main.js b/js/main.js index f9e0e81d7a..6c62f3c317 100644 --- a/js/main.js +++ b/js/main.js @@ -1,738 +1,739 @@ -/* global Loader, addAnimateCSS, removeAnimateCSS, AnimateCSSIn, AnimateCSSOut, modulePositions, io */ +/* global addAnimateCSS, removeAnimateCSS, AnimateCSSIn, AnimateCSSOut, modulePositions, io */ -const MM = (function () { - let modules = []; +// eslint-disable-next-line import-x/extensions +import { Loader } from "./loader.js"; - /* Private Methods */ +let modules = []; - /** - * Create dom objects for all modules that are configured for a specific position. - */ - const createDomObjects = function () { - const domCreationPromises = []; - - modules.forEach(function (module) { - if (typeof module.data.position !== "string") { - return; - } +/** + * Create dom objects for all modules that are configured for a specific position. + */ +function createDomObjects () { + const domCreationPromises = []; - let haveAnimateIn = null; - // check if have valid animateIn in module definition (module.data.animateIn) - if (module.data.animateIn && AnimateCSSIn.indexOf(module.data.animateIn) !== -1) haveAnimateIn = module.data.animateIn; - - const wrapper = selectWrapper(module.data.position); + modules.forEach(function (module) { + if (typeof module.data.position !== "string") { + return; + } - const dom = document.createElement("div"); - dom.id = module.identifier; - dom.className = module.name; + let haveAnimateIn = null; + // check if have valid animateIn in module definition (module.data.animateIn) + if (module.data.animateIn && AnimateCSSIn.indexOf(module.data.animateIn) !== -1) haveAnimateIn = module.data.animateIn; - if (typeof module.data.classes === "string") { - dom.className = `module ${dom.className} ${module.data.classes}`; - } + const wrapper = selectWrapper(module.data.position); - dom.style.order = (typeof module.data.order === "number" && Number.isInteger(module.data.order)) ? module.data.order : 0; + const dom = document.createElement("div"); + dom.id = module.identifier; + dom.className = module.name; - dom.opacity = 0; - wrapper.appendChild(dom); + if (typeof module.data.classes === "string") { + dom.className = `module ${dom.className} ${module.data.classes}`; + } - const moduleHeader = document.createElement("header"); - moduleHeader.innerHTML = module.getHeader(); - moduleHeader.className = "module-header"; - dom.appendChild(moduleHeader); + dom.style.order = (typeof module.data.order === "number" && Number.isInteger(module.data.order)) ? module.data.order : 0; - if (typeof module.getHeader() === "undefined" || module.getHeader() !== "") { - moduleHeader.style.display = "none;"; - } else { - moduleHeader.style.display = "block;"; - } + dom.opacity = 0; + wrapper.appendChild(dom); - const moduleContent = document.createElement("div"); - moduleContent.className = "module-content"; - dom.appendChild(moduleContent); - - // create the domCreationPromise with AnimateCSS (with animateIn of module definition) - // or just display it - var domCreationPromise; - if (haveAnimateIn) domCreationPromise = updateDom(module, { options: { speed: 1000, animate: { in: haveAnimateIn } } }, true); - else domCreationPromise = updateDom(module, 0); - - domCreationPromises.push(domCreationPromise); - domCreationPromise - .then(function () { - sendNotification("MODULE_DOM_CREATED", null, null, module); - }) - .catch(Log.error); - }); + const moduleHeader = document.createElement("header"); + moduleHeader.innerHTML = module.getHeader(); + moduleHeader.className = "module-header"; + dom.appendChild(moduleHeader); - updateWrapperStates(); - - Promise.all(domCreationPromises).then(function () { - sendNotification("DOM_OBJECTS_CREATED"); - }); - }; - - /** - * Select the wrapper dom object for a specific position. - * @param {string} position The name of the position. - * @returns {HTMLElement | void} the wrapper element - */ - const selectWrapper = function (position) { - const classes = position.replace("_", " "); - const parentWrapper = document.getElementsByClassName(classes); - if (parentWrapper.length > 0) { - const wrapper = parentWrapper[0].getElementsByClassName("container"); - if (wrapper.length > 0) { - return wrapper[0]; - } + if (typeof module.getHeader() === "undefined" || module.getHeader() !== "") { + moduleHeader.style.display = "none;"; + } else { + moduleHeader.style.display = "block;"; } - }; - /** - * Send a notification to all modules. - * @param {string} notification The identifier of the notification. - * @param {object} payload The payload of the notification. - * @param {Module} sender The module that sent the notification. - * @param {Module} [sendTo] The (optional) module to send the notification to. - */ - const sendNotification = function (notification, payload, sender, sendTo) { - for (const m in modules) { - const module = modules[m]; - if (module !== sender && (!sendTo || module === sendTo)) { - module.notificationReceived(notification, payload, sender); - } + const moduleContent = document.createElement("div"); + moduleContent.className = "module-content"; + dom.appendChild(moduleContent); + + // create the domCreationPromise with AnimateCSS (with animateIn of module definition) + // or just display it + var domCreationPromise; + if (haveAnimateIn) domCreationPromise = _updateDom(module, { options: { speed: 1000, animate: { in: haveAnimateIn } } }, true); + else domCreationPromise = _updateDom(module, 0); + + domCreationPromises.push(domCreationPromise); + domCreationPromise + .then(function () { + _sendNotification("MODULE_DOM_CREATED", null, null, module); + }) + .catch(Log.error); + }); + + updateWrapperStates(); + + Promise.all(domCreationPromises).then(function () { + _sendNotification("DOM_OBJECTS_CREATED"); + }); +} + +/** + * Select the wrapper dom object for a specific position. + * @param {string} position The name of the position. + * @returns {HTMLElement | void} the wrapper element + */ +function selectWrapper (position) { + const classes = position.replace("_", " "); + const parentWrapper = document.getElementsByClassName(classes); + if (parentWrapper.length > 0) { + const wrapper = parentWrapper[0].getElementsByClassName("container"); + if (wrapper.length > 0) { + return wrapper[0]; } - }; - - /** - * Update the dom for a specific module. - * @param {Module} module The module that needs an update. - * @param {object|number} [updateOptions] The (optional) number of microseconds for the animation or object with updateOptions (speed/animates) - * @param {boolean} [createAnimatedDom] for displaying only animateIn (used on first start of MagicMirror) - * @returns {Promise} Resolved when the dom is fully updated. - */ - const updateDom = function (module, updateOptions, createAnimatedDom = false) { - return new Promise(function (resolve) { - let speed = updateOptions; - let animateOut = null; - let animateIn = null; - if (typeof updateOptions === "object") { - if (typeof updateOptions.options === "object" && updateOptions.options.speed !== undefined) { - speed = updateOptions.options.speed; - Log.debug(`updateDom: ${module.identifier} Has speed in object: ${speed}`); - if (typeof updateOptions.options.animate === "object") { - animateOut = updateOptions.options.animate.out; - animateIn = updateOptions.options.animate.in; - Log.debug(`updateDom: ${module.identifier} Has animate in object: out->${animateOut}, in->${animateIn}`); - } - } else { - Log.debug(`updateDom: ${module.identifier} Has no speed in object`); - speed = 0; - } - } - - const newHeader = module.getHeader(); - let newContentPromise = module.getDom(); - - if (!(newContentPromise instanceof Promise)) { - // convert to a promise if not already one to avoid if/else's everywhere - newContentPromise = Promise.resolve(newContentPromise); - } - - newContentPromise - .then(function (newContent) { - const updatePromise = updateDomWithContent(module, speed, newHeader, newContent, animateOut, animateIn, createAnimatedDom); - - updatePromise.then(resolve).catch(Log.error); - }) - .catch(Log.error); - }); - }; - - /** - * Update the dom with the specified content - * @param {Module} module The module that needs an update. - * @param {number} [speed] The (optional) number of microseconds for the animation. - * @param {string} newHeader The new header that is generated. - * @param {HTMLElement} newContent The new content that is generated. - * @param {string} [animateOut] AnimateCss animation name before hidden - * @param {string} [animateIn] AnimateCss animation name on show - * @param {boolean} [createAnimatedDom] for displaying only animateIn (used on first start) - * @returns {Promise} Resolved when the module dom has been updated. - */ - const updateDomWithContent = function (module, speed, newHeader, newContent, animateOut, animateIn, createAnimatedDom = false) { - return new Promise(function (resolve) { - if (module.hidden || !speed) { - updateModuleContent(module, newHeader, newContent); - resolve(); - return; - } - - if (!moduleNeedsUpdate(module, newHeader, newContent)) { - resolve(); - return; - } - - if (!speed) { - updateModuleContent(module, newHeader, newContent); - resolve(); - return; - } - - if (createAnimatedDom && animateIn !== null) { - Log.debug(`${module.identifier} createAnimatedDom (${animateIn})`); - updateModuleContent(module, newHeader, newContent); - if (!module.hidden) { - showModule(module, speed, null, { animate: animateIn }); + } +} + +/** + * Send a notification to all modules. + * @param {string} notification The identifier of the notification. + * @param {object} payload The payload of the notification. + * @param {Module} sender The module that sent the notification. + * @param {Module} [sendTo] The (optional) module to send the notification to. + */ +function _sendNotification (notification, payload, sender, sendTo) { + for (const m in modules) { + const module = modules[m]; + if (module !== sender && (!sendTo || module === sendTo)) { + module.notificationReceived(notification, payload, sender); + } + } +} + +/** + * Update the dom for a specific module. + * @param {Module} module The module that needs an update. + * @param {object|number} [updateOptions] The (optional) number of microseconds for the animation or object with updateOptions (speed/animates) + * @param {boolean} [createAnimatedDom] for displaying only animateIn (used on first start of MagicMirror) + * @returns {Promise} Resolved when the dom is fully updated. + */ +function _updateDom (module, updateOptions, createAnimatedDom = false) { + return new Promise(function (resolve) { + let speed = updateOptions; + let animateOut = null; + let animateIn = null; + if (typeof updateOptions === "object") { + if (typeof updateOptions.options === "object" && updateOptions.options.speed !== undefined) { + speed = updateOptions.options.speed; + Log.debug(`updateDom: ${module.identifier} Has speed in object: ${speed}`); + if (typeof updateOptions.options.animate === "object") { + animateOut = updateOptions.options.animate.out; + animateIn = updateOptions.options.animate.in; + Log.debug(`updateDom: ${module.identifier} Has animate in object: out->${animateOut}, in->${animateIn}`); } - resolve(); - return; + } else { + Log.debug(`updateDom: ${module.identifier} Has no speed in object`); + speed = 0; } - - hideModule( - module, - speed / 2, - function () { - updateModuleContent(module, newHeader, newContent); - if (!module.hidden) { - showModule(module, speed / 2, null, { animate: animateIn }); - } - resolve(); - }, - { animate: animateOut } - ); - }); - }; - - /** - * Check if the content has changed. - * @param {Module} module The module to check. - * @param {string} newHeader The new header that is generated. - * @param {HTMLElement} newContent The new content that is generated. - * @returns {boolean} True if the module need an update, false otherwise - */ - const moduleNeedsUpdate = function (module, newHeader, newContent) { - const moduleWrapper = document.getElementById(module.identifier); - if (moduleWrapper === null) { - return false; } - const contentWrapper = moduleWrapper.getElementsByClassName("module-content"); - const headerWrapper = moduleWrapper.getElementsByClassName("module-header"); - - let headerNeedsUpdate = false; - let contentNeedsUpdate; + const newHeader = module.getHeader(); + let newContentPromise = module.getDom(); - if (headerWrapper.length > 0) { - headerNeedsUpdate = newHeader !== headerWrapper[0].innerHTML; + if (!(newContentPromise instanceof Promise)) { + // convert to a promise if not already one to avoid if/else's everywhere + newContentPromise = Promise.resolve(newContentPromise); } - const tempContentWrapper = document.createElement("div"); - tempContentWrapper.appendChild(newContent); - contentNeedsUpdate = tempContentWrapper.innerHTML !== contentWrapper[0].innerHTML; - - return headerNeedsUpdate || contentNeedsUpdate; - }; - - /** - * Update the content of a module on screen. - * @param {Module} module The module to check. - * @param {string} newHeader The new header that is generated. - * @param {HTMLElement} newContent The new content that is generated. - */ - const updateModuleContent = function (module, newHeader, newContent) { - const moduleWrapper = document.getElementById(module.identifier); - if (moduleWrapper === null) { + newContentPromise + .then(function (newContent) { + const updatePromise = updateDomWithContent(module, speed, newHeader, newContent, animateOut, animateIn, createAnimatedDom); + + updatePromise.then(resolve).catch(Log.error); + }) + .catch(Log.error); + }); +} + +/** + * Update the dom with the specified content + * @param {Module} module The module that needs an update. + * @param {number} [speed] The (optional) number of microseconds for the animation. + * @param {string} newHeader The new header that is generated. + * @param {HTMLElement} newContent The new content that is generated. + * @param {string} [animateOut] AnimateCss animation name before hidden + * @param {string} [animateIn] AnimateCss animation name on show + * @param {boolean} [createAnimatedDom] for displaying only animateIn (used on first start) + * @returns {Promise} Resolved when the module dom has been updated. + */ +function updateDomWithContent (module, speed, newHeader, newContent, animateOut, animateIn, createAnimatedDom = false) { + return new Promise(function (resolve) { + if (module.hidden || !speed) { + updateModuleContent(module, newHeader, newContent); + resolve(); return; } - const headerWrapper = moduleWrapper.getElementsByClassName("module-header"); - const contentWrapper = moduleWrapper.getElementsByClassName("module-content"); - - contentWrapper[0].innerHTML = ""; - contentWrapper[0].appendChild(newContent); - headerWrapper[0].innerHTML = newHeader; - if (headerWrapper.length > 0 && newHeader) { - headerWrapper[0].style.display = "block"; - } else { - headerWrapper[0].style.display = "none"; + if (!moduleNeedsUpdate(module, newHeader, newContent)) { + resolve(); + return; } - }; - /** - * Hide the module. - * @param {Module} module The module to hide. - * @param {number} speed The speed of the hide animation. - * @param {Promise} callback Called when the animation is done. - * @param {object} [options] Optional settings for the hide method. - */ - const hideModule = function (module, speed, callback, options = {}) { - // set lockString if set in options. - if (options.lockString) { - if (module.lockStrings.indexOf(options.lockString) === -1) { - module.lockStrings.push(options.lockString); - } + if (!speed) { + updateModuleContent(module, newHeader, newContent); + resolve(); + return; } - const moduleWrapper = document.getElementById(module.identifier); - if (moduleWrapper !== null) { - clearTimeout(module.showHideTimer); - // reset all animations if needed - if (module.hasAnimateOut) { - removeAnimateCSS(module.identifier, module.hasAnimateOut); - Log.debug(`${module.identifier} Force remove animateOut (in hide): ${module.hasAnimateOut}`); - module.hasAnimateOut = false; - } - if (module.hasAnimateIn) { - removeAnimateCSS(module.identifier, module.hasAnimateIn); - Log.debug(`${module.identifier} Force remove animateIn (in hide): ${module.hasAnimateIn}`); - module.hasAnimateIn = false; - } - // haveAnimateName for verify if we are using AnimateCSS library - // we check AnimateCSSOut Array for validate it - // and finally return the animate name or `null` (for default MM² animation) - let haveAnimateName = null; - // check if have valid animateOut in module definition (module.data.animateOut) - if (module.data.animateOut && AnimateCSSOut.indexOf(module.data.animateOut) !== -1) haveAnimateName = module.data.animateOut; - // can't be override with options.animate - else if (options.animate && AnimateCSSOut.indexOf(options.animate) !== -1) haveAnimateName = options.animate; - - if (haveAnimateName) { - // with AnimateCSS - Log.debug(`${module.identifier} Has animateOut: ${haveAnimateName}`); - module.hasAnimateOut = haveAnimateName; - addAnimateCSS(module.identifier, haveAnimateName, speed / 1000); - module.showHideTimer = setTimeout(function () { - removeAnimateCSS(module.identifier, haveAnimateName); - Log.debug(`${module.identifier} Remove animateOut: ${module.hasAnimateOut}`); - // AnimateCSS is now done - moduleWrapper.style.opacity = 0; - moduleWrapper.classList.add("hidden"); - moduleWrapper.style.position = "fixed"; - module.hasAnimateOut = false; - - updateWrapperStates(); - if (typeof callback === "function") { - callback(); - } - }, speed); - } else { - // default MM² Animate - moduleWrapper.style.transition = `opacity ${speed / 1000}s`; - moduleWrapper.style.opacity = 0; - moduleWrapper.classList.add("hidden"); - module.showHideTimer = setTimeout(function () { - // To not take up any space, we just make the position absolute. - // since it's fade out anyway, we can see it lay above or - // below other modules. This works way better than adjusting - // the .display property. - moduleWrapper.style.position = "fixed"; - - updateWrapperStates(); - - if (typeof callback === "function") { - callback(); - } - }, speed); - } - } else { - // invoke callback even if no content, issue 1308 - if (typeof callback === "function") { - callback(); + if (createAnimatedDom && animateIn !== null) { + Log.debug(`${module.identifier} createAnimatedDom (${animateIn})`); + updateModuleContent(module, newHeader, newContent); + if (!module.hidden) { + _showModule(module, speed, null, { animate: animateIn }); } + resolve(); + return; } - }; - /** - * Show the module. - * @param {Module} module The module to show. - * @param {number} speed The speed of the show animation. - * @param {Promise} callback Called when the animation is done. - * @param {object} [options] Optional settings for the show method. - */ - const showModule = function (module, speed, callback, options = {}) { - // remove lockString if set in options. - if (options.lockString) { - const index = module.lockStrings.indexOf(options.lockString); - if (index !== -1) { - module.lockStrings.splice(index, 1); - } + _hideModule( + module, + speed / 2, + function () { + updateModuleContent(module, newHeader, newContent); + if (!module.hidden) { + _showModule(module, speed / 2, null, { animate: animateIn }); + } + resolve(); + }, + { animate: animateOut } + ); + }); +} + +/** + * Check if the content has changed. + * @param {Module} module The module to check. + * @param {string} newHeader The new header that is generated. + * @param {HTMLElement} newContent The new content that is generated. + * @returns {boolean} True if the module need an update, false otherwise + */ +function moduleNeedsUpdate (module, newHeader, newContent) { + const moduleWrapper = document.getElementById(module.identifier); + if (moduleWrapper === null) { + return false; + } + + const contentWrapper = moduleWrapper.getElementsByClassName("module-content"); + const headerWrapper = moduleWrapper.getElementsByClassName("module-header"); + + let headerNeedsUpdate = false; + let contentNeedsUpdate; + + if (headerWrapper.length > 0) { + headerNeedsUpdate = newHeader !== headerWrapper[0].innerHTML; + } + + const tempContentWrapper = document.createElement("div"); + tempContentWrapper.appendChild(newContent); + contentNeedsUpdate = tempContentWrapper.innerHTML !== contentWrapper[0].innerHTML; + + return headerNeedsUpdate || contentNeedsUpdate; +} + +/** + * Update the content of a module on screen. + * @param {Module} module The module to check. + * @param {string} newHeader The new header that is generated. + * @param {HTMLElement} newContent The new content that is generated. + */ +function updateModuleContent (module, newHeader, newContent) { + const moduleWrapper = document.getElementById(module.identifier); + if (moduleWrapper === null) { + return; + } + const headerWrapper = moduleWrapper.getElementsByClassName("module-header"); + const contentWrapper = moduleWrapper.getElementsByClassName("module-content"); + + contentWrapper[0].innerHTML = ""; + contentWrapper[0].appendChild(newContent); + + headerWrapper[0].innerHTML = newHeader; + if (headerWrapper.length > 0 && newHeader) { + headerWrapper[0].style.display = "block"; + } else { + headerWrapper[0].style.display = "none"; + } +} + +/** + * Hide the module. + * @param {Module} module The module to hide. + * @param {number} speed The speed of the hide animation. + * @param {Promise} callback Called when the animation is done. + * @param {object} [options] Optional settings for the hide method. + */ +function _hideModule (module, speed, callback, options = {}) { + // set lockString if set in options. + if (options.lockString) { + if (module.lockStrings.indexOf(options.lockString) === -1) { + module.lockStrings.push(options.lockString); } + } - // Check if there are no more lockStrings set, or the force option is set. - // 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; - } + const moduleWrapper = document.getElementById(module.identifier); + if (moduleWrapper !== null) { + clearTimeout(module.showHideTimer); // reset all animations if needed if (module.hasAnimateOut) { removeAnimateCSS(module.identifier, module.hasAnimateOut); - Log.debug(`${module.identifier} Force remove animateOut (in show): ${module.hasAnimateOut}`); + Log.debug(`${module.identifier} Force remove animateOut (in hide): ${module.hasAnimateOut}`); module.hasAnimateOut = false; } if (module.hasAnimateIn) { removeAnimateCSS(module.identifier, module.hasAnimateIn); - Log.debug(`${module.identifier} Force remove animateIn (in show): ${module.hasAnimateIn}`); + Log.debug(`${module.identifier} Force remove animateIn (in hide): ${module.hasAnimateIn}`); module.hasAnimateIn = false; } + // haveAnimateName for verify if we are using AnimateCSS library + // we check AnimateCSSOut Array for validate it + // and finally return the animate name or `null` (for default MM² animation) + let haveAnimateName = null; + // check if have valid animateOut in module definition (module.data.animateOut) + if (module.data.animateOut && AnimateCSSOut.indexOf(module.data.animateOut) !== -1) haveAnimateName = module.data.animateOut; + // can't be override with options.animate + else if (options.animate && AnimateCSSOut.indexOf(options.animate) !== -1) haveAnimateName = options.animate; + + if (haveAnimateName) { + // with AnimateCSS + Log.debug(`${module.identifier} Has animateOut: ${haveAnimateName}`); + module.hasAnimateOut = haveAnimateName; + addAnimateCSS(module.identifier, haveAnimateName, speed / 1000); + module.showHideTimer = setTimeout(function () { + removeAnimateCSS(module.identifier, haveAnimateName); + Log.debug(`${module.identifier} Remove animateOut: ${module.hasAnimateOut}`); + // AnimateCSS is now done + moduleWrapper.style.opacity = 0; + moduleWrapper.classList.add("hidden"); + moduleWrapper.style.position = "fixed"; + module.hasAnimateOut = false; - module.hidden = false; - - // If forced show, clean current lockStrings. - if (module.lockStrings.length !== 0 && options.force === true) { - Log.log(`Force show of module: ${module.name}`); - module.lockStrings = []; + updateWrapperStates(); + if (typeof callback === "function") { + callback(); + } + }, speed); + } else { + // default MM² Animate + moduleWrapper.style.transition = `opacity ${speed / 1000}s`; + moduleWrapper.style.opacity = 0; + moduleWrapper.classList.add("hidden"); + module.showHideTimer = setTimeout(function () { + // To not take up any space, we just make the position absolute. + // since it's fade out anyway, we can see it lay above or + // below other modules. This works way better than adjusting + // the .display property. + moduleWrapper.style.position = "fixed"; + + updateWrapperStates(); + + if (typeof callback === "function") { + callback(); + } + }, speed); + } + } else { + // invoke callback even if no content, issue 1308 + if (typeof callback === "function") { + callback(); } + } +} + +/** + * Show the module. + * @param {Module} module The module to show. + * @param {number} speed The speed of the show animation. + * @param {Promise} callback Called when the animation is done. + * @param {object} [options] Optional settings for the show method. + */ +function _showModule (module, speed, callback, options = {}) { + // remove lockString if set in options. + if (options.lockString) { + const index = module.lockStrings.indexOf(options.lockString); + if (index !== -1) { + module.lockStrings.splice(index, 1); + } + } + + // Check if there are no more lockStrings set, or the force option is set. + // 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; + } + // reset all animations if needed + if (module.hasAnimateOut) { + removeAnimateCSS(module.identifier, module.hasAnimateOut); + Log.debug(`${module.identifier} Force remove animateOut (in show): ${module.hasAnimateOut}`); + module.hasAnimateOut = false; + } + if (module.hasAnimateIn) { + removeAnimateCSS(module.identifier, module.hasAnimateIn); + Log.debug(`${module.identifier} Force remove animateIn (in show): ${module.hasAnimateIn}`); + module.hasAnimateIn = false; + } + + module.hidden = false; + + // If forced show, clean current lockStrings. + if (module.lockStrings.length !== 0 && options.force === true) { + Log.log(`Force show of module: ${module.name}`); + module.lockStrings = []; + } + + const moduleWrapper = document.getElementById(module.identifier); + if (moduleWrapper !== null) { + clearTimeout(module.showHideTimer); + + // haveAnimateName for verify if we are using AnimateCSS library + // we check AnimateCSSIn Array for validate it + // and finally return the animate name or `null` (for default MM² animation) + let haveAnimateName = null; + // check if have valid animateOut in module definition (module.data.animateIn) + if (module.data.animateIn && AnimateCSSIn.indexOf(module.data.animateIn) !== -1) haveAnimateName = module.data.animateIn; + // can't be override with options.animate + else if (options.animate && AnimateCSSIn.indexOf(options.animate) !== -1) haveAnimateName = options.animate; + + if (!haveAnimateName) moduleWrapper.style.transition = `opacity ${speed / 1000}s`; + // Restore the position. See _hideModule() for more info. + moduleWrapper.style.position = "static"; + moduleWrapper.classList.remove("hidden"); - const moduleWrapper = document.getElementById(module.identifier); - if (moduleWrapper !== null) { - clearTimeout(module.showHideTimer); - - // haveAnimateName for verify if we are using AnimateCSS library - // we check AnimateCSSIn Array for validate it - // and finally return the animate name or `null` (for default MM² animation) - let haveAnimateName = null; - // check if have valid animateOut in module definition (module.data.animateIn) - if (module.data.animateIn && AnimateCSSIn.indexOf(module.data.animateIn) !== -1) haveAnimateName = module.data.animateIn; - // can't be override with options.animate - else if (options.animate && AnimateCSSIn.indexOf(options.animate) !== -1) haveAnimateName = options.animate; - - if (!haveAnimateName) moduleWrapper.style.transition = `opacity ${speed / 1000}s`; - // Restore the position. See hideModule() for more info. - moduleWrapper.style.position = "static"; - moduleWrapper.classList.remove("hidden"); - - updateWrapperStates(); - - // Waiting for DOM-changes done in updateWrapperStates before we can start the animation. - void moduleWrapper.parentElement.parentElement.offsetHeight; - moduleWrapper.style.opacity = 1; - - if (haveAnimateName) { - // with AnimateCSS - Log.debug(`${module.identifier} Has animateIn: ${haveAnimateName}`); - module.hasAnimateIn = haveAnimateName; - addAnimateCSS(module.identifier, haveAnimateName, speed / 1000); - module.showHideTimer = setTimeout(function () { - removeAnimateCSS(module.identifier, haveAnimateName); - Log.debug(`${module.identifier} Remove animateIn: ${haveAnimateName}`); - module.hasAnimateIn = false; - if (typeof callback === "function") { - callback(); - } - }, speed); - } else { - // default MM² Animate - module.showHideTimer = setTimeout(function () { - if (typeof callback === "function") { - callback(); - } - }, speed); - } + updateWrapperStates(); + + // Waiting for DOM-changes done in updateWrapperStates before we can start the animation. + void moduleWrapper.parentElement.parentElement.offsetHeight; + moduleWrapper.style.opacity = 1; + + if (haveAnimateName) { + // with AnimateCSS + Log.debug(`${module.identifier} Has animateIn: ${haveAnimateName}`); + module.hasAnimateIn = haveAnimateName; + addAnimateCSS(module.identifier, haveAnimateName, speed / 1000); + module.showHideTimer = setTimeout(function () { + removeAnimateCSS(module.identifier, haveAnimateName); + Log.debug(`${module.identifier} Remove animateIn: ${haveAnimateName}`); + module.hasAnimateIn = false; + if (typeof callback === "function") { + callback(); + } + }, speed); } else { - // invoke callback - if (typeof callback === "function") { - callback(); - } + // default MM² Animate + module.showHideTimer = setTimeout(function () { + if (typeof callback === "function") { + callback(); + } + }, speed); + } + } else { + // invoke callback + if (typeof callback === "function") { + callback(); } - }; + } +} + +/** + * Checks for all positions if it has visible content. + * If not, if will hide the position to prevent unwanted margins. + * This method should be called by the show and hide methods. + * + * Example: + * If the top_bar only contains the update notification. And no update is available, + * the update notification is hidden. The top bar still occupies space making for + * an ugly top margin. By using this function, the top bar will be hidden if the + * update notification is not visible. + */ +function updateWrapperStates () { + modulePositions.forEach(function (position) { + const wrapper = selectWrapper(position); + const moduleWrappers = wrapper.getElementsByClassName("module"); + + let showWrapper = false; + Array.prototype.forEach.call(moduleWrappers, function (moduleWrapper) { + if (moduleWrapper.style.position === "" || moduleWrapper.style.position === "static") { + showWrapper = true; + } + }); + + // move container definitions to main CSS + wrapper.className = showWrapper ? "container" : "container hidden"; + }); +} + +/** + * Loads the core config from the server (already combined with the system defaults). + */ +async function loadConfig () { + try { + const res = await fetch(new URL("config/", `${location.origin}${config.basePath}`)); + + // The server tags functions as { __mmFunction: "" } because + // JSON.stringify can't serialise live functions. This reviver turns + // those tagged objects back into callable functions. + config = JSON.parse(await res.text(), (key, value) => { + if (value && typeof value === "object" && typeof value.__mmFunction === "string") { + try { + return new Function(`return (${value.__mmFunction})`)(); + } catch { + Log.warn(`Failed to revive function for config key "${key}".`); + } + } + return value; + }); + } catch (error) { + Log.error("Unable to retrieve config", error); + } +} + +/** + * Adds special selectors on a collection of modules. + * @param {Module[]} modules Array of modules. + */ +function setSelectionMethodsForModules (modules) { /** - * Checks for all positions if it has visible content. - * If not, if will hide the position to prevent unwanted margins. - * This method should be called by the show and hide methods. - * - * Example: - * If the top_bar only contains the update notification. And no update is available, - * the update notification is hidden. The top bar still occupies space making for - * an ugly top margin. By using this function, the top bar will be hidden if the - * update notification is not visible. + * Filter modules with the specified classes. + * @param {string|string[]} className one or multiple classnames (array or space divided). + * @returns {Module[]} Filtered collection of modules. */ + function withClass (className) { + return modulesByClass(className, true); + } - const updateWrapperStates = function () { - modulePositions.forEach(function (position) { - const wrapper = selectWrapper(position); - const moduleWrappers = wrapper.getElementsByClassName("module"); + /** + * Filter modules without the specified classes. + * @param {string|string[]} className one or multiple classnames (array or space divided). + * @returns {Module[]} Filtered collection of modules. + */ + function exceptWithClass (className) { + return modulesByClass(className, false); + } - let showWrapper = false; - Array.prototype.forEach.call(moduleWrappers, function (moduleWrapper) { - if (moduleWrapper.style.position === "" || moduleWrapper.style.position === "static") { - showWrapper = true; + /** + * Filters a collection of modules based on classname(s). + * @param {string|string[]} className one or multiple classnames (array or space divided). + * @param {boolean} include if the filter should include or exclude the modules with the specific classes. + * @returns {Module[]} Filtered collection of modules. + */ + function modulesByClass (className, include) { + let searchClasses = className; + if (typeof className === "string") { + searchClasses = className.split(" "); + } + + const newModules = modules.filter(function (module) { + const classes = module.data.classes.toLowerCase().split(" "); + + for (const searchClass of searchClasses) { + if (classes.indexOf(searchClass.toLowerCase()) !== -1) { + return include; } - }); + } - // move container definitions to main CSS - wrapper.className = showWrapper ? "container" : "container hidden"; + return !include; }); - }; + + setSelectionMethodsForModules(newModules); + return newModules; + } /** - * Loads the core config from the server (already combined with the system defaults). + * Removes a module instance from the collection. + * @param {object} module The module instance to remove from the collection. + * @returns {Module[]} Filtered collection of modules. */ - const loadConfig = async function () { - try { - const res = await fetch(new URL("config/", `${location.origin}${config.basePath}`)); - - // The server tags functions as { __mmFunction: "" } because - // JSON.stringify can't serialise live functions. This reviver turns - // those tagged objects back into callable functions. - config = JSON.parse(await res.text(), (key, value) => { - if (value && typeof value === "object" && typeof value.__mmFunction === "string") { - try { - return new Function(`return (${value.__mmFunction})`)(); - } catch { - Log.warn(`Failed to revive function for config key "${key}".`); - } - } - return value; - }); - } catch (error) { - Log.error("Unable to retrieve config", error); - } - }; + function exceptModule (module) { + const newModules = modules.filter(function (mod) { + return mod.identifier !== module.identifier; + }); + + setSelectionMethodsForModules(newModules); + return newModules; + } /** - * Adds special selectors on a collection of modules. - * @param {Module[]} modules Array of modules. + * Walks thru a collection of modules and executes the callback with the module as an argument. + * @param {module} callback The function to execute with the module as an argument. */ - const setSelectionMethodsForModules = function (modules) { - - /** - * Filter modules with the specified classes. - * @param {string|string[]} className one or multiple classnames (array or space divided). - * @returns {Module[]} Filtered collection of modules. - */ - const withClass = function (className) { - return modulesByClass(className, true); - }; - - /** - * Filter modules without the specified classes. - * @param {string|string[]} className one or multiple classnames (array or space divided). - * @returns {Module[]} Filtered collection of modules. - */ - const exceptWithClass = function (className) { - return modulesByClass(className, false); - }; - - /** - * Filters a collection of modules based on classname(s). - * @param {string|string[]} className one or multiple classnames (array or space divided). - * @param {boolean} include if the filter should include or exclude the modules with the specific classes. - * @returns {Module[]} Filtered collection of modules. - */ - const modulesByClass = function (className, include) { - let searchClasses = className; - if (typeof className === "string") { - searchClasses = className.split(" "); - } + function enumerate (callback) { + modules.map(function (module) { + callback(module); + }); + } + + if (typeof modules.withClass === "undefined") { + Object.defineProperty(modules, "withClass", { value: withClass, enumerable: false }); + } + if (typeof modules.exceptWithClass === "undefined") { + Object.defineProperty(modules, "exceptWithClass", { value: exceptWithClass, enumerable: false }); + } + if (typeof modules.exceptModule === "undefined") { + Object.defineProperty(modules, "exceptModule", { value: exceptModule, enumerable: false }); + } + if (typeof modules.enumerate === "undefined") { + Object.defineProperty(modules, "enumerate", { value: enumerate, enumerable: false }); + } +} + +export const MM = { + + /* Public Methods */ - const newModules = modules.filter(function (module) { - const classes = module.data.classes.toLowerCase().split(" "); + /** + * Main init method. + */ + async init () { + Log.info("Initializing MagicMirror²."); + await loadConfig(); - for (const searchClass of searchClasses) { - if (classes.indexOf(searchClass.toLowerCase()) !== -1) { - return include; - } - } + Log.setLogLevel(config.logLevel); - return !include; - }); + await globalThis.Translator.loadCoreTranslations(config.language); + await Loader.loadModules(); + }, - setSelectionMethodsForModules(newModules); - return newModules; - }; - - /** - * Removes a module instance from the collection. - * @param {object} module The module instance to remove from the collection. - * @returns {Module[]} Filtered collection of modules. - */ - const exceptModule = function (module) { - const newModules = modules.filter(function (mod) { - return mod.identifier !== module.identifier; - }); + /** + * Gets called when all modules are started. + * @param {Module[]} moduleObjects All module instances. + */ + modulesStarted (moduleObjects) { + modules = []; + let startUp = ""; - setSelectionMethodsForModules(newModules); - return newModules; - }; - - /** - * Walks thru a collection of modules and executes the callback with the module as an argument. - * @param {module} callback The function to execute with the module as an argument. - */ - const enumerate = function (callback) { - modules.map(function (module) { - callback(module); - }); - }; + moduleObjects.forEach((module) => modules.push(module)); - if (typeof modules.withClass === "undefined") { - Object.defineProperty(modules, "withClass", { value: withClass, enumerable: false }); - } - if (typeof modules.exceptWithClass === "undefined") { - Object.defineProperty(modules, "exceptWithClass", { value: exceptWithClass, enumerable: false }); - } - if (typeof modules.exceptModule === "undefined") { - Object.defineProperty(modules, "exceptModule", { value: exceptModule, enumerable: false }); - } - if (typeof modules.enumerate === "undefined") { - Object.defineProperty(modules, "enumerate", { value: enumerate, enumerable: false }); - } - }; + Log.info("All modules started!"); + _sendNotification("ALL_MODULES_STARTED"); - return { + createDomObjects(); - /* Public Methods */ - - /** - * Main init method. - */ - async init () { - Log.info("Initializing MagicMirror²."); - await loadConfig(); + // Setup global socket listener for RELOAD event (watch mode) + if (typeof io !== "undefined") { + const socket = io("/", { + path: `${config.basePath || "/"}socket.io` + }); - Log.setLogLevel(config.logLevel); + socket.on("RELOAD", () => { + Log.warn("Reload notification received from server"); + window.location.reload(true); + }); + } - await Translator.loadCoreTranslations(config.language); - await Loader.loadModules(); - }, + if (config.reloadAfterServerRestart) { + setInterval(async () => { + // if server startup time has changed (which means server was restarted) + // the client reloads the mm page + try { + const res = await fetch(`${location.protocol}//${location.host}${config.basePath}startup`); + const curr = await res.text(); + if (startUp === "") startUp = curr; + if (startUp !== curr) { + startUp = ""; + window.location.reload(true); + Log.warn("Refreshing Website because server was restarted"); + } + } catch (err) { + Log.error(`MagicMirror not reachable: ${err}`); + } + }, config.checkServerInterval); + } + }, - /** - * Gets called when all modules are started. - * @param {Module[]} moduleObjects All module instances. - */ - modulesStarted (moduleObjects) { - modules = []; - let startUp = ""; + /** + * Send a notification to all modules. + * @param {string} notification The identifier of the notification. + * @param {object} payload The payload of the notification. + * @param {Module} sender The module that sent the notification. + */ + sendNotification (notification, payload, sender) { + if (arguments.length < 3) { + Log.error("sendNotification: Missing arguments."); + return; + } - moduleObjects.forEach((module) => modules.push(module)); + if (typeof notification !== "string") { + Log.error("sendNotification: Notification should be a string."); + return; + } - Log.info("All modules started!"); - sendNotification("ALL_MODULES_STARTED"); + if (!(sender instanceof Module)) { + Log.error("sendNotification: Sender should be a module."); + return; + } - createDomObjects(); + // Further implementation is done in the private method. + _sendNotification(notification, payload, sender); + }, - // Setup global socket listener for RELOAD event (watch mode) - if (typeof io !== "undefined") { - const socket = io("/", { - path: `${config.basePath || "/"}socket.io` - }); + /** + * Update the dom for a specific module. + * @param {Module} module The module that needs an update. + * @param {object|number} [updateOptions] The (optional) number of microseconds for the animation or object with updateOptions (speed/animates) + */ + updateDom (module, updateOptions) { + if (!(module instanceof Module)) { + Log.error("updateDom: Sender should be a module."); + return; + } - socket.on("RELOAD", () => { - Log.warn("Reload notification received from server"); - window.location.reload(true); - }); - } + if (!module.data.position) { + Log.warn("module tries to update the DOM without being displayed."); + return; + } - if (config.reloadAfterServerRestart) { - setInterval(async () => { - // if server startup time has changed (which means server was restarted) - // the client reloads the mm page - try { - const res = await fetch(`${location.protocol}//${location.host}${config.basePath}startup`); - const curr = await res.text(); - if (startUp === "") startUp = curr; - if (startUp !== curr) { - startUp = ""; - window.location.reload(true); - Log.warn("Refreshing Website because server was restarted"); - } - } catch (err) { - Log.error(`MagicMirror not reachable: ${err}`); - } - }, config.checkServerInterval); - } - }, - - /** - * Send a notification to all modules. - * @param {string} notification The identifier of the notification. - * @param {object} payload The payload of the notification. - * @param {Module} sender The module that sent the notification. - */ - sendNotification (notification, payload, sender) { - if (arguments.length < 3) { - Log.error("sendNotification: Missing arguments."); - return; - } + // Further implementation is done in the private method. + _updateDom(module, updateOptions).then(function () { + // Once the update is complete and rendered, send a notification to the module that the DOM has been updated + _sendNotification("MODULE_DOM_UPDATED", null, null, module); + }); + }, - if (typeof notification !== "string") { - Log.error("sendNotification: Notification should be a string."); - return; - } + /** + * Returns a collection of all modules currently active. + * @returns {Module[]} A collection of all modules currently active. + */ + getModules () { + setSelectionMethodsForModules(modules); + return modules; + }, - if (!(sender instanceof Module)) { - Log.error("sendNotification: Sender should be a module."); - return; - } + /** + * Hide the module. + * @param {Module} module The module to hide. + * @param {number} speed The speed of the hide animation. + * @param {Promise} callback Called when the animation is done. + * @param {object} [options] Optional settings for the hide method. + */ + hideModule (module, speed, callback, options) { + module.hidden = true; + _hideModule(module, speed, callback, options); + }, - // Further implementation is done in the private method. - sendNotification(notification, payload, sender); - }, - - /** - * Update the dom for a specific module. - * @param {Module} module The module that needs an update. - * @param {object|number} [updateOptions] The (optional) number of microseconds for the animation or object with updateOptions (speed/animates) - */ - updateDom (module, updateOptions) { - if (!(module instanceof Module)) { - Log.error("updateDom: Sender should be a module."); - return; - } + /** + * Show the module. + * @param {Module} module The module to show. + * @param {number} speed The speed of the show animation. + * @param {Promise} callback Called when the animation is done. + * @param {object} [options] Optional settings for the show method. + */ + showModule (module, speed, callback, options) { + // do not change module.hidden yet, only if we really show it later + _showModule(module, speed, callback, options); + }, - if (!module.data.position) { - Log.warn("module tries to update the DOM without being displayed."); - return; - } + // Return all available module positions. + getAvailableModulePositions: modulePositions +}; - // Further implementation is done in the private method. - updateDom(module, updateOptions).then(function () { - // Once the update is complete and rendered, send a notification to the module that the DOM has been updated - sendNotification("MODULE_DOM_UPDATED", null, null, module); - }); - }, - - /** - * Returns a collection of all modules currently active. - * @returns {Module[]} A collection of all modules currently active. - */ - getModules () { - setSelectionMethodsForModules(modules); - return modules; - }, - - /** - * Hide the module. - * @param {Module} module The module to hide. - * @param {number} speed The speed of the hide animation. - * @param {Promise} callback Called when the animation is done. - * @param {object} [options] Optional settings for the hide method. - */ - hideModule (module, speed, callback, options) { - module.hidden = true; - hideModule(module, speed, callback, options); - }, - - /** - * Show the module. - * @param {Module} module The module to show. - * @param {number} speed The speed of the show animation. - * @param {Promise} callback Called when the animation is done. - * @param {object} [options] Optional settings for the show method. - */ - showModule (module, speed, callback, options) { - // do not change module.hidden yet, only if we really show it later - showModule(module, speed, callback, options); - }, - - // Return all available module positions. - getAvailableModulePositions: modulePositions - }; -}()); +// Legacy global bridge for third-party modules that reference window.MM directly. +if (!globalThis.MM) globalThis.MM = MM; MM.init(); diff --git a/js/module.js b/js/module.js index 96479ec3b8..e99eaa3b12 100644 --- a/js/module.js +++ b/js/module.js @@ -1,10 +1,15 @@ -/* global Loader, MMSocket, nunjucks */ +/* global nunjucks */ + +// eslint-disable-next-line import-x/extensions +import { Loader } from "./loader.js"; +// eslint-disable-next-line import-x/extensions +import { MMSocket } from "./socketclient.js"; /* * Module Blueprint. * @typedef {Object} Module */ -class Module { +export class Module { /** * Initializes per-instance mutable state. @@ -412,6 +417,8 @@ class Module { } } +globalThis.Module = Module; + /** * Merging MagicMirror² (or other) default/config script by `@bugsounet` * Merge 2 objects or/with array @@ -499,8 +506,6 @@ Module.register = function (name, moduleDefinition) { Module.definitions[name] = moduleDefinition; }; -window.Module = Module; - /** * Compare two semantic version numbers and return the difference. * @param {string} a Version number a. @@ -508,7 +513,7 @@ window.Module = Module; * @returns {number} A positive number if a is larger than b, a negative * number if a is smaller and 0 if they are the same */ -function cmpVersions (a, b) { +export function cmpVersions (a, b) { const regExStrip0 = /(\.0+)+$/; const segmentsA = a.replace(regExStrip0, "").split("."); const segmentsB = b.replace(regExStrip0, "").split("."); @@ -528,7 +533,7 @@ function cmpVersions (a, b) { * @param {object} obj Object to be cloned * @returns {object} the cloned object */ -function cloneObject (obj) { +export function cloneObject (obj) { if (obj === null || typeof obj !== "object") { return obj; } diff --git a/js/socketclient.js b/js/socketclient.js index 1f7b3f3a4c..9a2dfbfd98 100644 --- a/js/socketclient.js +++ b/js/socketclient.js @@ -1,6 +1,6 @@ /* global io */ -const MMSocket = function (moduleName) { +export const MMSocket = function (moduleName) { if (typeof moduleName !== "string") { throw new Error("Please set the module name for the MMSocket."); } @@ -45,4 +45,5 @@ const MMSocket = function (moduleName) { }; }; -globalThis.MMSocket = MMSocket; +// Legacy global bridge for third-party modules that reference MMSocket directly. +if (!globalThis.MMSocket) globalThis.MMSocket = MMSocket; diff --git a/tests/e2e/translations_spec.js b/tests/e2e/translations_spec.js index 54c39daaf6..ee275ae7fa 100644 --- a/tests/e2e/translations_spec.js +++ b/tests/e2e/translations_spec.js @@ -1,5 +1,6 @@ const fs = require("node:fs"); const path = require("node:path"); +const { pathToFileURL } = require("node:url"); const helmet = require("helmet"); const { JSDOM } = require("jsdom"); const express = require("express"); @@ -54,23 +55,47 @@ describe("translations", () => { describe("loadTranslations", () => { let dom; - beforeEach(() => { + beforeEach(async () => { // Create a new translation test environment for each test const env = createTranslationTestEnvironment(); const window = env.window; - // Load module.js content directly for loadTranslations tests - const moduleJs = fs.readFileSync(path.join(__dirname, "..", "..", "js", "module.js"), "utf-8"); - - // Execute the script in the JSDOM context - window.eval(moduleJs); - - // Additional setup for loadTranslations tests - window.config = { language: "de" }; + // Bridge JSDOM globals to Node.js so module.js (ES module) can access them + global.Log = window.Log; + global.Translator = window.Translator; + global.config = { language: "de" }; + global.window = { name: "", mmVersion: "2.0.0" }; + global.MM = { hideModule: () => {}, showModule: () => {}, sendNotification: () => {}, updateDom: () => {} }; + global.nunjucks = { + Environment () { + this.addFilter = () => {}; + this.renderString = () => ""; + this.render = (_t, _d, cb) => cb(null, ""); + }, + WebLoader () {}, + runtime: { markSafe: (str) => str } + }; + + // Import Module directly — eval can't handle ES module syntax + const modulePath = pathToFileURL(path.join(__dirname, "..", "..", "js", "module.js")).href; + const { Module } = await import(`${modulePath}?test=${Date.now()}`); + window.Module = Module; + + // Expose config on window so tests can modify dom.window.config + window.config = global.config; dom = { window }; }); + afterEach(() => { + delete global.Log; + delete global.Translator; + delete global.config; + delete global.window; + delete global.MM; + delete global.nunjucks; + }); + it("should load translation file", async () => { const { Translator, Module, config } = dom.window; config.language = "en"; diff --git a/tests/unit/classes/module_spec.js b/tests/unit/classes/module_spec.js index 9f687a4a41..8f62c39c54 100644 --- a/tests/unit/classes/module_spec.js +++ b/tests/unit/classes/module_spec.js @@ -1,26 +1,64 @@ const path = require("node:path"); -const { JSDOM } = require("jsdom"); +const { pathToFileURL } = require("node:url"); describe("File js/module (cloneObject)", () => { describe("Test function cloneObject", () => { let clone; let Module; - let dom; - - beforeAll(() => { - return new Promise((done) => { - dom = new JSDOM( - `\ - \ -