From c8935be6fea44a866db83e0572431194c43ea70d Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 13 Nov 2017 13:35:44 +0100 Subject: [PATCH] MOBILE-2261 site: Implement sites factory and events provider --- src/app/app.module.ts | 6 +- src/classes/site.ts | 1387 +++++++++++++++++++++++++ src/classes/sqlitedb.ts | 18 +- src/core/constants.ts | 1 + src/core/emulator/classes/sqlitedb.ts | 8 +- src/providers/events.ts | 127 +++ src/providers/sites-factory.ts | 57 + src/providers/utils/text.ts | 129 --- src/providers/utils/url.ts | 197 +++- src/providers/utils/utils.ts | 52 + src/providers/ws.ts | 159 ++- 11 files changed, 1888 insertions(+), 253 deletions(-) create mode 100644 src/classes/site.ts create mode 100644 src/providers/events.ts create mode 100644 src/providers/sites-factory.ts diff --git a/src/app/app.module.ts b/src/app/app.module.ts index bbb6d8f17..b628174fc 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -26,6 +26,8 @@ import { CoreMimetypeUtilsProvider } from '../providers/utils/mimetype'; import { CoreInitDelegate } from '../providers/init'; import { CoreFileProvider } from '../providers/file'; import { CoreWSProvider } from '../providers/ws'; +import { CoreEventsProvider } from '../providers/events'; +import { CoreSitesFactoryProvider } from '../providers/sites-factory'; // For translate loader. AoT requires an exported function for factories. export function createTranslateLoader(http: HttpClient) { @@ -72,7 +74,9 @@ export function createTranslateLoader(http: HttpClient) { CoreMimetypeUtilsProvider, CoreInitDelegate, CoreFileProvider, - CoreWSProvider + CoreWSProvider, + CoreEventsProvider, + CoreSitesFactoryProvider, ] }) export class AppModule { diff --git a/src/classes/site.ts b/src/classes/site.ts new file mode 100644 index 000000000..3097746e9 --- /dev/null +++ b/src/classes/site.ts @@ -0,0 +1,1387 @@ +// (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 { Injector } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { HttpClient } from '@angular/common/http'; +import { SQLiteDB } from './sqlitedb'; +import { CoreAppProvider } from '../providers/app'; +import { CoreDbProvider } from '../providers/db'; +import { CoreEventsProvider } from '../providers/events'; +import { CoreFileProvider } from '../providers/file'; +import { CoreLoggerProvider } from '../providers/logger'; +import { CoreWSProvider, CoreWSPreSets, CoreWSFileUploadOptions } from '../providers/ws'; +import { CoreDomUtilsProvider } from '../providers/utils/dom'; +import { CoreTextUtilsProvider } from '../providers/utils/text'; +import { CoreTimeUtilsProvider } from '../providers/utils/time'; +import { CoreUrlUtilsProvider } from '../providers/utils/url'; +import { CoreUtilsProvider } from '../providers/utils/utils'; +import { CoreConstants } from '../core/constants'; +import { CoreConfigConstants } from '../configconstants'; +import { Md5 } from 'ts-md5/dist/md5'; + +export interface CoreSiteWSPreSets { + getFromCache?: boolean; // Get the value from the cache if it's still valid. + saveToCache?: boolean; // Save the result to the cache. + omitExpires?: boolean; // Ignore cache expiration. + emergencyCache?: boolean; // Use the cache when a request fails. Defaults to true. + cacheKey?: string; // Extra key to add to the cache when storing this call, to identify the entry. + getCacheUsingCacheKey?: boolean; // Whether it should use cache key to retrieve the cached data instead of the request params. + getEmergencyCacheUsingCacheKey?: boolean; // Same as getCacheUsingCacheKey, but for emergency cache. + uniqueCacheKey?: boolean; // Whether it should only be 1 entry for this cache key (all entries with same key will be deleted). + filter?: boolean; // Whether to filter WS response (moodlewssettingfilter). Defaults to true. + rewriteurls?: boolean; // Whether to rewrite URLs (moodlewssettingfileurl). Defaults to true. + responseExpected?: boolean; // Defaults to true. Set to false when the expected response is null. + typeExpected?: string; // Defaults to 'object'. Use it when you expect a type that's not an object|array. +}; + +export interface LocalMobileResponse { + code: number; // Code to identify the authentication method to use. + service?: string; // Name of the service to use. + warning?: string; // Code of the warning message. + coreSupported?: boolean; // Whether core SSO is supported. +} + +/** + * Class that represents a site (combination of site + user). + * It will have all the site data and provide utility functions regarding a site. + */ +export class CoreSite { + // List of injected services. This class isn't injectable, so it cannot use DI. + protected appProvider; + protected dbProvider; + protected domUtils; + protected eventsProvider; + protected fileProvider; + protected http; + protected textUtils; + protected timeUtils; + protected translate; + protected utils; + protected urlUtils; + protected wsProvider; + + // Variables for the database. + protected WS_CACHE_STORE = 'wscache'; + protected tableSchema = { + name: this.WS_CACHE_STORE, + columns: [ + { + name: 'id', + type: 'TEXT', + primaryKey: true + }, + { + name: 'data', + type: 'TEXT' + }, + { + name: 'key', + type: 'TEXT' + }, + { + name: 'expirationTime', + type: 'INTEGER' + } + ] + }; + + // Rest of variables. + protected logger; + protected db: SQLiteDB; + protected cleanUnicode = false; + protected lastAutoLogin = 0; + protected moodleReleases = { + '3.1': 2016052300, + '3.2': 2016120500, + '3.3': 2017051503, + '3.4': 2017111300 + }; + protected deprecatedFunctions = { + "core_grade_get_definitions": "core_grading_get_definitions", + "moodle_course_create_courses": "core_course_create_courses", + "moodle_course_get_courses": "core_course_get_courses", + "moodle_enrol_get_users_courses": "core_enrol_get_users_courses", + "moodle_file_get_files": "core_files_get_files", + "moodle_file_upload": "core_files_upload", + "moodle_group_add_groupmembers": "core_group_add_group_members", + "moodle_group_create_groups": "core_group_create_groups", + "moodle_group_delete_groupmembers": "core_group_delete_group_members", + "moodle_group_delete_groups": "core_group_delete_groups", + "moodle_group_get_course_groups": "core_group_get_course_groups", + "moodle_group_get_groupmembers": "core_group_get_group_members", + "moodle_group_get_groups": "core_group_get_groups", + "moodle_message_send_instantmessages": "core_message_send_instant_messages", + "moodle_notes_create_notes": "core_notes_create_notes", + "moodle_role_assign": "core_role_assign_role", + "moodle_role_unassign": "core_role_unassign_role", + "moodle_user_create_users": "core_user_create_users", + "moodle_user_delete_users": "core_user_delete_users", + "moodle_user_get_course_participants_by_id": "core_user_get_course_user_profiles", + "moodle_user_get_users_by_courseid": "core_enrol_get_enrolled_users", + // Both *_user_get_users_by_id are deprecated, but there is no equivalent available in the Mobile service. + "moodle_user_get_users_by_id": "core_user_get_users_by_id", + "moodle_user_update_users": "core_user_update_users", + "moodle_webservice_get_siteinfo": "core_webservice_get_site_info", + }; + + /** + * Create a site. + * + * @param {Injector} injector Angular injector to prevent having to pass all the required services. + * @param {string} id Site ID. + * @param {string} siteUrl Site URL. + * @param {string} token Site's WS token. + * @param {any} info Site info. + * @param {string} [privateToken] Private token. + * @param {any} [config] Site public config. + * @param {boolean} [loggedOut] Whether user is logged out. + */ + constructor(injector: Injector, public id: string, public siteUrl: string, public token: string, public infos: any, + public privateToken?: string, public config?: any, public loggedOut?: boolean) { + // Inject the required services. + let logger = injector.get(CoreLoggerProvider); + this.appProvider = injector.get(CoreAppProvider); + this.dbProvider = injector.get(CoreDbProvider); + this.domUtils = injector.get(CoreDomUtilsProvider); + this.eventsProvider = injector.get(CoreEventsProvider); + this.fileProvider = injector.get(CoreFileProvider); + this.http = injector.get(HttpClient); + this.textUtils = injector.get(CoreTextUtilsProvider); + this.timeUtils = injector.get(CoreTimeUtilsProvider); + this.translate = injector.get(TranslateService); + this.utils = injector.get(CoreUtilsProvider); + this.urlUtils = injector.get(CoreUrlUtilsProvider); + this.wsProvider = injector.get(CoreWSProvider); + + this.logger = logger.getInstance('CoreWSProvider'); + + if (this.id) { + this.initDB(); + } + } + + /** + * Initialize the database. + */ + initDB() : void { + this.db = this.dbProvider.getDB('Site-' + this.id); + this.db.createTableFromSchema(this.tableSchema); + } + + /** + * Get site ID. + * + * @return {string} Site ID. + */ + getId() : string { + return this.id; + } + + /** + * Get site URL. + * + * @return {string} Site URL. + */ + getURL() : string { + return this.siteUrl; + } + + /** + * Get site token. + * + * @return {string} Site token. + */ + getToken() : string { + return this.token; + } + + /** + * Get site info. + * + * @return {any} Site info. + */ + getInfo() : any { + return this.infos; + } + + /** + * Get site private token. + * + * @return {string} Site private token. + */ + getPrivateToken() : string { + return this.privateToken; + } + + /** + * Get site DB. + * + * @return {SQLiteDB} Site DB. + */ + getDb() : SQLiteDB { + return this.db; + } + + /** + * Get site user's ID. + * + * @return {number} User's ID. + */ + getUserId() : number { + if (typeof this.infos != 'undefined' && typeof this.infos.userid != 'undefined') { + return this.infos.userid; + } + } + + /** + * Get site Course ID for frontpage course. If not declared it will return 1 as default. + * + * @return {number} Site Home ID. + */ + getSiteHomeId() : number { + return this.infos && this.infos.siteid || 1; + } + + /** + * Set site ID. + * + * @param {string} New ID. + */ + setId(id: string) : void { + this.id = id; + this.initDB(); + } + + /** + * Set site token. + * + * @param {string} New token. + */ + setToken(token: string) : void { + this.token = token; + } + + /** + * Set site private token. + * + * @param {string} privateToken New private token. + */ + setPrivateToken(privateToken: string) : void { + this.privateToken = privateToken; + } + + /** + * Check if user logged out from the site and needs to authenticate again. + * + * @return {boolean} Whether is logged out. + */ + isLoggedOut() : boolean { + return !!this.loggedOut; + } + + /** + * Set site info. + * + * @param {any} New info. + */ + setInfo(infos: any) : void { + this.infos = infos; + } + + /** + * Set site config. + * + * @param {any} Config. + */ + setConfig(config: any) : void { + this.config = config; + } + + /** + * Set site logged out. + * + * @param {boolean} loggedOut True if logged out and needs to authenticate again, false otherwise. + */ + setLoggedOut(loggedOut: boolean) : void { + this.loggedOut = !!loggedOut; + } + + /** + * Can the user access their private files? + * + * @return {boolean} Whether can access my files. + */ + canAccessMyFiles() : boolean { + const infos = this.getInfo(); + return infos && (typeof infos.usercanmanageownfiles === 'undefined' || infos.usercanmanageownfiles); + } + + /** + * Can the user download files? + * + * @return {boolean} Whether can download files. + */ + canDownloadFiles() : boolean { + const infos = this.getInfo(); + return infos && infos.downloadfiles; + } + + /** + * Can the user use an advanced feature? + * + * @param {string} feature The name of the feature. + * @param {boolean} [whenUndefined=true] The value to return when the parameter is undefined. + * @return {boolean} Whether can use advanced feature. + */ + canUseAdvancedFeature(feature: string, whenUndefined = true) : boolean { + let infos = this.getInfo(), + canUse = true; + + if (typeof infos.advancedfeatures === 'undefined') { + canUse = whenUndefined; + } else { + for (let i in infos.advancedfeatures) { + let item = infos.advancedfeatures[i]; + if (item.name === feature && parseInt(item.value, 10) === 0) { + canUse = false; + } + } + + } + + return canUse; + } + + /** + * Can the user upload files? + * + * @return {boolean} Whether can upload files. + */ + canUploadFiles() : boolean { + const infos = this.getInfo(); + return infos && infos.uploadfiles; + } + + /** + * Fetch site info from the Moodle site. + * + * @return {Promise} A promise to be resolved when the site info is retrieved. + */ + fetchSiteInfo() : Promise { + // get_site_info won't be cached. + let preSets = { + getFromCache: false, + saveToCache: false + } + + // Reset clean Unicode to check if it's supported again. + this.cleanUnicode = false; + + return this.read('core_webservice_get_site_info', {}, preSets); + } + + /** + * Read some data from the Moodle site using WS. Requests are cached by default. + * + * @param {string} method WS method to use. + * @param {any} data Data to send to the WS. + * @param {CoreSiteWSPreSets} [preSets] Extra options. + * @return {Promise} Promise resolved with the response, rejected with CoreWSError if it fails. + */ + read(method: string, data: any, preSets?: CoreSiteWSPreSets) : Promise { + preSets = preSets || {}; + if (typeof preSets.getFromCache == 'undefined') { + preSets.getFromCache = true; + } + if (typeof preSets.saveToCache == 'undefined') { + preSets.saveToCache = true; + } + return this.request(method, data, preSets); + } + + /** + * Sends some data to the Moodle site using WS. Requests are NOT cached by default. + * + * @param {string} method WS method to use. + * @param {any} data Data to send to the WS. + * @param {CoreSiteWSPreSets} [preSets] Extra options. + * @return {Promise} Promise resolved with the response, rejected with CoreWSError if it fails. + */ + write(method: string, data: any, preSets?: CoreSiteWSPreSets) : Promise { + preSets = preSets || {}; + if (typeof preSets.getFromCache == 'undefined') { + preSets.getFromCache = false; + } + if (typeof preSets.saveToCache == 'undefined') { + preSets.saveToCache = false; + } + if (typeof preSets.emergencyCache == 'undefined') { + preSets.emergencyCache = false; + } + return this.request(method, data, preSets); + } + + /** + * WS request to the site. + * + * @param {string} method The WebService method to be called. + * @param {any} data Arguments to pass to the method. + * @param {CoreSiteWSPreSets} preSets Extra options. + * @param {boolean} [retrying] True if we're retrying the call for some reason. This is to prevent infinite loops. + * @return {Promise} Promise resolved with the response, rejected with CoreWSError if it fails. + * @description + * + * Sends a webservice request to the site. This method will automatically add the + * required parameters and pass it on to the low level API in CoreWSProvider.call(). + * + * Caching is also implemented, when enabled this method will returned a cached version of the request if the + * data hasn't expired. + * + * This method is smart which means that it will try to map the method to a compatibility one if need be, usually this + * means that it will fallback on the 'local_mobile_' prefixed function if it is available and the non-prefixed is not. + */ + request(method: string, data: any, preSets: CoreSiteWSPreSets, retrying?: boolean) : Promise { + let initialToken = this.token; + data = data || {}; + + // Get the method to use based on the available ones. + method = this.getCompatibleFunction(method); + + // Check if the method is available, use a prefixed version if possible. + // We ignore this check when we do not have the site info, as the list of functions is not loaded yet. + if (this.getInfo() && !this.wsAvailable(method, false)) { + const compatibilityMethod = CoreConstants.wsPrefix + method; + if (this.wsAvailable(compatibilityMethod, false)) { + this.logger.info(`Using compatibility WS method '${compatibilityMethod}'`); + method = compatibilityMethod; + } else { + this.logger.error(`WS function '${method}' is not available, even in compatibility mode.`); + return Promise.reject(this.wsProvider.createFakeWSError('mm.core.wsfunctionnotavailable', true)); + } + } + + let wsPreSets: CoreWSPreSets = { + wsToken: this.token, + siteUrl: this.siteUrl, + cleanUnicode: this.cleanUnicode, + typeExpected: preSets.typeExpected, + responseExpected: preSets.responseExpected + }; + + if (wsPreSets.cleanUnicode && this.textUtils.hasUnicodeData(data)) { + // Data will be cleaned, notify the user. + this.domUtils.showToast('mm.core.unicodenotsupported', true, 3000); + } else { + // No need to clean data in this call. + wsPreSets.cleanUnicode = false; + } + + // Enable text filtering by default. + data.moodlewssettingfilter = preSets.filter === false ? false : true; + data.moodlewssettingfileurl = preSets.rewriteurls === false ? false : true; + + // Convert the values to string before starting the cache process. + try { + data = this.wsProvider.convertValuesToString(data, wsPreSets.cleanUnicode); + } catch (e) { + // Empty cleaned text found. + return Promise.reject(this.wsProvider.createFakeWSError('mm.core.unicodenotsupportedcleanerror', true)); + } + + return this.getFromCache(method, data, preSets).catch(() => { + // Do not pass those options to the core WS factory. + return this.wsProvider.call(method, data, wsPreSets).then((response) => { + if (preSets.saveToCache) { + this.saveToCache(method, data, response, preSets); + } + + // We pass back a clone of the original object, this may + // prevent errors if in the callback the object is modified. + return Object.assign({}, response); + }).catch((error) => { + if (error.errorcode == 'invalidtoken' || + (error.errorcode == 'accessexception' && error.message.indexOf('Invalid token - token expired') > -1)) { + if (initialToken !== this.token && !retrying) { + // Token has changed, retry with the new token. + return this.request(method, data, preSets, true); + } else if (this.appProvider.isSSOAuthenticationOngoing()) { + // There's an SSO authentication ongoing, wait for it to finish and try again. + return this.appProvider.waitForSSOAuthentication().then(() => { + return this.request(method, data, preSets, true); + }); + } + + // Session expired, trigger event. + this.eventsProvider.trigger(CoreEventsProvider.SESSION_EXPIRED, {siteId: this.id}); + // Change error message. We'll try to get data from cache. + error.message = this.translate.instant('mm.core.lostconnection'); + } else if (error.errorcode === 'userdeleted') { + // User deleted, trigger event. + this.eventsProvider.trigger(CoreEventsProvider.USER_DELETED, {siteId: this.id, params: data}); + error.message = this.translate.instant('mm.core.userdeleted'); + return Promise.reject(error); + } else if (error.errorcode === 'forcepasswordchangenotice') { + // Password Change Forced, trigger event. + this.eventsProvider.trigger(CoreEventsProvider.PASSWORD_CHANGE_FORCED, {siteId: this.id}); + error.message = this.translate.instant('mm.core.forcepasswordchangenotice'); + return Promise.reject(error); + } else if (error.errorcode === 'usernotfullysetup') { + // User not fully setup, trigger event. + this.eventsProvider.trigger(CoreEventsProvider.USER_NOT_FULLY_SETUP, {siteId: this.id}); + error.message = this.translate.instant('mm.core.usernotfullysetup'); + return Promise.reject(error); + } else if (error.errorcode === 'sitepolicynotagreed') { + // Site policy not agreed, trigger event. + this.eventsProvider.trigger(CoreEventsProvider.SITE_POLICY_NOT_AGREED, {siteId: this.id}); + error.message = this.translate.instant('mm.core.sitepolicynotagreederror'); + return Promise.reject(error); + } else if (error.errorcode === 'dmlwriteexception' && this.textUtils.hasUnicodeData(data)) { + if (!this.cleanUnicode) { + // Try again cleaning unicode. + this.cleanUnicode = true; + return this.request(method, data, preSets); + } + // This should not happen. + error.message = this.translate.instant('mm.core.unicodenotsupported'); + return Promise.reject(error); + } else if (typeof preSets.emergencyCache !== 'undefined' && !preSets.emergencyCache) { + this.logger.debug(`WS call '${method}' failed. Emergency cache is forbidden, rejecting.`); + return Promise.reject(error); + } + + this.logger.debug(`WS call '${method}' failed. Trying to use the emergency cache.`); + preSets.omitExpires = true; + preSets.getFromCache = true; + return this.getFromCache(method, data, preSets, true).catch(() => { + return Promise.reject(error); + }); + }); + }); + } + + /** + * Check if a WS is available in this site. + * + * @param {string} method WS name. + * @param {boolean} [checkPrefix=true] When true also checks with the compatibility prefix. + * @return {boolean} Whether the WS is available. + */ + wsAvailable(method: string, checkPrefix = true) : boolean { + if (typeof this.infos == 'undefined') { + return false; + } + + for (let i = 0; i < this.infos.functions.length; i++) { + const func = this.infos.functions[i]; + if (func.name == method) { + return true; + } + } + + // Let's try again with the compatibility prefix. + if (checkPrefix) { + return this.wsAvailable(CoreConstants.wsPrefix + method, false); + } + + return false; + } + + /** + * Get cache ID. + * + * @param {string} method The WebService method. + * @param {any} data Arguments to pass to the method. + * @return {string} Cache ID. + */ + protected getCacheId(method: string, data: any) : string { + return Md5.hashAsciiStr(method + ':' + this.utils.sortAndStringify(data)); + } + + /** + * Get a WS response from cache. + * + * @param {string} method The WebService method to be called. + * @param {any} data Arguments to pass to the method. + * @param {CoreSiteWSPreSets} preSets Extra options. + * @param {boolean} emergency Whether it's an "emergency" cache call (WS call failed). + * @return {Promise} Promise resolved with the WS response. + */ + protected getFromCache(method: string, data: any, preSets: CoreSiteWSPreSets, emergency?: boolean) : Promise { + let id = this.getCacheId(method, data), + promise; + + if (!this.db || !preSets.getFromCache) { + return Promise.reject(null); + } + + if (preSets.getCacheUsingCacheKey || (emergency && preSets.getEmergencyCacheUsingCacheKey)) { + promise = this.db.getRecords(this.WS_CACHE_STORE, {key: preSets.cacheKey}).then((entries) => { + if (!entries.length) { + // Cache key not found, get by params sent. + return this.db.getRecord(this.WS_CACHE_STORE, {id: id}); + } else if (entries.length > 1) { + // More than one entry found. Search the one with same ID as this call. + for (let i = 0, len = entries.length; i < len; i++) { + let entry = entries[i]; + if (entry.id == id) { + return entry; + } + } + } + return entries[0]; + }); + } else { + promise = this.db.getRecord(this.WS_CACHE_STORE, {id: id}); + } + + return promise.then((entry) => { + const now = Date.now(); + + preSets.omitExpires = preSets.omitExpires || !this.appProvider.isOnline(); + + if (!preSets.omitExpires) { + if (now > entry.expirationTime) { + this.logger.debug('Cached element found, but it is expired'); + return Promise.reject(null); + } + } + + if (typeof entry != 'undefined' && typeof entry.data != 'undefined') { + const expires = (entry.expirationTime - now) / 1000; + this.logger.info(`Cached element found, id: ${id} expires in ${expires} seconds`); + return JSON.parse(entry.data); + } + + return Promise.reject(null); + }); + } + + /** + * Save a WS response to cache. + * + * @param {string} method The WebService method. + * @param {any} data Arguments to pass to the method. + * @param {any} response The WS response. + * @param {CoreSiteWSPreSets} preSets Extra options. + * @return {Promise} Promise resolved when the response is saved. + */ + protected saveToCache(method: string, data: any, response: any, preSets: CoreSiteWSPreSets) : Promise { + let id = this.getCacheId(method, data), + cacheExpirationTime = CoreConfigConstants.cache_expiration_time, + promise, + entry: any = { + id: id, + data: JSON.stringify(response) + } + + if (!this.db) { + return Promise.reject(null); + } else { + if (preSets.uniqueCacheKey) { + // Cache key must be unique, delete all entries with same cache key. + promise = this.deleteFromCache(method, data, preSets, true).catch(() => { + // Ignore errors. + }); + } else { + promise = Promise.resolve(); + } + + return promise.then(() => { + cacheExpirationTime = isNaN(cacheExpirationTime) ? 300000 : cacheExpirationTime; + entry.expirationTime = new Date().getTime() + cacheExpirationTime; + if (preSets.cacheKey) { + entry.key = preSets.cacheKey; + } + return this.db.insertOrUpdateRecord(this.WS_CACHE_STORE, entry, {id: id}); + }); + } + } + + /** + * Delete a WS cache entry or entries. + * + * @param {string} method The WebService method to be called. + * @param {any} data Arguments to pass to the method. + * @param {CoreSiteWSPreSets} preSets Extra options. + * @param {boolean} [allCacheKey] True to delete all entries with the cache key, false to delete only by ID. + * @return {Promise} Promise resolved when the entries are deleted. + */ + protected deleteFromCache(method: string, data: any, preSets: CoreSiteWSPreSets, allCacheKey?: boolean) : Promise { + const id = this.getCacheId(method, data); + + if (!this.db) { + return Promise.reject(null); + } else { + if (allCacheKey) { + return this.db.deleteRecords(this.WS_CACHE_STORE, {key: preSets.cacheKey}); + } else { + return this.db.deleteRecords(this.WS_CACHE_STORE, {id: id}); + } + } + } + + /* + * Uploads a file using Cordova File API. + * + * @param {string} filePath File path. + * @param {CoreWSFileUploadOptions} options File upload options. + * @return {Promise} Promise resolved when uploaded. + */ + uploadFile(filePath: string, options: CoreWSFileUploadOptions) : Promise { + if (!options.fileArea) { + options.fileArea = 'draft'; + } + + return this.wsProvider.uploadFile(filePath, options, { + siteUrl: this.siteUrl, + wsToken: this.token + }); + } + + /** + * Invalidates all the cache entries. + * + * @return {Promise} Promise resolved when the cache entries are invalidated. + */ + invalidateWsCache() : Promise { + if (!this.db) { + return Promise.reject(null); + } + + this.logger.debug('Invalidate all the cache for site: ' + this.id); + return this.db.updateRecords(this.WS_CACHE_STORE, {expirationTime: 0}); + } + + /** + * Invalidates all the cache entries with a certain key. + * + * @param {string} key Key to search. + * @return {Promise} Promise resolved when the cache entries are invalidated. + */ + invalidateWsCacheForKey(key: string) : Promise { + if (!this.db) { + return Promise.reject(null); + } + if (!key) { + return Promise.resolve(); + } + + this.logger.debug('Invalidate cache for key: ' + key); + return this.db.updateRecords(this.WS_CACHE_STORE, {expirationTime: 0}, {key: key}); + } + + /** + * Invalidates all the cache entries in an array of keys. + * + * @param {string[]} keys Keys to search. + * @return {Promise} Promise resolved when the cache entries are invalidated. + */ + invalidateMultipleWsCacheForKey(keys: string[]) : Promise { + if (!this.db) { + return Promise.reject(null); + } + if (!keys || !keys.length) { + return Promise.resolve(); + } + + let promises = []; + + this.logger.debug('Invalidating multiple cache keys'); + keys.forEach((key) => { + promises.push(this.invalidateWsCacheForKey(key)); + }); + + return Promise.all(promises); + } + + /** + * Invalidates all the cache entries whose key starts with a certain value. + * + * @param {string} key Key to search. + * @return {Promise} Promise resolved when the cache entries are invalidated. + */ + invalidateWsCacheForKeyStartingWith(key: string) : Promise { + if (!this.db) { + return Promise.reject(null); + } + if (!key) { + return Promise.resolve(); + } + + this.logger.debug('Invalidate cache for key starting with: ' + key); + let sql = 'UPDATE ' + this.WS_CACHE_STORE + ' SET expirationTime=0 WHERE key LIKE ?%'; + return this.db.execute(sql, [key]); + } + + /** + * Generic function for adding the wstoken to Moodle urls and for pointing to the correct script. + * Uses CoreUtilsProvider.fixPluginfileURL, passing site's token. + * + * @param {string} url The url to be fixed. + * @return {string} Fixed URL. + */ + fixPluginfileURL(url: string) : string { + return this.urlUtils.fixPluginfileURL(url, this.token); + } + + /** + * Deletes site's DB. + * + * @return {Promise} Promise to be resolved when the DB is deleted. + */ + deleteDB() : Promise { + return this.dbProvider.deleteDB('Site-' + this.id); + } + + /** + * Deletes site's folder. + * + * @return {Promise} Promise to be resolved when the DB is deleted. + */ + deleteFolder() : Promise { + if (this.fileProvider.isAvailable()) { + const siteFolder = this.fileProvider.getSiteFolder(this.id); + return this.fileProvider.removeDir(siteFolder).catch(() => { + // Ignore any errors, $mmFS.removeDir fails if folder doesn't exists. + }); + } else { + return Promise.resolve(); + } + } + + /** + * Get space usage of the site. + * + * @return {Promise} Promise resolved with the site space usage (size). + */ + getSpaceUsage() : Promise { + if (this.fileProvider.isAvailable()) { + const siteFolderPath = this.fileProvider.getSiteFolder(this.id); + return this.fileProvider.getDirectorySize(siteFolderPath).catch(() => { + return 0; + }); + } else { + return Promise.resolve(0); + } + } + + /** + * Returns the URL to the documentation of the app, based on Moodle version and current language. + * + * @param {string} [page] Docs page to go to. + * @return {Promise} Promise resolved with the Moodle docs URL. + */ + getDocsUrl(page: string) : Promise { + const release = this.infos.release ? this.infos.release : undefined; + return this.urlUtils.getDocsUrl(release, page); + } + + /** + * Check if the local_mobile plugin is installed in the Moodle site. + * + * @param {boolean} [retrying] True if we're retrying the check. + * @return {Promise} Promise resolved when the check is done. + */ + checkLocalMobilePlugin(retrying?: boolean) : Promise { + const checkUrl = this.siteUrl + '/local/mobile/check.php', + service = CoreConfigConstants.wsextservice; + + if (!service) { + // External service not defined. + return Promise.resolve({code: 0}); + } + + let observable = this.http.post(checkUrl, {service: service}).timeout(CoreConstants.wsTimeout); + return this.utils.observableToPromise(observable).then((data: any) => { + if (typeof data != 'undefined' && data.errorcode === 'requirecorrectaccess') { + if (!retrying) { + this.siteUrl = this.urlUtils.addOrRemoveWWW(this.siteUrl); + return this.checkLocalMobilePlugin(true); + } else { + return Promise.reject(data.error); + } + } else if (typeof data == 'undefined' || typeof data.code == 'undefined') { + // local_mobile returned something we didn't expect. Let's assume it's not installed. + return {code: 0, warning: 'mm.login.localmobileunexpectedresponse'}; + } + + const code = parseInt(data.code, 10); + if (data.error) { + switch (code) { + case 1: + // Site in maintenance mode. + return Promise.reject(this.translate.instant('mm.login.siteinmaintenance')); + case 2: + // Web services not enabled. + return Promise.reject(this.translate.instant('mm.login.webservicesnotenabled')); + case 3: + // Extended service not enabled, but the official is enabled. + return {code: 0}; + case 4: + // Neither extended or official services enabled. + return Promise.reject(this.translate.instant('mm.login.mobileservicesnotenabled')); + default: + return Promise.reject(this.translate.instant('mm.core.unexpectederror')); + } + } else { + return {code: code, service: service, coresupported: !!data.coresupported}; + } + }, () => { + return {code: 0}; + }); + } + + /** + * Check if local_mobile has been installed in Moodle. + * + * @return {boolean} Whether the App is able to use local_mobile plugin for this site. + */ + checkIfAppUsesLocalMobile() : boolean { + let appUsesLocalMobile = false; + + if (!this.infos || !this.infos.functions) { + return appUsesLocalMobile; + } + + this.infos.functions.forEach((func) => { + if (func.name.indexOf(CoreConstants.wsPrefix) != -1) { + appUsesLocalMobile = true; + } + }); + + return appUsesLocalMobile; + } + + /** + * Check if local_mobile has been installed in Moodle but the app is not using it. + * + * @return {Promise} Promise resolved it local_mobile was added, rejected otherwise. + */ + checkIfLocalMobileInstalledAndNotUsed() : Promise { + const appUsesLocalMobile = this.checkIfAppUsesLocalMobile(); + + if (appUsesLocalMobile) { + // App already uses local_mobile, it wasn't added. + return Promise.reject(null); + } + + return this.checkLocalMobilePlugin().then((data: LocalMobileResponse) : any => { + if (typeof data.service == 'undefined') { + // local_mobile NOT installed. Reject. + return Promise.reject(null); + } + return data; + }); + } + + /** + * Check if a URL belongs to this site. + * + * @param {string} url URL to check. + * @return {boolean} Whether the URL belongs to this site. + */ + containsUrl(url: string) : boolean { + if (!url) { + return false; + } + + const siteUrl = this.urlUtils.removeProtocolAndWWW(this.siteUrl); + url = this.urlUtils.removeProtocolAndWWW(url); + return url.indexOf(siteUrl) == 0; + } + + /** + * Get the public config of this site. + * + * @return {Promise} Promise resolved with public config. Rejected with an object if error, see CoreWSProvider.callAjax. + */ + getPublicConfig() : Promise { + return this.wsProvider.callAjax('tool_mobile_get_public_config', {}, {siteUrl: this.siteUrl}).then((config) => { + // Use the wwwroot returned by the server. + if (config.httpswwwroot) { + this.siteUrl = config.httpswwwroot; + } + return config; + }); + } + + /** + * Open a URL in browser using auto-login in the Moodle site if available. + * + * @param {string} url The URL to open. + * @param {string} [alertMessage] If defined, an alert will be shown before opening the browser. + * @return {Promise} Promise resolved when done, rejected otherwise. + */ + openInBrowserWithAutoLogin(url: string, alertMessage?: string) : Promise { + return this.openWithAutoLogin(false, url, undefined, alertMessage); + } + + /** + * Open a URL in browser using auto-login in the Moodle site if available and the URL belongs to the site. + * + * @param {string} url The URL to open. + * @param {string} [alertMessage] If defined, an alert will be shown before opening the browser. + * @return {Promise} Promise resolved when done, rejected otherwise. + */ + openInBrowserWithAutoLoginIfSameSite(url: string, alertMessage?: string) : Promise { + return this.openWithAutoLoginIfSameSite(false, url, undefined, alertMessage); + } + + /** + * Open a URL in inappbrowser using auto-login in the Moodle site if available. + * + * @param {string} url The URL to open. + * @param {any} [options] Override default options passed to InAppBrowser. + * @param {string} [alertMessage] If defined, an alert will be shown before opening the inappbrowser. + * @return {Promise} Promise resolved when done, rejected otherwise. + */ + openInAppWithAutoLogin(url: string, options?: any, alertMessage?: string) : Promise { + return this.openWithAutoLogin(true, url, options, alertMessage); + } + + /** + * Open a URL in inappbrowser using auto-login in the Moodle site if available and the URL belongs to the site. + * + * @param {string} url The URL to open. + * @param {object} [options] Override default options passed to inappbrowser. + * @param {string} [alertMessage] If defined, an alert will be shown before opening the inappbrowser. + * @return {Promise} Promise resolved when done, rejected otherwise. + */ + openInAppWithAutoLoginIfSameSite(url: string, options: any, alertMessage?: string) : Promise { + return this.openWithAutoLoginIfSameSite(true, url, options, alertMessage); + } + + /** + * Open a URL in browser or InAppBrowser using auto-login in the Moodle site if available. + * + * @param {boolean} inApp True to open it in InAppBrowser, false to open in browser. + * @param {string} url The URL to open. + * @param {object} [options] Override default options passed to $cordovaInAppBrowser#open. + * @param {string} [alertMessage] If defined, an alert will be shown before opening the browser/inappbrowser. + * @return {Promise} Promise resolved when done, rejected otherwise. + */ + openWithAutoLogin(inApp: boolean, url: string, options?: any, alertMessage?: string) : Promise { + // Convenience function to open the URL. + let open = (url) => { + if (modal) { + modal.dismiss(); + } + + let promise; + if (alertMessage) { + promise = this.domUtils.showAlert('mm.core.notice', alertMessage, null, 3000); + } else { + promise = Promise.resolve(); + } + + return promise.finally(() => { + if (inApp) { + this.utils.openInApp(url, options); + } else { + this.utils.openInBrowser(url); + } + }); + }; + + if (!this.privateToken || !this.wsAvailable('tool_mobile_get_autologin_key') || + (this.lastAutoLogin && this.timeUtils.timestamp() - this.lastAutoLogin < 6 * CoreConstants.secondsMinute)) { + // No private token, WS not available or last auto-login was less than 6 minutes ago. + // Open the final URL without auto-login. + return open(url); + } + + const userId = this.getUserId(), + params = { + privatetoken: this.privateToken + }, + modal = this.domUtils.showModalLoading(); + + // Use write to not use cache. + return this.write('tool_mobile_get_autologin_key', params).then((data) => { + if (!data.autologinurl || !data.key) { + // Not valid data, open the final URL without auto-login. + return open(url); + } + + this.lastAutoLogin = this.timeUtils.timestamp(); + + return open(data.autologinurl + '?userid=' + userId + '&key=' + data.key + '&urltogo=' + url); + }).catch(() => { + // Couldn't get autologin key, open the final URL without auto-login. + return open(url); + }); + } + + /** + * Open a URL in browser or InAppBrowser using auto-login in the Moodle site if available and the URL belongs to the site. + * + * @param {boolean} inApp True to open it in InAppBrowser, false to open in browser. + * @param {string} url The URL to open. + * @param {object} [options] Override default options passed to inappbrowser. + * @param {string} [alertMessage] If defined, an alert will be shown before opening the browser/inappbrowser. + * @return {Promise} Promise resolved when done, rejected otherwise. + */ + openWithAutoLoginIfSameSite(inApp: boolean, url: string, options?: any, alertMessage?: string) : Promise { + if (this.containsUrl(url)) { + return this.openWithAutoLogin(inApp, url, options, alertMessage); + } else { + if (inApp) { + this.utils.openInApp(url, options); + } else { + this.utils.openInBrowser(url); + } + return Promise.resolve(); + } + } + + /** + * Get the config of this site. + * It is recommended to use getStoredConfig instead since it's faster and doesn't use network. + * + * @param {string} [name] Name of the setting to get. If not set or false, all settings will be returned. + * @param {boolean} [ignoreCache] True if it should ignore cached data. + * @return {Promise} Promise resolved with site config. + */ + getConfig(name?: string, ignoreCache?: boolean) { + let preSets: CoreSiteWSPreSets = { + cacheKey: this.getConfigCacheKey() + } + + if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return this.read('tool_mobile_get_config', {}, preSets).then((config) => { + if (name) { + // Return the requested setting. + for (let x in config.settings) { + if (config.settings[x].name == name) { + return config.settings[x].value; + } + } + return Promise.reject(null); + } else { + // Return all settings in the same array. + let settings = {}; + config.settings.forEach((setting) => { + settings[setting.name] = setting.value; + }); + return settings; + } + }); + } + + /** + * Invalidates config WS call. + * + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateConfig() : Promise { + return this.invalidateWsCacheForKey(this.getConfigCacheKey()); + } + + /** + * Get cache key for getConfig WS calls. + * + * @return {string} Cache key. + */ + protected getConfigCacheKey() : string { + return 'tool_mobile_get_config'; + } + + /** + * Get the stored config of this site. + * + * @param {string} [name] Name of the setting to get. If not set, all settings will be returned. + * @return {any} Site config or a specific setting. + */ + getStoredConfig(name?: string) : any { + if (!this.config) { + return; + } + + if (name) { + return this.config[name]; + } else { + return this.config; + } + } + + /** + * Check if a certain feature is disabled in the site. + * + * @param {string} name Name of the feature to check. + * @return {boolean} Whether it's disabled. + */ + isFeatureDisabled(name: string) : boolean { + const disabledFeatures = this.getStoredConfig('tool_mobile_disabledfeatures'); + if (!disabledFeatures) { + return false; + } + + const regEx = new RegExp('(,|^)' + this.textUtils.escapeForRegex(name) + '(,|$)', 'g'); + return !!disabledFeatures.match(regEx); + } + + /** + * Return the function to be used, based on the available functions in the site. It'll try to use non-deprecated + * functions first, and fallback to deprecated ones if needed. + * + * @param {string} method WS function to check. + * @return {string} Method to use based in the available functions. + */ + getCompatibleFunction(method: string) : string { + const newFunction = this.deprecatedFunctions[method]; + if (typeof newFunction != 'undefined') { + // Deprecated function is being used. Warn the developer. + if (this.wsAvailable(newFunction)) { + this.logger.warn('You are using deprecated Web Services: ' + method + + ' you must replace it with the newer function: ' + newFunction); + return newFunction; + } else { + this.logger.warn('You are using deprecated Web Services. ' + + 'Your remote site seems to be outdated, consider upgrade it to the latest Moodle version.'); + } + } else if (!this.wsAvailable(method)) { + // Method not available. Check if there is a deprecated method to use. + for (let oldFunc in this.deprecatedFunctions) { + if (this.deprecatedFunctions[oldFunc] === method && this.wsAvailable(oldFunc)) { + this.logger.warn('Your remote site doesn\'t support the function ' + method + + ', it seems to be outdated, consider upgrade it to the latest Moodle version.'); + return oldFunc; // Use deprecated function. + } + } + } + return method; + } + + /** + * Check if the site version is greater than one or several versions. + * This function accepts a string or an array of strings. If array, the last version must be the highest. + * + * @param {string | string[]} versions Version or list of versions to check. + * @return {boolean} Whether it's greater or equal, false otherwise. + * @description + * If a string is supplied (e.g. '3.2.1'), it will check if the site version is greater or equal than this version. + * + * If an array of versions is supplied, it will check if the site version is greater or equal than the last version, + * or if it's higher or equal than any of the other releases supplied but lower than the next major release. The last + * version of the array must be the highest version. + * For example, if the values supplied are ['3.0.5', '3.2.3', '3.3.1'] the function will return true if the site version + * is either: + * - Greater or equal than 3.3.1. + * - Greater or equal than 3.2.3 but lower than 3.3. + * - Greater or equal than 3.0.5 but lower than 3.1. + * + * This function only accepts versions from 2.4.0 and above. If any of the versions supplied isn't found, it will assume + * it's the last released major version. + */ + isVersionGreaterEqualThan(versions: string | string[]) : boolean { + const siteVersion = parseInt(this.getInfo().version, 10); + + if (Array.isArray(versions)) { + if (!versions.length) { + return false; + } + + for (let i = 0; i < versions.length; i++) { + let versionNumber = this.getVersionNumber(versions[i]); + if (i == versions.length - 1) { + // It's the last version, check only if site version is greater than this one. + return siteVersion >= versionNumber; + } else { + // Check if site version if bigger than this number but lesser than next major. + if (siteVersion >= versionNumber && siteVersion < this.getNextMajorVersionNumber(versions[i])) { + return true; + } + } + } + } else if (typeof versions == 'string') { + // Compare with this version. + return siteVersion >= this.getVersionNumber(versions); + } + + return false; + } + + /** + * Get a version number from a release version. + * If release version is valid but not found in the list of Moodle releases, it will use the last released major version. + * + * @param {string} version Release version to convert to version number. + * @return {number} Version number, 0 if invalid. + */ + protected getVersionNumber(version: string) : number { + let data = this.getMajorAndMinor(version); + + if (!data) { + // Invalid version. + return 0; + } + + if (typeof this.moodleReleases[data.major] == 'undefined') { + // Major version not found. Use the last one. + data.major = Object.keys(this.moodleReleases).slice(-1); + } + + return this.moodleReleases[data.major] + data.minor; + } + + /** + * Given a release version, return the major and minor versions. + * + * @param {string} version Release version (e.g. '3.1.0'). + * @return {object} Object with major and minor. Returns false if invalid version. + */ + protected getMajorAndMinor(version) : any { + const match = version.match(/(\d)+(?:\.(\d)+)?(?:\.(\d)+)?/); + if (!match || !match[1]) { + // Invalid version. + return false; + } + + return { + major: match[1] + '.' + (match[2] || '0'), + minor: parseInt(match[3] || 0, 10) + } + } + + /** + * Given a release version, return the next major version number. + * + * @param {string} version Release version (e.g. '3.1.0'). + * @return {number} Next major version number. + */ + protected getNextMajorVersionNumber(version: string) : number { + let data = this.getMajorAndMinor(version), + position, + releases = Object.keys(this.moodleReleases); + + if (!data) { + // Invalid version. + return 0; + } + + position = releases.indexOf(data.major); + + if (position == -1 || position == releases.length -1) { + // Major version not found or it's the last one. Use the last one. + return this.moodleReleases[releases[position]]; + } + + return this.moodleReleases[releases[position + 1]]; + } +} diff --git a/src/classes/sqlitedb.ts b/src/classes/sqlitedb.ts index 39e371051..4b545c51a 100644 --- a/src/classes/sqlitedb.ts +++ b/src/classes/sqlitedb.ts @@ -303,12 +303,14 @@ export class SQLiteDB { /** * Execute a SQL query. + * IMPORTANT: Use this function only if you cannot use any of the other functions in this API. Please take into account that + * these query will be run in SQLite (Mobile) and Web SQL (desktop), so your query should work in both environments. * * @param {string} sql SQL query to execute. * @param {any[]} params Query parameters. * @return {Promise} Promise resolved with the result. */ - protected execute(sql: string, params?: any[]) : Promise { + execute(sql: string, params?: any[]) : Promise { return this.ready().then(() => { return this.db.executeSql(sql, params); }); @@ -316,16 +318,28 @@ export class SQLiteDB { /** * Execute a set of SQL queries. This operation is atomic. + * IMPORTANT: Use this function only if you cannot use any of the other functions in this API. Please take into account that + * these query will be run in SQLite (Mobile) and Web SQL (desktop), so your query should work in both environments. * * @param {any[]} sqlStatements SQL statements to execute. * @return {Promise} Promise resolved with the result. */ - protected executeBatch(sqlStatements: any[]) : Promise { + executeBatch(sqlStatements: any[]) : Promise { return this.ready().then(() => { return this.db.sqlBatch(sqlStatements); }); } + /** + * Get all the records from a table. + * + * @param {string} table The table to query. + * @return {Promise} Promise resolved with the records. + */ + getAllRecords(table: string) : Promise { + return this.getRecords(table); + } + /** * Get a single field value from a table record where all the given conditions met. * diff --git a/src/core/constants.ts b/src/core/constants.ts index f596d8acd..7290b7d1b 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -25,4 +25,5 @@ export class CoreConstants { public static dontShowError = 'CoreDontShowError'; public static settingsRichTextEditor = 'CoreSettingsRichTextEditor'; public static wsTimeout = 30000; + public static wsPrefix = 'local_mobile_'; } diff --git a/src/core/emulator/classes/sqlitedb.ts b/src/core/emulator/classes/sqlitedb.ts index 34210a38f..193e48263 100644 --- a/src/core/emulator/classes/sqlitedb.ts +++ b/src/core/emulator/classes/sqlitedb.ts @@ -78,12 +78,14 @@ export class SQLiteDBMock extends SQLiteDB { /** * Execute a SQL query. + * IMPORTANT: Use this function only if you cannot use any of the other functions in this API. Please take into account that + * these query will be run in SQLite (Mobile) and Web SQL (desktop), so your query should work in both environments. * * @param {string} sql SQL query to execute. * @param {any[]} params Query parameters. * @return {Promise} Promise resolved with the result. */ - protected execute(sql: string, params?: any[]) : Promise { + execute(sql: string, params?: any[]) : Promise { return new Promise((resolve, reject) => { // With WebSQL, all queries must be run in a transaction. this.db.transaction((tx) => { @@ -96,11 +98,13 @@ export class SQLiteDBMock extends SQLiteDB { /** * Execute a set of SQL queries. This operation is atomic. + * IMPORTANT: Use this function only if you cannot use any of the other functions in this API. Please take into account that + * these query will be run in SQLite (Mobile) and Web SQL (desktop), so your query should work in both environments. * * @param {any[]} sqlStatements SQL statements to execute. * @return {Promise} Promise resolved with the result. */ - protected executeBatch(sqlStatements: any[]) : Promise { + executeBatch(sqlStatements: any[]) : Promise { return new Promise((resolve, reject) => { // Create a transaction to execute the queries. this.db.transaction((tx) => { diff --git a/src/providers/events.ts b/src/providers/events.ts new file mode 100644 index 000000000..a7f0261bd --- /dev/null +++ b/src/providers/events.ts @@ -0,0 +1,127 @@ +// (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 { Subject } from 'rxjs'; +import { CoreLoggerProvider } from '../providers/logger'; + +export interface CoreEventObserver { + off: () => void; // Unsubscribe. +}; + +/* + * Service to send and listen to events. + */ +@Injectable() +export class CoreEventsProvider { + public static SESSION_EXPIRED = 'session_expired'; + public static PASSWORD_CHANGE_FORCED = 'password_change_forced'; + public static USER_NOT_FULLY_SETUP = 'user_not_fully_setup'; + public static SITE_POLICY_NOT_AGREED = 'site_policy_not_agreed'; + public static LOGIN = 'login'; + public static LOGOUT = 'logout'; + public static LANGUAGE_CHANGED = 'language_changed'; + public static NOTIFICATION_SOUND_CHANGED = 'notification_sound_changed'; + public static SITE_ADDED = 'site_added'; + public static SITE_UPDATED = 'site_updated'; + public static SITE_DELETED = 'site_deleted'; + public static COMPLETION_MODULE_VIEWED = 'completion_module_viewed'; + public static USER_DELETED = 'user_deleted'; + public static PACKAGE_STATUS_CHANGED = 'package_status_changed'; + public static SECTION_STATUS_CHANGED = 'section_status_changed'; + public static REMOTE_ADDONS_LOADED = 'remote_addons_loaded'; + + logger; + observables = {}; + uniqueEvents = {}; + + constructor(logger: CoreLoggerProvider) { + this.logger = logger.getInstance('CoreEventsProvider'); + } + + /** + * Listen for a certain event. To stop listening to the event: + * let observer = eventsProvider.on('something', myCallBack); + * ... + * observer.off(); + * + * @param {string} eventName Name of the event to listen to. + * @param {Function} callBack Function to call when the event is triggered. + * @return {CoreEventObserver} Observer to stop listening. + */ + on(eventName: string, callBack: Function) : CoreEventObserver { + // If it's a unique event and has been triggered already, call the callBack. + // We don't need to create an observer because the event won't be triggered again. + if (this.uniqueEvents[eventName]) { + callBack(this.uniqueEvents[eventName].data); + // Return a fake observer to prevent errors. + return { + off: () => {} + }; + } + + this.logger.debug(`New observer listening to event '${eventName}'`); + + if (typeof this.observables[eventName] == 'undefined') { + // No observable for this event, create a new one. + this.observables[eventName] = new Subject(); + } + + this.observables[eventName].subscribe(callBack); + + // Create and return a CoreEventObserver. + return { + off: () => { + this.logger.debug(`Stop listening to event '${eventName}'`); + this.observables[eventName].unsubscribe(callBack); + } + }; + } + + /** + * Triggers an event, notifying all the observers. + * + * @param {string} event Name of the event to trigger. + * @param {any} data Data to pass to the observers. + */ + trigger(eventName: string, data: any) : void { + this.logger.debug(`Event '${eventName}' triggered.`); + if (this.observables[eventName]) { + this.observables[eventName].next(data); + } + } + + /** + * Triggers a unique event, notifying all the observers. If the event has already been triggered, don't do anything. + * + * @param {string} event Name of the event to trigger. + * @param {any} data Data to pass to the observers. + */ + triggerUnique(eventName: string, data: any) : void { + if (this.uniqueEvents[eventName]) { + this.logger.debug(`Unique event '${eventName}' ignored because it was already triggered.`); + } else { + this.logger.debug(`Unique event '${eventName}' triggered.`); + // Store the data so it can be passed to observers that register from now on. + this.uniqueEvents[eventName] = { + data: data + }; + + // Now pass the data to observers. + if (this.observables[eventName]) { + this.observables[eventName].next(data); + } + } + } +} diff --git a/src/providers/sites-factory.ts b/src/providers/sites-factory.ts new file mode 100644 index 000000000..99d400332 --- /dev/null +++ b/src/providers/sites-factory.ts @@ -0,0 +1,57 @@ +// (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, Injector } from '@angular/core'; +import { CoreSite } from '../classes/site'; + +/* + * Provider to create sites instances. +*/ +@Injectable() +export class CoreSitesFactoryProvider { + + constructor(private injector: Injector) {} + + /** + * Make a site object. + * + * @param {string} id Site ID. + * @param {string} siteUrl Site URL. + * @param {string} token Site's WS token. + * @param {any} info Site info. + * @param {string} [privateToken] Private token. + * @param {any} [config] Site public config. + * @param {boolean} [loggedOut] Whether user is logged out. + * @return {CoreSite} Site instance. + * @description + * This returns a site object. + */ + makeSite = function(id: string, siteUrl: string, token: string, info: any, privateToken?: string, + config?: any, loggedOut?: boolean) : CoreSite { + return new CoreSite(this.injector, id, siteUrl, token, info, privateToken, config, loggedOut); + } + + /** + * Gets the list of Site methods. + * + * @return {string[]} List of methods. + */ + getSiteMethods(): string[] { + let methods = []; + for (let name in CoreSite.prototype) { + methods.push(name); + } + return methods; + } +} diff --git a/src/providers/utils/text.ts b/src/providers/utils/text.ts index a8054ad68..4fad2e7ef 100644 --- a/src/providers/utils/text.ts +++ b/src/providers/utils/text.ts @@ -25,25 +25,6 @@ export class CoreTextUtilsProvider { constructor(private translate: TranslateService, private langProvider: CoreLangProvider) {} - /** - * Add or remove 'www' from a URL. The url needs to have http or https protocol. - * - * @param {string} url URL to modify. - * @return {string} Modified URL. - */ - addOrRemoveWWW(url: string) : string { - if (url) { - if (url.match(/http(s)?:\/\/www\./)) { - // Already has www. Remove it. - url = url.replace('www.', ''); - } else { - url = url.replace('https://', 'https://www.'); - url = url.replace('http://', 'http://www.'); - } - } - return url; - } - /** * Given a list of sentences, build a message with all of them wrapped in

. * @@ -326,47 +307,6 @@ export class CoreTextUtilsProvider { }); } - /** - * Formats a URL, trim, lowercase, etc... - * - * @param {string} url The url to be formatted. - * @return {string} Fromatted url. - */ - formatURL(url: string) : string { - url = url.trim(); - - // Check if the URL starts by http or https. - if (! /^http(s)?\:\/\/.*/i.test(url)) { - // Test first allways https. - url = 'https://' + url; - } - - // http allways in lowercase. - url = url.replace(/^http/i, 'http'); - url = url.replace(/^https/i, 'https'); - - // Replace last slash. - url = url.replace(/\/$/, ""); - - return url; - } - - /** - * Given a URL, returns what's after the last '/' without params. - * Example: - * http://mysite.com/a/course.html?id=1 -> course.html - * - * @param {string} url URL to treat. - * @return {string} Last file without params. - */ - getLastFileWithoutParams(url: string) : string { - let filename = url.substr(url.lastIndexOf('/') + 1); - if (filename.indexOf('?') != -1) { - filename = filename.substr(0, filename.indexOf('?')); - } - return filename; - } - /** * Get the pluginfile URL to replace @@PLUGINFILE@@ wildcards. * @@ -383,61 +323,6 @@ export class CoreTextUtilsProvider { return undefined; } - /** - * Get the protocol from a URL. - * E.g. http://www.google.com returns 'http'. - * - * @param {string} url URL to treat. - * @return {string} Protocol, undefined if no protocol found. - */ - getUrlProtocol(url: string) : string { - if (!url) { - return; - } - - let matches = url.match(/^([^\/:\.\?]*):\/\//); - if (matches && matches[1]) { - return matches[1]; - } - } - - /** - * Get the scheme from a URL. Please notice that, if a URL has protocol, it will return the protocol. - * E.g. javascript:doSomething() returns 'javascript'. - * - * @param {string} url URL to treat. - * @return {string} Scheme, undefined if no scheme found. - */ - getUrlScheme(url: string) : string { - if (!url) { - return; - } - - let matches = url.match(/^([a-z][a-z0-9+\-.]*):/); - if (matches && matches[1]) { - return matches[1]; - } - } - - /* - * Gets a username from a URL like: user@mysite.com. - * - * @param {string} url URL to treat. - * @return {string} Username. Undefined if no username found. - */ - getUsernameFromUrl(url: string) : string { - if (url.indexOf('@') > -1) { - // Get URL without protocol. - let withoutProtocol = url.replace(/.*?:\/\//, ''), - matches = withoutProtocol.match(/[^@]*/); - - // Make sure that @ is at the start of the URL, not in a param at the end. - if (matches && matches.length && !matches[0].match(/[\/|?]/)) { - return matches[0]; - } - } - } - /** * Check if a text contains HTML tags. * @@ -498,20 +383,6 @@ export class CoreTextUtilsProvider { return json; } - /** - * Remove protocol and www from a URL. - * - * @param {string} url URL to treat. - * @return {string} Treated URL. - */ - removeProtocolAndWWW(url: string) : string { - // Remove protocol. - url = url.replace(/.*?:\/\//g, ''); - // Remove www. - url = url.replace(/^www./, ''); - return url; - } - /** * Replace all characters that cause problems with files in Android and iOS. * diff --git a/src/providers/utils/url.ts b/src/providers/utils/url.ts index 3d6c9f47d..6f3a2f1bd 100644 --- a/src/providers/utils/url.ts +++ b/src/providers/utils/url.ts @@ -23,6 +23,25 @@ export class CoreUrlUtilsProvider { constructor(private langProvider: CoreLangProvider) {} + /** + * Add or remove 'www' from a URL. The url needs to have http or https protocol. + * + * @param {string} url URL to modify. + * @return {string} Modified URL. + */ + addOrRemoveWWW(url: string) : string { + if (url) { + if (url.match(/http(s)?:\/\/www\./)) { + // Already has www. Remove it. + url = url.replace('www.', ''); + } else { + url = url.replace('https://', 'https://www.'); + url = url.replace('http://', 'http://www.'); + } + } + return url; + } + /** * Extracts the parameters from a URL and stores them in an object. * @@ -41,6 +60,71 @@ export class CoreUrlUtilsProvider { return params; } + /** + * Generic function for adding the wstoken to Moodle urls and for pointing to the correct script. + * For download remote files from Moodle we need to use the special /webservice/pluginfile passing + * the ws token as a get parameter. + * + * @param {string} url The url to be fixed. + * @param {string} token Token to use. + * @return {string} Fixed URL. + */ + fixPluginfileURL(url: string, token: string) : string { + if (!url || !token) { + return ''; + } + + // First check if we need to fix this url or is already fixed. + if (url.indexOf('token=') != -1) { + return url; + } + + // Check if is a valid URL (contains the pluginfile endpoint). + if (!this.isPluginFileUrl(url)) { + return url; + } + + // In which way the server is serving the files? Are we using slash parameters? + if (url.indexOf('?file=') != -1 || url.indexOf('?forcedownload=') != -1 || url.indexOf('?rev=') != -1) { + url += '&'; + } else { + url += '?'; + } + // Always send offline=1 (for external repositories). It shouldn't cause problems for local files or old Moodles. + url += 'token=' + token + '&offline=1'; + + // Some webservices returns directly the correct download url, others not. + if (url.indexOf('/webservice/pluginfile') == -1) { + url = url.replace('/pluginfile', '/webservice/pluginfile'); + } + return url; + } + + /** + * Formats a URL, trim, lowercase, etc... + * + * @param {string} url The url to be formatted. + * @return {string} Fromatted url. + */ + formatURL(url: string) : string { + url = url.trim(); + + // Check if the URL starts by http or https. + if (! /^http(s)?\:\/\/.*/i.test(url)) { + // Test first allways https. + url = 'https://' + url; + } + + // http allways in lowercase. + url = url.replace(/^http/i, 'http'); + url = url.replace(/^https/i, 'https'); + + // Replace last slash. + url = url.replace(/\/$/, ""); + + return url; + } + /** * Returns the URL to the documentation of the app, based on Moodle version and current language. * @@ -67,6 +151,77 @@ export class CoreUrlUtilsProvider { }); } + /** + * Given a URL, returns what's after the last '/' without params. + * Example: + * http://mysite.com/a/course.html?id=1 -> course.html + * + * @param {string} url URL to treat. + * @return {string} Last file without params. + */ + getLastFileWithoutParams(url: string) : string { + let filename = url.substr(url.lastIndexOf('/') + 1); + if (filename.indexOf('?') != -1) { + filename = filename.substr(0, filename.indexOf('?')); + } + return filename; + } + + /** + * Get the protocol from a URL. + * E.g. http://www.google.com returns 'http'. + * + * @param {string} url URL to treat. + * @return {string} Protocol, undefined if no protocol found. + */ + getUrlProtocol(url: string) : string { + if (!url) { + return; + } + + let matches = url.match(/^([^\/:\.\?]*):\/\//); + if (matches && matches[1]) { + return matches[1]; + } + } + + /** + * Get the scheme from a URL. Please notice that, if a URL has protocol, it will return the protocol. + * E.g. javascript:doSomething() returns 'javascript'. + * + * @param {string} url URL to treat. + * @return {string} Scheme, undefined if no scheme found. + */ + getUrlScheme(url: string) : string { + if (!url) { + return; + } + + let matches = url.match(/^([a-z][a-z0-9+\-.]*):/); + if (matches && matches[1]) { + return matches[1]; + } + } + + /* + * Gets a username from a URL like: user@mysite.com. + * + * @param {string} url URL to treat. + * @return {string} Username. Undefined if no username found. + */ + getUsernameFromUrl(url: string) : string { + if (url.indexOf('@') > -1) { + // Get URL without protocol. + let withoutProtocol = url.replace(/.*?:\/\//, ''), + matches = withoutProtocol.match(/[^@]*/); + + // Make sure that @ is at the start of the URL, not in a param at the end. + if (matches && matches.length && !matches[0].match(/[\/|?]/)) { + return matches[0]; + } + } + } + /** * Returns if a URL has any protocol (not a relative URL). * @@ -128,42 +283,16 @@ export class CoreUrlUtilsProvider { } /** - * Generic function for adding the wstoken to Moodle urls and for pointing to the correct script. - * For download remote files from Moodle we need to use the special /webservice/pluginfile passing - * the ws token as a get parameter. + * Remove protocol and www from a URL. * - * @param {string} url The url to be fixed. - * @param {string} token Token to use. - * @return {string} Fixed URL. + * @param {string} url URL to treat. + * @return {string} Treated URL. */ - fixPluginfileURL(url: string, token: string) : string { - if (!url || !token) { - return ''; - } - - // First check if we need to fix this url or is already fixed. - if (url.indexOf('token=') != -1) { - return url; - } - - // Check if is a valid URL (contains the pluginfile endpoint). - if (!this.isPluginFileUrl(url)) { - return url; - } - - // In which way the server is serving the files? Are we using slash parameters? - if (url.indexOf('?file=') != -1 || url.indexOf('?forcedownload=') != -1 || url.indexOf('?rev=') != -1) { - url += '&'; - } else { - url += '?'; - } - // Always send offline=1 (for external repositories). It shouldn't cause problems for local files or old Moodles. - url += 'token=' + token + '&offline=1'; - - // Some webservices returns directly the correct download url, others not. - if (url.indexOf('/webservice/pluginfile') == -1) { - url = url.replace('/pluginfile', '/webservice/pluginfile'); - } + removeProtocolAndWWW(url: string) : string { + // Remove protocol. + url = url.replace(/.*?:\/\//g, ''); + // Remove www. + url = url.replace(/^www./, ''); return url; } diff --git a/src/providers/utils/utils.ts b/src/providers/utils/utils.ts index c1644346b..52ec9dfda 100644 --- a/src/providers/utils/utils.ts +++ b/src/providers/utils/utils.ts @@ -14,6 +14,7 @@ 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'; @@ -349,6 +350,34 @@ export class CoreUtilsProvider { 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. * @@ -916,6 +945,18 @@ export class CoreUtilsProvider { 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) => { + obs.subscribe(resolve, reject); + }); + } + /** * Similar to AngularJS $q.defer(). It will return an object containing the promise, and the resolve and reject functions. * @@ -1021,6 +1062,17 @@ export class CoreUtilsProvider { return query.length ? query.substr(0, query.length - 1) : query; } + /** + * 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. * diff --git a/src/providers/ws.ts b/src/providers/ws.ts index ed0893775..ea826b056 100644 --- a/src/providers/ws.ts +++ b/src/providers/ws.ts @@ -104,7 +104,7 @@ export class CoreWSProvider { * A wrapper function for a moodle WebService call. * * @param {string} method The WebService method to be called. - * @param {any} data Arguments to pass to the method. + * @param {any} data Arguments to pass to the method. It's recommended to call convertValuesToString before passing the data. * @param {CoreWSPreSets} preSets Extra settings and information. * @return {Promise} Promise resolved with the response data in success and rejected with the error message if it fails. */ @@ -123,13 +123,6 @@ export class CoreWSProvider { preSets.responseExpected = true; } - try { - data = this.convertValuesToString(data, preSets.cleanUnicode); - } catch (e) { - // Empty cleaned text found. - return Promise.reject(this.createFakeWSError('mm.core.unicodenotsupportedcleanerror', true)); - } - data.wsfunction = method; data.wstoken = preSets.wsToken; siteUrl = preSets.siteUrl + '/webservice/rest/server.php?moodlewsrestformat=json'; @@ -182,33 +175,32 @@ export class CoreWSProvider { siteUrl = preSets.siteUrl + '/lib/ajax/service.php'; - return new Promise((resolve, reject) => { - this.http.post(siteUrl, JSON.stringify(ajaxData)).timeout(CoreConstants.wsTimeout).subscribe((data: any) => { - // Some moodle web services return null. If the responseExpected value is set then so long as no data - // is returned, we create a blank object. - if (!data && !preSets.responseExpected) { - data = [{}]; - } + let observable = this.http.post(siteUrl, JSON.stringify(ajaxData)).timeout(CoreConstants.wsTimeout); + return this.utils.observableToPromise(observable).then((data: any) => { + // Some moodle web services return null. If the responseExpected value is set then so long as no data + // is returned, we create a blank object. + if (!data && !preSets.responseExpected) { + data = [{}]; + } - // Check if error. Ajax layer should always return an object (if error) or an array (if success). - if (!data || typeof data != 'object') { - return rejectWithError(this.translate.instant('mm.core.serverconnection')); - } else if (data.error) { - return rejectWithError(data.error, data.errorcode); - } + // Check if error. Ajax layer should always return an object (if error) or an array (if success). + if (!data || typeof data != 'object') { + return rejectWithError(this.translate.instant('mm.core.serverconnection')); + } else if (data.error) { + return rejectWithError(data.error, data.errorcode); + } - // Get the first response since only one request was done. - data = data[0]; + // Get the first response since only one request was done. + data = data[0]; - if (data.error) { - return rejectWithError(data.exception.message, data.exception.errorcode); - } + if (data.error) { + return rejectWithError(data.exception.message, data.exception.errorcode); + } - return data.data; - }, (data) => { - let available = data.status == 404 ? -1 : 0; - return rejectWithError(this.translate.instant('mm.core.serverconnection'), '', available); - }); + return data.data; + }, (data) => { + let available = data.status == 404 ? -1 : 0; + return rejectWithError(this.translate.instant('mm.core.serverconnection'), '', available); }); // Convenience function to return an error. @@ -237,7 +229,7 @@ export class CoreWSProvider { * @param {boolean} [stripUnicode] If Unicode long chars need to be stripped. * @return {object} The cleaned object, with multilevel array and objects preserved. */ - protected convertValuesToString(data: object, stripUnicode?: boolean) : object { + convertValuesToString(data: object, stripUnicode?: boolean) : object { let result; if (!Array.isArray(data) && typeof data == 'object') { result = {}; @@ -435,10 +427,7 @@ export class CoreWSProvider { let promise = this.getPromiseHttp('head', url); if (!promise) { - promise = new Promise((resolve, reject) => { - this.http.head(url).timeout(CoreConstants.wsTimeout).subscribe(resolve, reject); - }); - + promise = this.utils.observableToPromise(this.http.head(url).timeout(CoreConstants.wsTimeout)); this.setPromiseHttp(promise, 'head', url); } @@ -455,58 +444,58 @@ export class CoreWSProvider { * @return {Promise} Promise resolved with the response data in success and rejected with CoreWSError if it fails. */ performPost(method: string, siteUrl: string, ajaxData: any, preSets: CoreWSPreSets) : Promise { - // Create the promise for the request. - let promise = new Promise((resolve, reject) => { + // Perform the post request. + let observable = this.http.post(siteUrl, ajaxData).timeout(CoreConstants.wsTimeout), + promise; - this.http.post(siteUrl, ajaxData).timeout(CoreConstants.wsTimeout).subscribe((data: any) => { - // Some moodle web services return null. - // If the responseExpected value is set to false, we create a blank object if the response is null. - if (!data && !preSets.responseExpected) { - data = {}; - } - - if (!data) { - return Promise.reject(this.createFakeWSError('mm.core.serverconnection', true)); - } else if (typeof data != preSets.typeExpected) { - this.logger.warn('Response of type "' + typeof data + `" received, expecting "${preSets.typeExpected}"`); - return Promise.reject(this.createFakeWSError('mm.core.errorinvalidresponse', true)); - } - - if (typeof data.exception !== 'undefined') { - return Promise.reject(data); - } - - if (typeof data.debuginfo != 'undefined') { - return Promise.reject(this.createFakeWSError('Error. ' + data.message)); - } - - return data; - }, (error) => { - // If server has heavy load, retry after some seconds. - if (error.status == 429) { - let retryPromise = this.addToRetryQueue(method, siteUrl, ajaxData, preSets); - - // Only process the queue one time. - if (this.retryTimeout == 0) { - this.retryTimeout = parseInt(error.headers('Retry-After'), 10) || 5; - this.logger.warn(`${error.statusText}. Retrying in ${this.retryTimeout} seconds. ` + - `${this.retryCalls.length} calls left.`); - - setTimeout(() => { - this.logger.warn(`Retrying now with ${this.retryCalls.length} calls to process.`); - // Finish timeout. - this.retryTimeout = 0; - this.processRetryQueue(); - }, this.retryTimeout * 1000); - } else { - this.logger.warn('Calls locked, trying later...'); - } - - return retryPromise; - } + promise = this.utils.observableToPromise(observable).then((data: any) => { + // Some moodle web services return null. + // If the responseExpected value is set to false, we create a blank object if the response is null. + if (!data && !preSets.responseExpected) { + data = {}; + } + if (!data) { return Promise.reject(this.createFakeWSError('mm.core.serverconnection', true)); - }); + } else if (typeof data != preSets.typeExpected) { + this.logger.warn('Response of type "' + typeof data + `" received, expecting "${preSets.typeExpected}"`); + return Promise.reject(this.createFakeWSError('mm.core.errorinvalidresponse', true)); + } + + if (typeof data.exception !== 'undefined') { + return Promise.reject(data); + } + + if (typeof data.debuginfo != 'undefined') { + return Promise.reject(this.createFakeWSError('Error. ' + data.message)); + } + + return data; + }, (error) => { + // If server has heavy load, retry after some seconds. + if (error.status == 429) { + let retryPromise = this.addToRetryQueue(method, siteUrl, ajaxData, preSets); + + // Only process the queue one time. + if (this.retryTimeout == 0) { + this.retryTimeout = parseInt(error.headers('Retry-After'), 10) || 5; + this.logger.warn(`${error.statusText}. Retrying in ${this.retryTimeout} seconds. ` + + `${this.retryCalls.length} calls left.`); + + setTimeout(() => { + this.logger.warn(`Retrying now with ${this.retryCalls.length} calls to process.`); + // Finish timeout. + this.retryTimeout = 0; + this.processRetryQueue(); + }, this.retryTimeout * 1000); + } else { + this.logger.warn('Calls locked, trying later...'); + } + + return retryPromise; + } + + return Promise.reject(this.createFakeWSError('mm.core.serverconnection', true)); }); this.setPromiseHttp(promise, 'post', preSets.siteUrl, ajaxData);