// (C) Copyright 2015 Martin Dougiamas // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. import { Injectable } from '@angular/core'; import { Platform } from 'ionic-angular'; import { Observable } from 'rxjs'; import { InAppBrowser, InAppBrowserObject } from '@ionic-native/in-app-browser'; import { Clipboard } from '@ionic-native/clipboard'; import { CoreAppProvider } from '../app'; import { CoreDomUtilsProvider } from './dom'; import { CoreLoggerProvider } from '../logger'; import { TranslateService } from '@ngx-translate/core'; import { CoreLangProvider } from '../lang'; export interface PromiseDefer { promise?: Promise; // Promise created. resolve?: (value?: any) => any; // Function to resolve the promise. reject?: (reason?: any) => any; // Function to reject the promise. } /* * "Utils" service with helper functions. */ @Injectable() export class CoreUtilsProvider { logger; iabInstance: InAppBrowserObject; constructor(private iab: InAppBrowser, private appProvider: CoreAppProvider, private clipboard: Clipboard, private domUtils: CoreDomUtilsProvider, logger: CoreLoggerProvider, private translate: TranslateService, private platform: Platform, private langProvider: CoreLangProvider) { this.logger = logger.getInstance('CoreUtilsProvider'); } /** * Similar to Promise.all, but if a promise fails this function's promise won't be rejected until ALL promises have finished. * * @param {Promise[]} promises Promises. * @return {Promise} Promise resolved if all promises are resolved and rejected if at least 1 promise fails. */ allPromises(promises: Promise[]): Promise { if (!promises || !promises.length) { return Promise.resolve(); } return new Promise((resolve, reject) => { let count = 0, total = promises.length, error; promises.forEach((promise) => { promise.catch((err) => { error = err; }).finally(() => { count++; if (count === total) { // All promises have finished, reject/resolve. if (error) { reject(error); } else { resolve(); } } }); }); }); } /** * Compare two objects. This function won't compare functions and proto properties, it's a basic compare. * Also, this will only check if itemA's properties are in itemB with same value. This function will still * return true if itemB has more properties than itemA. * * @param {any} itemA First object. * @param {any} itemB Second object. * @param {number} [maxLevels=0] Number of levels to reach if 2 objects are compared. * @param {number} [level=0] Current deep level (when comparing objects). * @param {boolean} [undefinedIsNull=true] True if undefined is equal to null. Defaults to true. * @return {boolean} Whether both items are equal. */ basicLeftCompare(itemA: any, itemB: any, maxLevels = 0, level = 0, undefinedIsNull = true) : boolean { if (typeof itemA == 'function' || typeof itemB == 'function') { return true; // Don't compare functions. } else if (typeof itemA == 'object' && typeof itemB == 'object') { if (level >= maxLevels) { return true; // Max deep reached. } let equal = true; for (let name in itemA) { let value = itemA[name]; if (name == '$$hashKey') { // Ignore $$hashKey property since it's a "calculated" property. return; } if (!this.basicLeftCompare(value, itemB[name], maxLevels, level + 1)) { equal = false; } } return equal; } else { if (undefinedIsNull && ( (typeof itemA == 'undefined' && itemB === null) || (itemA === null && typeof itemB == 'undefined'))) { return true; } // We'll treat "2" and 2 as the same value. let floatA = parseFloat(itemA), floatB = parseFloat(itemB); if (!isNaN(floatA) && !isNaN(floatB)) { return floatA == floatB; } return itemA === itemB; } } /** * Blocks leaving a view. This function should be used in views that want to perform a certain action before * leaving (usually, ask the user if he wants to leave because some data isn't saved). * * @param {Object} scope View's scope. * @param {Function} canLeaveFn Function called when the user wants to leave the view. Must return a promise * resolved if the view should be left, rejected if the user should stay in the view. * @param {Object} [currentView] Current view. Defaults to $ionicHistory.currentView(). * @return {Object} Object with: * -back: Original back function. * -unblock: Function to unblock. It is called automatically when scope is destroyed. */ blockLeaveView = function(scope, canLeaveFn, currentView) { // @todo // currentView = currentView || $ionicHistory.currentView(); // var unregisterHardwareBack, // leaving = false, // hasSplitView = $ionicPlatform.isTablet() && $state.current.name.split('.').length == 3, // skipSplitViewLeave = false; // // Override Ionic's back button behavior. // $rootScope.$ionicGoBack = goBack; // // Override Android's back button. We set a priority of 101 to override the "Return to previous view" action. // unregisterHardwareBack = $ionicPlatform.registerBackButtonAction(goBack, 101); // // Add function to the stack. // backFunctionsStack.push(goBack); // if (hasSplitView) { // // Block split view. // blockSplitView(true); // } // scope.$on('$destroy', unblock); // return { // back: originalBackFunction, // unblock: unblock // }; // // Function called when the user wants to leave the view. // function goBack() { // // Check that we're leaving the current view, since the user can navigate to other views from here. // if ($ionicHistory.currentView() !== currentView) { // // It's another view. // originalBackFunction(); // return; // } // if (leaving) { // // Leave view pending, don't call again. // return; // } // leaving = true; // canLeaveFn().then(function() { // // User confirmed to leave or there was no need to confirm, go back. // // Skip next leave view from split view if there's one since we already checked if user can leave. // skipSplitViewLeave = hasSplitView; // originalBackFunction(); // }).finally(function() { // leaving = false; // }); // } // // Leaving current view when it's in split view. // function leaveViewInSplitView() { // if (skipSplitViewLeave) { // skipSplitViewLeave = false; // return $q.when(); // } // return canLeaveFn(); // } // // Restore original back functions. // function unblock() { // unregisterHardwareBack(); // if (hasSplitView) { // // Unblock split view. // blockSplitView(false); // } // // Remove function from the stack. // var position = backFunctionsStack.indexOf(goBack); // if (position > -1) { // backFunctionsStack.splice(position, 1); // } // // Revert go back only if it hasn't been overridden by another view. // if ($rootScope.$ionicGoBack === goBack) { // if (!backFunctionsStack.length) { // // Shouldn't happen. Reset stack. // backFunctionsStack = [originalBackFunction]; // $rootScope.$ionicGoBack = originalBackFunction; // } else { // $rootScope.$ionicGoBack = backFunctionsStack[backFunctionsStack.length - 1]; // } // } // } // // Block or unblock split view. // function blockSplitView(block) { // $rootScope.$broadcast(mmCoreSplitViewBlock, { // block: block, // blockFunction: leaveViewInSplitView, // state: currentView.stateName, // stateParams: currentView.stateParams // }); // } } /** * Close the InAppBrowser window. * * @param {boolean} [closeAll] Desktop only. True to close all secondary windows, false to close only the "current" one. */ closeInAppBrowser(closeAll: boolean) : void { if (this.iabInstance) { this.iabInstance.close(); if (closeAll && this.appProvider.isDesktop()) { require('electron').ipcRenderer.send('closeSecondaryWindows'); } } } /** * Copy properties from one object to another. * * @param {any} from Object to copy the properties from. * @param {any} to Object where to store the properties. */ copyProperties(from: any, to: any) : void { for (let name in from) { let value = from[name]; if (typeof value == 'object') { // Clone the object. to[name] = Object.assign({}, value); } else { to[name] = value; } } } /** * Copies a text to clipboard and shows a toast message. * * @param {string} text Text to be copied * @return {Promise} Promise resolved when text is copied. */ copyToClipboard(text: string) : Promise { return this.clipboard.copy(text).then(() => { // Show toast using ionicLoading. return this.domUtils.showToast('mm.core.copiedtoclipboard', true); }).catch(() => { // Ignore errors. }); } /** * Empties an array without losing its reference. * * @param {any[]} array Array to empty. */ emptyArray(array: any[]) : void { array.length = 0; // Empty array without losing its reference. } /** * Removes all properties from an object without losing its reference. * * @param {object} object Object to remove the properties. */ emptyObject(object: object) : void { for (let key in object) { if (object.hasOwnProperty(key)) { delete object[key]; } } } /** * Execute promises one depending on the previous. * * @param {any[]} orderedPromisesData Data to be executed including the following values: * - func: Function to be executed. * - context: Context to pass to the function. This allows using "this" inside the function. * - params: Array of data to be sent to the function. * - blocking: Boolean. If promise should block the following. * @return {Promise} Promise resolved when all promises are resolved. */ executeOrderedPromises(orderedPromisesData: any[]) : Promise { let promises = [], dependency = Promise.resolve(); // Execute all the processes in order. for (let i in orderedPromisesData) { let data = orderedPromisesData[i], promise; // Add the process to the dependency stack. promise = dependency.finally(() => { let prom; try { prom = data.func.apply(data.context, data.params || []); } catch (e) { this.logger.error(e.message); return; } return prom; }); promises.push(promise); // If the new process is blocking, we set it as the dependency. if (data.blocking) { dependency = promise; } } // Return when all promises are done. return this.allPromises(promises); } /** * Flatten an object, moving subobjects' properties to the first level using dot notation. E.g.: * {a: {b: 1, c: 2}, d: 3} -> {'a.b': 1, 'a.c': 2, d: 3} * * @param {object} obj Object to flatten. * @return {object} Flatten object. */ flattenObject(obj: object) : object { let toReturn = {}; for (let name in obj) { if (!obj.hasOwnProperty(name)) continue; if (typeof obj[name] == 'object') { let flatObject = this.flattenObject(obj[name]); for (let subName in flatObject) { if (!flatObject.hasOwnProperty(subName)) continue; toReturn[name + '.' + subName] = flatObject[subName]; } } else { toReturn[name] = obj[name]; } } return toReturn; } /** * Given an array of strings, return only the ones that match a regular expression. * * @param {string[]} array Array to filter. * @param {RegExp} regex RegExp to apply to each string. * @return {string[]} Filtered array. */ filterByRegexp(array: string[], regex: RegExp) : string[] { if (!array || !array.length) { return []; } return array.filter((entry) => { let matches = entry.match(regex); return matches && matches.length; }); } /** * Filter the list of site IDs based on a isEnabled function. * * @param {string[]} siteIds Site IDs to filter. * @param {Function} isEnabledFn Function to call for each site. Must return true or a promise resolved with true if enabled. * It receives a siteId param and all the params sent to this function after 'checkAll'. * @param {boolean} [checkAll] True if it should check all the sites, false if it should check only 1 and treat them all * depending on this result. * @param {any} ...args All the params sent after checkAll will be passed to isEnabledFn. * @return {Promise} Promise resolved with the list of enabled sites. */ filterEnabledSites(siteIds: string[], isEnabledFn: Function, checkAll?: boolean, ...args) : Promise { let promises = [], enabledSites = []; for (let i in siteIds) { let siteId = siteIds[i]; if (checkAll || !promises.length) { promises.push(Promise.resolve(isEnabledFn.apply(isEnabledFn, [siteId].concat(args))).then((enabled) => { if (enabled) { enabledSites.push(siteId); } })); } } return this.allPromises(promises).catch(() => { // Ignore errors. }).then(() => { if (!checkAll) { // Checking 1 was enough, so it will either return all the sites or none. return enabledSites.length ? siteIds : []; } else { return enabledSites; } }); } /** * Given a float, prints it nicely. Localized floats must not be used in calculations! * Based on Moodle's format_float. * * @param {any} float The float to print. * @return {string} Locale float. */ formatFloat(float: any) : string { if (typeof float == 'undefined') { return ''; } let localeSeparator = this.translate.instant('mm.core.decsep'); // Convert float to string. float += ''; return float.replace('.', localeSeparator); } /** * Returns a tree formatted from a plain list. * List has to be sorted by depth to allow this function to work correctly. Errors can be thrown if a child node is * processed before a parent node. * * @param {any[]} list List to format. * @param {string} [parentFieldName=parent] Name of the parent field to match with children. * @param {string} [idFieldName=id] Name of the children field to match with parent. * @param {number} [rootParentId=0] The id of the root. * @param {number} [maxDepth=5] Max Depth to convert to tree. Children found will be in the last level of depth. * @return {any[]} Array with the formatted tree, children will be on each node under children field. */ formatTree(list: any[], parentFieldName = 'parent', idFieldName = 'id', rootParentId = 0, maxDepth = 5) : any[] { let map = {}, mapDepth = {}, parent, id, tree = []; list.forEach((node, index) => { id = node[idFieldName]; parent = node[parentFieldName]; node.children = []; // Use map to look-up the parents. map[id] = index; if (parent != rootParentId) { let parentNode = list[map[parent]]; if (parentNode) { if (mapDepth[parent] == maxDepth) { // Reached max level of depth. Proceed with flat order. Find parent object of the current node. let parentOfParent = parentNode[parentFieldName]; if (parentOfParent) { // This element will be the child of the node that is two levels up the hierarchy // (i.e. the child of node.parent.parent). list[map[parentOfParent]].children.push(node); // Assign depth level to the same depth as the parent (i.e. max depth level). mapDepth[id] = mapDepth[parent]; // Change the parent to be the one that is two levels up the hierarchy. node.parent = parentOfParent; } } else { parentNode.children.push(node); // Increase the depth level. mapDepth[id] = mapDepth[parent] + 1; } } } else { tree.push(node); // Root elements are the first elements in the tree structure, therefore have the depth level 1. mapDepth[id] = 1; } }); return tree; } /** * Get country name based on country code. * * @param {string} code Country code (AF, ES, US, ...). * @return {string} Country name. If the country is not found, return the country code. */ getCountryName(code: string) : string { let countryKey = 'mm.core.country-' + code, countryName = this.translate.instant(countryKey); return countryName !== countryKey ? countryName : code; } /** * Get list of countries with their code and translated name. * * @return {Promise} Promise resolved with the list of countries. */ getCountryList() : Promise { // Get the current language. return this.langProvider.getCurrentLanguage().then((lang) => { // Get the full list of translations. Create a promise to convert the observable into a promise. return new Promise((resolve, reject) => { let observer = this.translate.getTranslation(lang).subscribe((table) => { resolve(table); observer.unsubscribe(); }, (err) => { reject(err); observer.unsubscribe(); }); }); }).then((table) => { let countries = {}; for (let name in table) { if (name.indexOf('mm.core.country-') === 0) { name = name.replace('mm.core.country-', ''); countries[name] = table[name]; } } return countries; }); } /** * Given a list of files, check if there are repeated names. * * @param {any[]} files List of files. * @return {string|boolean} String with error message if repeated, false if no repeated. */ hasRepeatedFilenames(files: any[]) : string|boolean { if (!files || !files.length) { return false; } let names = []; // Check if there are 2 files with the same name. for (let i = 0; i < files.length; i++) { let name = files[i].filename || files[i].name; if (names.indexOf(name) > -1) { return this.translate.instant('mm.core.filenameexist', {$a: name}); } else { names.push(name); } } return false; } /** * Gets the index of the first string that matches a regular expression. * * @param {string[]} array Array to search. * @param {RegExp} regex RegExp to apply to each string. * @return {number} Index of the first string that matches the RegExp. -1 if not found. */ indexOfRegexp(array: string[], regex: RegExp) : number { if (!array || !array.length) { return -1; } for (let i = 0; i < array.length; i++) { let entry = array[i], matches = entry.match(regex); if (matches && matches.length) { return i; } } return -1; } /** * Return true if the param is false (bool), 0 (number) or "0" (string). * * @param {any} value Value to check. * @return {boolean} Whether the value is false, 0 or "0". */ isFalseOrZero(value: any) : boolean { return typeof value != 'undefined' && (value === false || value === "false" || parseInt(value, 10) === 0); } /** * Return true if the param is true (bool), 1 (number) or "1" (string). * * @param {any} value Value to check. * @return {boolean} Whether the value is true, 1 or "1". */ isTrueOrOne(value: any) : boolean { return typeof value != 'undefined' && (value === true || value === "true" || parseInt(value, 10) === 1); } /** * Given an error returned by a WS call, check if the error is generated by the app or it has been returned by the WebSwervice. * * @param {string} error Error to check. * @return {boolean} Whether the error was returned by the WebService. */ isWebServiceError(error: string) : boolean { let localErrors = [ this.translate.instant('mm.core.wsfunctionnotavailable'), this.translate.instant('mm.core.lostconnection'), this.translate.instant('mm.core.userdeleted'), this.translate.instant('mm.core.unexpectederror'), this.translate.instant('mm.core.networkerrormsg'), this.translate.instant('mm.core.serverconnection'), this.translate.instant('mm.core.errorinvalidresponse'), this.translate.instant('mm.core.sitemaintenance'), this.translate.instant('mm.core.upgraderunning'), this.translate.instant('mm.core.nopasswordchangeforced'), this.translate.instant('mm.core.unicodenotsupported') ]; return error && localErrors.indexOf(error) == -1; } /** * Merge two arrays, removing duplicate values. * * @param {any[]} array1 The first array. * @param {any[]} array2 The second array. * @param [key] Key of the property that must be unique. If not specified, the whole entry. * @return {any[]} Merged array. */ mergeArraysWithoutDuplicates(array1: any[], array2: any[], key?: string) : any[] { return this.uniqueArray(array1.concat(array2), key); } /** * Open a file using platform specific method. * * node-webkit: Using the default application configured. * Android: Using the WebIntent plugin. * iOs: Using handleDocumentWithURL. * * @param {string} path The local path of the file to be open. * @return {Promise} Promise resolved when done. */ openFile(path: string) : Promise { return new Promise((resolve, reject) => { if (this.appProvider.isDesktop()) { // It's a desktop app, send an event so the file is opened. It has to be done with an event // because opening the file from here (renderer process) doesn't focus the opened app. // Use sendSync so we can receive the result. if (require('electron').ipcRenderer.sendSync('openItem', path)) { resolve(); } else { reject(this.translate.instant('mm.core.erroropenfilenoapp')); } } else if ((window).plugins) { // @todo reject('TODO'); // var extension = $mmFS.getFileExtension(path), // mimetype = $mmFS.getMimeType(extension); // if (ionic.Platform.isAndroid() && window.plugins.webintent) { // var iParams = { // action: "android.intent.action.VIEW", // url: path, // type: mimetype // }; // window.plugins.webintent.startActivity( // iParams, // function() { // $log.debug('Intent launched'); // deferred.resolve(); // }, // function() { // $log.debug('Intent launching failed.'); // $log.debug('action: ' + iParams.action); // $log.debug('url: ' + iParams.url); // $log.debug('type: ' + iParams.type); // if (!extension || extension.indexOf('/') > -1 || extension.indexOf('\\') > -1) { // // Extension not found. // $mmLang.translateAndRejectDeferred(deferred, 'mm.core.erroropenfilenoextension'); // } else { // $mmLang.translateAndRejectDeferred(deferred, 'mm.core.erroropenfilenoapp'); // } // } // ); // } else if (ionic.Platform.isIOS() && typeof handleDocumentWithURL == 'function') { // $mmFS.getBasePath().then(function(fsRoot) { // // Encode/decode the specific file path, note that a path may contain directories // // with white spaces, special characters... // if (path.indexOf(fsRoot > -1)) { // path = path.replace(fsRoot, ""); // path = encodeURIComponent($mmText.decodeURIComponent(path)); // path = fsRoot + path; // } // handleDocumentWithURL( // function() { // $log.debug('File opened with handleDocumentWithURL' + path); // deferred.resolve(); // }, // function(error) { // $log.debug('Error opening with handleDocumentWithURL' + path); // if(error == 53) { // $log.error('No app that handles this file type.'); // } // self.openInBrowser(path); // deferred.resolve(); // }, // path // ); // }, deferred.reject); // } else { // // Last try, launch the file with the browser. // this.openInBrowser(path); // resolve(); // } } else { // Changing _blank for _system may work in cordova 2.4 and onwards. this.logger.log('Opening external file using window.open()'); window.open(path, '_blank'); resolve(); } }); } /** * Open a URL using InAppBrowser. * Do not use for files, refer to {@link openFile}. * * @param {string} url The URL to open. * @param {any} [options] Override default options passed to InAppBrowser. * @return {InAppBrowserObject} The opened window. */ openInApp(url: string, options?: any) : InAppBrowserObject { if (!url) { return; } options = options || {}; if (!options.enableViewPortScale) { options.enableViewPortScale = 'yes'; // Enable zoom on iOS. } if (!options.location && this.platform.is('ios') && url.indexOf('file://') === 0) { // The URL uses file protocol, don't show it on iOS. // In Android we keep it because otherwise we lose the whole toolbar. options.location = 'no'; } this.iabInstance = this.iab.create(url, '_blank', options); return this.iabInstance; } /** * Open a URL using a browser. * Do not use for files, refer to {@link openFile}. * * @param {string} url The URL to open. */ openInBrowser(url: string) : void { if (this.appProvider.isDesktop()) { // It's a desktop app, use Electron shell library to open the browser. let shell = require('electron').shell; if (!shell.openExternal(url)) { // Open browser failed, open a new window in the app. window.open(url, '_system'); } } else { window.open(url, '_system'); } } /** * Open an online file using platform specific method. * Specially useful for audio and video since they can be streamed. * * node-webkit: Using the default application configured. * Android: Using the WebIntent plugin. * iOS: Using the window.open method (InAppBrowser) * We don't use iOS quickview framework because it doesn't support streaming. * * @param {string} url The URL of the file. * @return {Promise} Promise resolved when opened. */ openOnlineFile(url: string) : Promise { return new Promise((resolve, reject) => { // @todo reject('TODO'); // if (ionic.Platform.isAndroid() && window.plugins && window.plugins.webintent) { // // In Android we need the mimetype to open it. // var iParams; // self.getMimeTypeFromUrl(url).catch(function() { // // Error getting mimetype, return undefined. // }).then(function(mimetype) { // if (!mimetype) { // // Couldn't retrieve mimetype. Return error. // $mmLang.translateAndRejectDeferred(deferred, 'mm.core.erroropenfilenoextension'); // return; // } // iParams = { // action: "android.intent.action.VIEW", // url: url, // type: mimetype // }; // window.plugins.webintent.startActivity( // iParams, // function() { // $log.debug('Intent launched'); // deferred.resolve(); // }, // function() { // $log.debug('Intent launching failed.'); // $log.debug('action: ' + iParams.action); // $log.debug('url: ' + iParams.url); // $log.debug('type: ' + iParams.type); // $mmLang.translateAndRejectDeferred(deferred, 'mm.core.erroropenfilenoapp'); // } // ); // }); // } else { // this.logger.log('Opening remote file using window.open()'); // window.open(url, '_blank'); // resolve(); // } }); } /** * Converts an object into an array, losing the keys. * * @param {object} obj Object to convert. * @return {any[]} Array with the values of the object but losing the keys. */ objectToArray(obj: object) : any[] { return Object.keys(obj).map((key) => { return obj[key]; }); } /** * Converts an object into an array of objects, where each entry is an object containing * the key and value of the original object. * For example, it can convert {size: 2} into [{name: 'size', value: 2}]. * * @param {object} obj Object to convert. * @param {string} keyName Name of the properties where to store the keys. * @param {string} valueName Name of the properties where to store the values. * @param {boolean} [sort] True to sort keys alphabetically, false otherwise. * @return {object[]} Array of objects with the name & value of each property. */ objectToArrayOfObjects(obj: object, keyName: string, valueName: string, sort?: boolean) : object[] { // Get the entries from an object or primitive value. let getEntries = (elKey, value) => { if (typeof value == 'object') { // It's an object, return at least an entry for each property. let keys = Object.keys(value), entries = []; keys.forEach((key) => { let newElKey = elKey ? elKey + '[' + key + ']' : key; entries = entries.concat(getEntries(newElKey, value[key])); }); return entries; } else { // Not an object, return a single entry. let entry = {}; entry[keyName] = elKey; entry[valueName] = value; return entry; } }; if (!obj) { return []; } // "obj" will always be an object, so "entries" will always be an array. let entries = getEntries('', obj); if (sort) { return entries.sort((a, b) => { return a.name >= b.name ? 1 : -1; }); } return entries; } /** * Converts an array of objects into an object with key and value. The opposite of objectToArrayOfObjects. * For example, it can convert [{name: 'size', value: 2}] into {size: 2}. * * @param {object[]} objects List of objects to convert. * @param {string} keyName Name of the properties where the keys are stored. * @param {string} valueName Name of the properties where the values are stored. * @param [keyPrefix] Key prefix if neededs to delete it. * @return {object} Object. */ objectToKeyValueMap(objects: object[], keyName: string, valueName: string, keyPrefix?: string) : object { let prefixSubstr = keyPrefix ? keyPrefix.length : 0, mapped = {}; objects.forEach((item) => { let key = prefixSubstr > 0 ? item[keyName].substr(prefixSubstr) : item[keyName]; mapped[key] = item[valueName]; }); return mapped; } /** * Given an observable, convert it to a Promise that will resolve with the first received value. * * @param {Observable} obs The observable to convert. * @return {Promise} Promise. */ observableToPromise(obs: Observable) : Promise { return new Promise((resolve, reject) => { let subscription = obs.subscribe((data) => { // Data received, unsubscribe. subscription.unsubscribe(); resolve(data); }, (error) => { // Data received, unsubscribe. subscription.unsubscribe(); reject(error); }); }); } /** * Similar to AngularJS $q.defer(). * * @return {PromiseDefer} The deferred promise. */ promiseDefer() : PromiseDefer { let deferred: PromiseDefer = {}; deferred.promise = new Promise((resolve, reject) => { deferred.resolve = resolve; deferred.reject = reject; }); return deferred; } /** * Given a promise, returns true if it's rejected or false if it's resolved. * * @param {Promise} promise Promise to check * @return {Promise} Promise resolved with boolean: true if the promise is rejected or false if it's resolved. */ promiseFails(promise: Promise) : Promise { return promise.then(() => { return false; }).catch(() => { return true; }); } /** * Given a promise, returns true if it's resolved or false if it's rejected. * * @param {Promise} promise Promise to check * @return {Promise} Promise resolved with boolean: true if the promise it's resolved or false if it's rejected. */ promiseWorks(promise: Promise) : Promise { return promise.then(() => { return true; }).catch(() => { return false; }); } /** * Tests to see whether two arrays or objects have the same value at a particular key. * Missing values are replaced by '', and the values are compared with ===. * Booleans and numbers are cast to string before comparing. * * @param {any} obj1 The first object or array. * @param {any} obj2 The second object or array. * @param {string} key Key to check. * @return {boolean} Whether the two objects/arrays have the same value (or lack of one) for a given key. */ sameAtKeyMissingIsBlank(obj1: any, obj2: any, key: string) : boolean { let value1 = typeof obj1[key] != 'undefined' ? obj1[key] : '', value2 = typeof obj2[key] != 'undefined' ? obj2[key] : ''; if (typeof value1 == 'number' || typeof value1 == 'boolean') { value1 = '' + value1; } if (typeof value2 == 'number' || typeof value2 == 'boolean') { value2 = '' + value2; } return value1 === value2; } /** * Stringify an object, sorting the properties. It doesn't sort arrays, only object properties. E.g.: * {b: 2, a: 1} -> '{"a":1,"b":2}' * * @param {object} obj Object to stringify. * @return {string} Stringified object. */ sortAndStringify(obj: object) : string { return JSON.stringify(obj, Object.keys(this.flattenObject(obj)).sort()); } /** * Sum the filesizes from a list of files checking if the size will be partial or totally calculated. * * @param {any[]} files List of files to sum its filesize. * @return {object} Object with the file size and a boolean to indicate if it is the total size or only partial. */ sumFileSizes(files: any[]) : object { let result = { size: 0, total: true }; files.forEach((file) => { if (typeof file.filesize == 'undefined') { // We don't have the file size, cannot calculate its total size. result.total = false; } else { result.size += file.filesize; } }); return result; } /** * Converts locale specific floating point/comma number back to standard PHP float value. * Do NOT try to do any math operations before this conversion on any user submitted floats! * Based on Moodle's unformat_float function. * * @param {any} localeFloat Locale aware float representation. * @return {any} False if bad format, empty string if empty value or the parsed float if not. */ unformatFloat(localeFloat: any) : any { // Bad format on input type number. if (typeof localeFloat == "undefined") { return false; } // Empty (but not zero). if (localeFloat == null) { return ''; } // Convert float to string. localeFloat += ''; localeFloat = localeFloat.trim(); if (localeFloat == '') { return ''; } let localeSeparator = this.translate.instant('mm.core.decsep'); localeFloat = localeFloat.replace(' ', ''); // No spaces - those might be used as thousand separators. localeFloat = localeFloat.replace(localeSeparator, '.'); localeFloat = parseFloat(localeFloat); // Bad format. if (isNaN(localeFloat)) { return false; } return localeFloat; } /** * Return an array without duplicate values. * * @param {any[]} array The array to treat. * @param [key] Key of the property that must be unique. If not specified, the whole entry. * @return {any[]} Array without duplicate values. */ uniqueArray(array: any[], key?: string) : any[] { let filtered = [], unique = [], len = array.length; for (let i = 0; i < len; i++) { let entry = array[i], value = key ? entry[key] : entry; if (unique.indexOf(value) == -1) { unique.push(value); filtered.push(entry); } } return filtered; } }