From 811bb3978136807078838864034ecff5be1eebbe Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 7 Oct 2020 10:52:51 +0200 Subject: [PATCH] MOBILE-3565 core: Migrate some core classes --- src/app/classes/delegate.ts | 352 ++++ src/app/classes/error.ts | 33 + src/app/classes/interceptor.ts | 77 + src/app/classes/native-to-angular-http.ts | 98 + src/app/classes/queue-runner.ts | 143 ++ src/app/classes/site.ts | 2057 +++++++++++++++++++++ src/app/classes/sqlitedb.ts | 1027 ++++++++++ src/app/singletons/core.singletons.ts | 56 +- tslint.json | 7 +- 9 files changed, 3846 insertions(+), 4 deletions(-) create mode 100644 src/app/classes/delegate.ts create mode 100644 src/app/classes/error.ts create mode 100644 src/app/classes/interceptor.ts create mode 100644 src/app/classes/native-to-angular-http.ts create mode 100644 src/app/classes/queue-runner.ts create mode 100644 src/app/classes/site.ts create mode 100644 src/app/classes/sqlitedb.ts diff --git a/src/app/classes/delegate.ts b/src/app/classes/delegate.ts new file mode 100644 index 000000000..464eca45b --- /dev/null +++ b/src/app/classes/delegate.ts @@ -0,0 +1,352 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { CoreSites } from '@services/sites'; +import { CoreEvents, CoreEventsProvider } from '@services/events'; +import { CoreSite } from '@classes/site'; +import { CoreLogger } from '@singletons/logger'; + +/** + * Superclass to help creating delegates + */ +export class CoreDelegate { + + /** + * Logger instance. + */ + protected logger: CoreLogger; + + /** + * List of registered handlers. + */ + protected handlers: { [s: string]: CoreDelegateHandler } = {}; + + /** + * List of registered handlers enabled for the current site. + */ + protected enabledHandlers: { [s: string]: CoreDelegateHandler } = {}; + + /** + * Default handler + */ + protected defaultHandler: CoreDelegateHandler; + + /** + * Time when last updateHandler functions started. + */ + protected lastUpdateHandlersStart: number; + + /** + * Feature prefix to check is feature is enabled or disabled in site. + * This check is only made if not false. Override on the subclass or override isFeatureDisabled function. + */ + protected featurePrefix: string; + + /** + * Name of the property to be used to index the handlers. By default, the handler's name will be used. + * If your delegate uses a Moodle component name to identify the handlers, please override this property. + * E.g. CoreCourseModuleDelegate uses 'modName' to index the handlers. + */ + protected handlerNameProperty = 'name'; + + /** + * Set of promises to update a handler, to prevent doing the same operation twice. + */ + protected updatePromises: {[siteId: string]: {[name: string]: Promise}} = {}; + + /** + * Whether handlers have been initialized. + */ + protected handlersInitialized = false; + + /** + * Promise to wait for handlers to be initialized. + */ + protected handlersInitPromise: Promise; + + /** + * Function to resolve the handlers init promise. + */ + protected handlersInitResolve: (value?: any) => void; + + /** + * Constructor of the Delegate. + * + * @param delegateName Delegate name used for logging purposes. + * @param listenSiteEvents Whether to update the handler when a site event occurs (login, site updated, ...). + */ + constructor(delegateName: string, listenSiteEvents?: boolean) { + this.logger = CoreLogger.getInstance(delegateName); + + this.handlersInitPromise = new Promise((resolve): void => { + this.handlersInitResolve = resolve; + }); + + if (listenSiteEvents) { + // Update handlers on this cases. + CoreEvents.instance.on(CoreEventsProvider.LOGIN, this.updateHandlers.bind(this)); + CoreEvents.instance.on(CoreEventsProvider.SITE_UPDATED, this.updateHandlers.bind(this)); + CoreEvents.instance.on(CoreEventsProvider.SITE_PLUGINS_LOADED, this.updateHandlers.bind(this)); + } + } + + /** + * Execute a certain function in a enabled handler. + * If the handler isn't found or function isn't defined, call the same function in the default handler. + * + * @param handlerName The handler name. + * @param fnName Name of the function to execute. + * @param params Parameters to pass to the function. + * @return Function returned value or default value. + */ + protected executeFunctionOnEnabled(handlerName: string, fnName: string, params?: any[]): any { + return this.execute(this.enabledHandlers[handlerName], fnName, params); + } + + /** + * Execute a certain function in a handler. + * If the handler isn't found or function isn't defined, call the same function in the default handler. + * + * @param handlerName The handler name. + * @param fnName Name of the function to execute. + * @param params Parameters to pass to the function. + * @return Function returned value or default value. + */ + protected executeFunction(handlerName: string, fnName: string, params?: any[]): any { + return this.execute(this.handlers[handlerName], fnName, params); + } + + /** + * Execute a certain function in a handler. + * If the handler isn't found or function isn't defined, call the same function in the default handler. + * + * @param handler The handler. + * @param fnName Name of the function to execute. + * @param params Parameters to pass to the function. + * @return Function returned value or default value. + */ + private execute(handler: any, fnName: string, params?: any[]): any { + if (handler && handler[fnName]) { + return handler[fnName].apply(handler, params); + } else if (this.defaultHandler && this.defaultHandler[fnName]) { + return this.defaultHandler[fnName].apply(this.defaultHandler, params); + } + } + + /** + * Get a handler. + * + * @param handlerName The handler name. + * @param enabled Only enabled, or any. + * @return Handler. + */ + protected getHandler(handlerName: string, enabled: boolean = false): CoreDelegateHandler { + return enabled ? this.enabledHandlers[handlerName] : this.handlers[handlerName]; + } + + /** + * Gets the handler full name for a given name. This is useful when the handlerNameProperty is different than "name". + * E.g. blocks are indexed by blockName. If you call this function passing the blockName it will return the name. + * + * @param name Name used to indentify the handler. + * @return Full name of corresponding handler. + */ + getHandlerName(name: string): string { + const handler = this.getHandler(name, true); + + if (!handler) { + return ''; + } + + return handler.name; + } + + /** + * Check if function exists on a handler. + * + * @param handlerName The handler name. + * @param fnName Name of the function to execute. + * @param onlyEnabled If check only enabled handlers or all. + * @return Function returned value or default value. + */ + protected hasFunction(handlerName: string, fnName: string, onlyEnabled: boolean = true): any { + const handler = onlyEnabled ? this.enabledHandlers[handlerName] : this.handlers[handlerName]; + + return handler && handler[fnName]; + } + + /** + * Check if a handler name has a registered handler (not necessarily enabled). + * + * @param name The handler name. + * @param enabled Only enabled, or any. + * @return If the handler is registered or not. + */ + hasHandler(name: string, enabled: boolean = false): boolean { + return enabled ? typeof this.enabledHandlers[name] !== 'undefined' : typeof this.handlers[name] !== 'undefined'; + } + + /** + * Check if a time belongs to the last update handlers call. + * This is to handle the cases where updateHandlers don't finish in the same order as they're called. + * + * @param time Time to check. + * @return Whether it's the last call. + */ + isLastUpdateCall(time: number): boolean { + if (!this.lastUpdateHandlersStart) { + return true; + } + + return time == this.lastUpdateHandlersStart; + } + + /** + * Register a handler. + * + * @param handler The handler delegate object to register. + * @return True when registered, false if already registered. + */ + registerHandler(handler: CoreDelegateHandler): boolean { + const key = handler[this.handlerNameProperty] || handler.name; + + if (typeof this.handlers[key] !== 'undefined') { + this.logger.log(`Handler '${handler[this.handlerNameProperty]}' already registered`); + + return false; + } + + this.logger.log(`Registered handler '${handler[this.handlerNameProperty]}'`); + this.handlers[key] = handler; + + return true; + } + + /** + * Update the handler for the current site. + * + * @param handler The handler to check. + * @param time Time this update process started. + * @return Resolved when done. + */ + protected updateHandler(handler: CoreDelegateHandler, time: number): Promise { + const siteId = CoreSites.instance.getCurrentSiteId(); + const currentSite = CoreSites.instance.getCurrentSite(); + let promise; + + if (this.updatePromises[siteId] && this.updatePromises[siteId][handler.name]) { + // There's already an update ongoing for this handler, return the promise. + return this.updatePromises[siteId][handler.name]; + } else if (!this.updatePromises[siteId]) { + this.updatePromises[siteId] = {}; + } + + if (!CoreSites.instance.isLoggedIn()) { + promise = Promise.reject(null); + } else if (this.isFeatureDisabled(handler, currentSite)) { + promise = Promise.resolve(false); + } else { + promise = Promise.resolve(handler.isEnabled()); + } + + // Checks if the handler is enabled. + this.updatePromises[siteId][handler.name] = promise.catch(() => { + return false; + }).then((enabled: boolean) => { + // Check that site hasn't changed since the check started. + if (CoreSites.instance.getCurrentSiteId() === siteId) { + const key = handler[this.handlerNameProperty] || handler.name; + + if (enabled) { + this.enabledHandlers[key] = handler; + } else { + delete this.enabledHandlers[key]; + } + } + }).finally(() => { + // Update finished, delete the promise. + delete this.updatePromises[siteId][handler.name]; + }); + + return this.updatePromises[siteId][handler.name]; + } + + /** + * Check if feature is enabled or disabled in the site, depending on the feature prefix and the handler name. + * + * @param handler Handler to check. + * @param site Site to check. + * @return Whether is enabled or disabled in site. + */ + protected isFeatureDisabled(handler: CoreDelegateHandler, site: CoreSite): boolean { + return typeof this.featurePrefix != 'undefined' && site.isFeatureDisabled(this.featurePrefix + handler.name); + } + + /** + * Update the handlers for the current site. + * + * @return Resolved when done. + */ + protected updateHandlers(): Promise { + const promises = [], + now = Date.now(); + + this.logger.debug('Updating handlers for current site.'); + + this.lastUpdateHandlersStart = now; + + // Loop over all the handlers. + for (const name in this.handlers) { + promises.push(this.updateHandler(this.handlers[name], now)); + } + + return Promise.all(promises).then(() => { + return true; + }, () => { + // Never reject. + return true; + }).then(() => { + + // Verify that this call is the last one that was started. + if (this.isLastUpdateCall(now)) { + this.handlersInitialized = true; + this.handlersInitResolve(); + + this.updateData(); + } + }); + } + + /** + * Update handlers Data. + * Override this function to update handlers data. + */ + updateData(): any { + // To be overridden. + } +} + +export interface CoreDelegateHandler { + /** + * Name of the handler, or name and sub context (AddonMessages, AddonMessages:blockContact, ...). + * This name will be used to check if the feature is disabled. + */ + name: string; + + /** + * Whether or not the handler is enabled on a site level. + * @return Whether or not the handler is enabled on a site level. + */ + isEnabled(): boolean | Promise; +} diff --git a/src/app/classes/error.ts b/src/app/classes/error.ts new file mode 100644 index 000000000..2bbb0b737 --- /dev/null +++ b/src/app/classes/error.ts @@ -0,0 +1,33 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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. + +/** + * Base Error class. + * + * The native Error class cannot be extended in Typescript without restoring the prototype chain, extend this + * class instead. + * + * @see https://stackoverflow.com/questions/41102060/typescript-extending-error-class + */ +export class CoreError extends Error { + + constructor(message?: string) { + super(message); + + // Fix prototype chain: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-2.html#support-for-newtarget + this.name = new.target.name; + Object.setPrototypeOf(this, new.target.prototype); + } + +} diff --git a/src/app/classes/interceptor.ts b/src/app/classes/interceptor.ts new file mode 100644 index 000000000..9877231c1 --- /dev/null +++ b/src/app/classes/interceptor.ts @@ -0,0 +1,77 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { HttpInterceptor, HttpHandler, HttpRequest } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +/** + * Interceptor for Http calls. Adds the header 'Content-Type'='application/x-www-form-urlencoded' + * and serializes the parameters if needed. + */ +@Injectable() +export class CoreInterceptor implements HttpInterceptor { + + /** + * Serialize an object to be used in a request. + * + * @param obj Object to serialize. + * @param addNull Add null values to the serialized as empty parameters. + * @return Serialization of the object. + */ + static serialize(obj: any, addNull?: boolean): string { + let query = ''; + let fullSubName; + let subValue; + let innerObj; + + for (const name in obj) { + const value = obj[name]; + + if (value instanceof Array) { + for (let i = 0; i < value.length; ++i) { + subValue = value[i]; + fullSubName = name + '[' + i + ']'; + innerObj = {}; + innerObj[fullSubName] = subValue; + query += this.serialize(innerObj) + '&'; + } + } else if (value instanceof Object) { + for (const subName in value) { + subValue = value[subName]; + fullSubName = name + '[' + subName + ']'; + innerObj = {}; + innerObj[fullSubName] = subValue; + query += this.serialize(innerObj) + '&'; + } + } else if (addNull || (typeof value != 'undefined' && value !== null)) { + query += encodeURIComponent(name) + '=' + encodeURIComponent(value) + '&'; + } + } + + return query.length ? query.substr(0, query.length - 1) : query; + } + + intercept(req: HttpRequest, next: HttpHandler): Observable { + // Add the header and serialize the body if needed. + const newReq = req.clone({ + headers: req.headers.set('Content-Type', 'application/x-www-form-urlencoded'), + body: typeof req.body == 'object' && String(req.body) != '[object File]' ? + CoreInterceptor.serialize(req.body) : req.body + }); + + // Pass on the cloned request instead of the original request. + return next.handle(newReq); + } +} diff --git a/src/app/classes/native-to-angular-http.ts b/src/app/classes/native-to-angular-http.ts new file mode 100644 index 000000000..e21e7eb7e --- /dev/null +++ b/src/app/classes/native-to-angular-http.ts @@ -0,0 +1,98 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { HttpResponse as AngularHttpResponse, HttpHeaders } from '@angular/common/http'; +import { HTTPResponse as NativeHttpResponse } from '@ionic-native/http'; + +const HTTP_STATUS_MESSAGES = { + 100: 'Continue', + 101: 'Switching Protocol', + 102: 'Processing', + 103: 'Early Hints', + 200: 'OK', + 201: 'Created', + 202: 'Accepted', + 203: 'Non-Authoritative Information', + 204: 'No Content', + 205: 'Reset Content', + 206: 'Partial Content', + 207: 'Multi-Status', + 208: 'Already Reported', + 226: 'IM Used', + 300: 'Multiple Choice', + 301: 'Moved Permanently', + 302: 'Found', + 303: 'See Other', + 304: 'Not Modified', + 305: 'Use Proxy', + 306: 'unused', + 307: 'Temporary Redirect', + 308: 'Permanent Redirect', + 400: 'Bad Request', + 401: 'Unauthorized', + 402: 'Payment Required', + 403: 'Forbidden', + 404: 'Not Found', + 405: 'Method Not Allowed', + 406: 'Not Acceptable', + 407: 'Proxy Authentication Required', + 408: 'Request Timeout', + 409: 'Conflict', + 410: 'Gone', + 411: 'Length Required', + 412: 'Precondition Failed', + 413: 'Payload Too Large', + 414: 'URI Too Long', + 415: 'Unsupported Media Type', + 416: 'Range Not Satisfiable', + 417: 'Expectation Failed', + 418: 'I\'m a teapot', + 421: 'Misdirected Request', + 422: 'Unprocessable Entity', + 423: 'Locked', + 424: 'Failed Dependency', + 425: 'Too Early', + 426: 'Upgrade Required', + 428: 'Precondition Required', + 429: 'Too Many Requests', + 431: 'Request Header Fields Too Large', + 451: 'Unavailable For Legal Reasons', + 500: 'Internal Server Error', + 501: 'Not Implemented', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Gateway Timeout', + 505: 'HTTP Version Not Supported', + 506: 'Variant Also Negotiates', + 507: 'Insufficient Storage', + 508: 'Loop Detected', + 510: 'Not Extended', + 511: 'Network Authentication Required', +}; + +/** + * Class that adapts a Cordova plugin http response to an Angular http response. + */ +export class CoreNativeToAngularHttpResponse extends AngularHttpResponse { + + constructor(protected nativeResponse: NativeHttpResponse) { + super({ + body: nativeResponse.data, + headers: new HttpHeaders(nativeResponse.headers), + status: nativeResponse.status, + statusText: HTTP_STATUS_MESSAGES[nativeResponse.status] || '', + url: nativeResponse.url || '' + }); + } +} diff --git a/src/app/classes/queue-runner.ts b/src/app/classes/queue-runner.ts new file mode 100644 index 000000000..fbc5d1d22 --- /dev/null +++ b/src/app/classes/queue-runner.ts @@ -0,0 +1,143 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { CoreUtils, PromiseDefer } from '@services/utils/utils'; + +/** + * Function to add to the queue. + */ +export type CoreQueueRunnerFunction = (...args: any[]) => T | Promise; + +/** + * Queue item. + */ +export type CoreQueueRunnerItem = { + /** + * Item ID. + */ + id: string; + + /** + * Function to execute. + */ + fn: CoreQueueRunnerFunction; + + /** + * Deferred with a promise resolved/rejected with the result of the function. + */ + deferred: PromiseDefer; +}; + +/** + * Options to pass to add item. + */ +export type CoreQueueRunnerAddOptions = { + /** + * Whether to allow having multiple entries with same ID in the queue. + */ + allowRepeated?: boolean; +}; + +/** + * A queue to prevent having too many concurrent executions. + */ +export class CoreQueueRunner { + protected queue: {[id: string]: CoreQueueRunnerItem} = {}; + protected orderedQueue: CoreQueueRunnerItem[] = []; + protected numberRunning = 0; + + constructor(protected maxParallel: number = 1) { } + + /** + * Get unique ID. + * + * @param id ID. + * @return Unique ID. + */ + protected getUniqueId(id: string): string { + let newId = id; + let num = 1; + + do { + newId = id + '-' + num; + num++; + } while (newId in this.queue); + + return newId; + } + + /** + * Process next item in the queue. + * + * @return Promise resolved when next item has been treated. + */ + protected async processNextItem(): Promise { + if (!this.orderedQueue.length || this.numberRunning >= this.maxParallel) { + // Queue is empty or max number of parallel runs reached, stop. + return; + } + + const item = this.orderedQueue.shift(); + this.numberRunning++; + + try { + const result = await item.fn(); + + item.deferred.resolve(result); + } catch (error) { + item.deferred.reject(error); + } finally { + delete this.queue[item.id]; + this.numberRunning--; + + this.processNextItem(); + } + } + + /** + * Add an item to the queue. + * + * @param id ID. + * @param fn Function to call. + * @param options Options. + * @return Promise resolved when the function has been executed. + */ + run(id: string, fn: CoreQueueRunnerFunction, options?: CoreQueueRunnerAddOptions): Promise { + options = options || {}; + + if (id in this.queue) { + if (!options.allowRepeated) { + // Item already in queue, return its promise. + return this.queue[id].deferred.promise; + } + + id = this.getUniqueId(id); + } + + // Add the item in the queue. + const item = { + id, + fn, + deferred: CoreUtils.instance.promiseDefer(), + }; + + this.queue[id] = item; + this.orderedQueue.push(item); + + // Process next item if we haven't reached the max yet. + this.processNextItem(); + + return item.deferred.promise; + } +} diff --git a/src/app/classes/site.ts b/src/app/classes/site.ts new file mode 100644 index 000000000..451107b40 --- /dev/null +++ b/src/app/classes/site.ts @@ -0,0 +1,2057 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { InAppBrowserObject } from '@ionic-native/in-app-browser'; +import { Md5 } from 'ts-md5/dist/md5'; + +import { SQLiteDB } from '@classes/sqlitedb'; +import { CoreApp } from '@services/app'; +import { CoreDB } from '@services/db'; +import { CoreEvents, CoreEventsProvider } from '@services/events'; +import { CoreFile } from '@services/file'; +import { CoreWS, CoreWSPreSets, CoreWSFileUploadOptions, CoreWSAjaxPreSets } from '@services/ws'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreUtils, PromiseDefer } from '@services/utils/utils'; +import { CoreConstants } from '@core/constants'; +import CoreConfigConstants from '@app/config.json'; +import { CoreLogger } from '@singletons/logger'; +import { Translate } from '@singletons/core.singletons'; + +/** + * Class that represents a site (combination of site + user). + * It will have all the site data and provide utility functions regarding a site. + * To add tables to the site's database, please use CoreSitesProvider.registerSiteSchema. This will make sure that + * the tables are created in all the sites, not just the current one. + */ +export class CoreSite { + static REQUEST_QUEUE_DELAY = 50; // Maximum number of miliseconds to wait before processing the queue. + static REQUEST_QUEUE_LIMIT = 10; // Maximum number of requests allowed in the queue. + static REQUEST_QUEUE_FORCE_WS = false; // Use "tool_mobile_call_external_functions" even for calling a single function. + + // Constants for cache update frequency. + static FREQUENCY_USUALLY = 0; + static FREQUENCY_OFTEN = 1; + static FREQUENCY_SOMETIMES = 2; + static FREQUENCY_RARELY = 3; + + // Variables for the database. + static WS_CACHE_TABLE = 'wscache_2'; + static CONFIG_TABLE = 'core_site_config'; + + static MINIMUM_MOODLE_VERSION = '3.1'; + + // Versions of Moodle releases. + protected MOODLE_RELEASES = { + 3.1: 2016052300, + 3.2: 2016120500, + 3.3: 2017051503, + 3.4: 2017111300, + 3.5: 2018051700, + 3.6: 2018120300, + 3.7: 2019052000 + }; + + // Possible cache update frequencies. + protected UPDATE_FREQUENCIES = [ + CoreConfigConstants.cache_update_frequency_usually || 420000, + CoreConfigConstants.cache_update_frequency_often || 1200000, + CoreConfigConstants.cache_update_frequency_sometimes || 3600000, + CoreConfigConstants.cache_update_frequency_rarely || 43200000 + ]; + + // Rest of variables. + protected logger: CoreLogger; + protected db: SQLiteDB; + protected cleanUnicode = false; + protected lastAutoLogin = 0; + protected offlineDisabled = false; + protected ongoingRequests: { [cacheId: string]: Promise } = {}; + protected requestQueue: RequestQueueItem[] = []; + protected requestQueueTimeout = null; + protected tokenPluginFileWorks: boolean; + protected tokenPluginFileWorksPromise: Promise; + protected oauthId: number; + + /** + * Create a site. + * + * @param id Site ID. + * @param siteUrl Site URL. + * @param token Site's WS token. + * @param info Site info. + * @param privateToken Private token. + * @param config Site public config. + * @param loggedOut Whether user is logged out. + */ + constructor(public id: string, public siteUrl: string, public token?: string, public infos?: any, + public privateToken?: string, public config?: any, public loggedOut?: boolean) { + this.logger = CoreLogger.getInstance('CoreWSProvider'); + this.setInfo(infos); + this.calculateOfflineDisabled(); + + if (this.id) { + this.initDB(); + } + } + + /** + * Initialize the database. + */ + initDB(): void { + this.db = CoreDB.instance.getDB('Site-' + this.id); + } + + /** + * Get site ID. + * + * @return Site ID. + */ + getId(): string { + return this.id; + } + + /** + * Get site URL. + * + * @return Site URL. + */ + getURL(): string { + return this.siteUrl; + } + + /** + * Get site token. + * + * @return Site token. + */ + getToken(): string { + return this.token; + } + + /** + * Get site info. + * + * @return Site info. + */ + getInfo(): any { + return this.infos; + } + + /** + * Get site private token. + * + * @return Site private token. + */ + getPrivateToken(): string { + return this.privateToken; + } + + /** + * Get site DB. + * + * @return Site DB. + */ + getDb(): SQLiteDB { + return this.db; + } + + /** + * Get site user's ID. + * + * @return 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 Site Home ID. + */ + getSiteHomeId(): number { + return this.infos && this.infos.siteid || 1; + } + + /** + * Get site name. + * + * @return Site name. + */ + getSiteName(): string { + if (CoreConfigConstants.sitename) { + // Overridden by config. + return CoreConfigConstants.sitename; + } else { + return this.infos && this.infos.sitename || ''; + } + } + + /** + * Set site ID. + * + * @param New ID. + */ + setId(id: string): void { + this.id = id; + this.initDB(); + } + + /** + * Set site token. + * + * @param New token. + */ + setToken(token: string): void { + this.token = token; + } + + /** + * Set site private token. + * + * @param 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 Whether is logged out. + */ + isLoggedOut(): boolean { + return !!this.loggedOut; + } + + /** + * Get OAuth ID. + * + * @return OAuth ID. + */ + getOAuthId(): number { + return this.oauthId; + } + + /** + * Set site info. + * + * @param New info. + */ + setInfo(infos: any): void { + this.infos = infos; + + // Index function by name to speed up wsAvailable method. + if (infos && infos.functions) { + infos.functionsByName = {}; + infos.functions.forEach((func) => { + infos.functionsByName[func.name] = func; + }); + } + } + + /** + * Set site config. + * + * @param config Config. + */ + setConfig(config: any): void { + if (config) { + config.tool_mobile_disabledfeatures = CoreTextUtils.instance.treatDisabledFeatures(config.tool_mobile_disabledfeatures); + } + + this.config = config; + this.calculateOfflineDisabled(); + } + + /** + * Set site logged out. + * + * @param loggedOut True if logged out and needs to authenticate again, false otherwise. + */ + setLoggedOut(loggedOut: boolean): void { + this.loggedOut = !!loggedOut; + } + + /** + * Set OAuth ID. + * + * @param oauth OAuth ID. + */ + setOAuthId(oauthId: number): void { + this.oauthId = oauthId; + } + + /** + * Check if the user authenticated in the site using an OAuth method. + * + * @return Whether the user authenticated in the site using an OAuth method. + */ + isOAuth(): boolean { + return this.oauthId != null && typeof this.oauthId != 'undefined'; + } + + /** + * Can the user access their private files? + * + * @return 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 Whether can download files. + */ + canDownloadFiles(): boolean { + const infos = this.getInfo(); + + return infos && infos.downloadfiles; + } + + /** + * Can the user use an advanced feature? + * + * @param feature The name of the feature. + * @param whenUndefined The value to return when the parameter is undefined. + * @return Whether can use advanced feature. + */ + canUseAdvancedFeature(feature: string, whenUndefined: boolean = true): boolean { + const infos = this.getInfo(); + let canUse = true; + + if (typeof infos.advancedfeatures === 'undefined') { + canUse = whenUndefined; + } else { + for (const i in infos.advancedfeatures) { + const item = infos.advancedfeatures[i]; + if (item.name === feature && parseInt(item.value, 10) === 0) { + canUse = false; + } + } + + } + + return canUse; + } + + /** + * Can the user upload files? + * + * @return Whether can upload files. + */ + canUploadFiles(): boolean { + const infos = this.getInfo(); + + return infos && infos.uploadfiles; + } + + /** + * Fetch site info from the Moodle site. + * + * @return A promise to be resolved when the site info is retrieved. + */ + fetchSiteInfo(): Promise { + // The get_site_info WS call won't be cached. + const preSets = { + getFromCache: false, + saveToCache: false, + skipQueue: true + }; + + // 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 method WS method to use. + * @param data Data to send to the WS. + * @param preSets Extra options. + * @return 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; + } + if (typeof preSets.reusePending == 'undefined') { + preSets.reusePending = true; + } + + return this.request(method, data, preSets); + } + + /** + * Sends some data to the Moodle site using WS. Requests are NOT cached by default. + * + * @param method WS method to use. + * @param data Data to send to the WS. + * @param preSets Extra options. + * @return 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 method The WebService method to be called. + * @param data Arguments to pass to the method. + * @param preSets Extra options. + * @param retrying True if we're retrying the call for some reason. This is to prevent infinite loops. + * @return 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 { + const initialToken = this.token; + data = data || {}; + + if (!CoreApp.instance.isOnline() && this.offlineDisabled) { + return Promise.reject(CoreWS.instance.createFakeWSError('core.errorofflinedisabled', true)); + } + + // 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.WS_PREFIX + 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(CoreUtils.instance.createFakeWSError('core.wsfunctionnotavailable', true)); + } + } + + const wsPreSets: CoreWSPreSets = { + wsToken: this.token, + siteUrl: this.siteUrl, + cleanUnicode: this.cleanUnicode, + typeExpected: preSets.typeExpected, + responseExpected: preSets.responseExpected + }; + + if (wsPreSets.cleanUnicode && CoreTextUtils.instance.hasUnicodeData(data)) { + // Data will be cleaned, notify the user. + CoreDomUtils.instance.showToast('core.unicodenotsupported', true, 3000); + } else { + // No need to clean data in this call. + wsPreSets.cleanUnicode = false; + } + + if (this.offlineDisabled) { + // Offline is disabled, don't use cache. + preSets.getFromCache = false; + preSets.saveToCache = false; + preSets.emergencyCache = false; + } + + // Enable text filtering by default. + data.moodlewssettingfilter = preSets.filter === false ? false : true; + data.moodlewssettingfileurl = preSets.rewriteurls === false ? false : true; + + const originalData = data; + + // Convert arguments to strings before starting the cache process. + data = CoreWS.instance.convertValuesToString(data, wsPreSets.cleanUnicode); + if (data == null) { + // Empty cleaned text found. + return Promise.reject(CoreUtils.instance.createFakeWSError('core.unicodenotsupportedcleanerror', true)); + } + + const cacheId = this.getCacheId(method, data); + + // Check for an ongoing identical request if we're not ignoring cache. + if (preSets.getFromCache && this.ongoingRequests[cacheId]) { + return this.ongoingRequests[cacheId].then((response) => { + // Clone the data, this may prevent errors if in the callback the object is modified. + return CoreUtils.instance.clone(response); + }); + } + + const promise = this.getFromCache(method, data, preSets, false, originalData).catch(() => { + if (preSets.forceOffline) { + // Don't call the WS, just fail. + return Promise.reject(CoreWS.instance.createFakeWSError('core.cannotconnect', true, + {$a: CoreSite.MINIMUM_MOODLE_VERSION})); + } + + // Call the WS. + return this.callOrEnqueueRequest(method, data, preSets, wsPreSets).then((response) => { + if (preSets.saveToCache) { + this.saveToCache(method, data, response, preSets); + } + + return 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. + preSets.getFromCache = false; // Don't check cache now. Also, it will skip ongoingRequests. + + return this.request(method, data, preSets, true); + } else if (CoreApp.instance.isSSOAuthenticationOngoing()) { + // There's an SSO authentication ongoing, wait for it to finish and try again. + return CoreApp.instance.waitForSSOAuthentication().then(() => { + return this.request(method, data, preSets, true); + }); + } + + // Session expired, trigger event. + CoreEvents.instance.trigger(CoreEventsProvider.SESSION_EXPIRED, {}, this.id); + // Change error message. Try to get data from cache, the event will handle the error. + error.message = Translate.instance.instant('core.lostconnection'); + } else if (error.errorcode === 'userdeleted') { + // User deleted, trigger event. + CoreEvents.instance.trigger(CoreEventsProvider.USER_DELETED, { params: data }, this.id); + error.message = Translate.instance.instant('core.userdeleted'); + + return Promise.reject(error); + } else if (error.errorcode === 'forcepasswordchangenotice') { + // Password Change Forced, trigger event. Try to get data from cache, the event will handle the error. + CoreEvents.instance.trigger(CoreEventsProvider.PASSWORD_CHANGE_FORCED, {}, this.id); + error.message = Translate.instance.instant('core.forcepasswordchangenotice'); + + } else if (error.errorcode === 'usernotfullysetup') { + // User not fully setup, trigger event. Try to get data from cache, the event will handle the error. + CoreEvents.instance.trigger(CoreEventsProvider.USER_NOT_FULLY_SETUP, {}, this.id); + error.message = Translate.instance.instant('core.usernotfullysetup'); + + } else if (error.errorcode === 'sitepolicynotagreed') { + // Site policy not agreed, trigger event. + CoreEvents.instance.trigger(CoreEventsProvider.SITE_POLICY_NOT_AGREED, {}, this.id); + error.message = Translate.instance.instant('core.login.sitepolicynotagreederror'); + + return Promise.reject(error); + } else if (error.errorcode === 'dmlwriteexception' && CoreTextUtils.instance.hasUnicodeData(data)) { + if (!this.cleanUnicode) { + // Try again cleaning unicode. + this.cleanUnicode = true; + + return this.request(method, data, preSets); + } + // This should not happen. + error.message = Translate.instance.instant('core.unicodenotsupported'); + + return Promise.reject(error); + } else if (error.exception === 'required_capability_exception' || error.errorcode === 'nopermission' || + error.errorcode === 'notingroup') { + // Translate error messages with missing strings. + if (error.message === 'error/nopermission') { + error.message = Translate.instance.instant('core.nopermissionerror'); + } else if (error.message === 'error/notingroup') { + error.message = Translate.instance.instant('core.notingroup'); + } + + // Save the error instead of deleting the cache entry so the same content is displayed in offline. + this.saveToCache(method, data, error, preSets); + + return Promise.reject(error); + } else if (preSets.cacheErrors && preSets.cacheErrors.indexOf(error.errorcode) != -1) { + // Save the error instead of deleting the cache entry so the same content is displayed in offline. + this.saveToCache(method, data, error, preSets); + + 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); + } + + if (preSets.deleteCacheIfWSError && CoreUtils.instance.isWebServiceError(error)) { + // Delete the cache entry and return the entry. Don't block the user with the delete. + this.deleteFromCache(method, data, preSets).catch(() => { + // Ignore errors. + }); + + 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, originalData).catch(() => { + return Promise.reject(error); + }); + }); + }).then((response) => { + // Check if the response is an error, this happens if the error was stored in the cache. + if (response && (typeof response.exception != 'undefined' || typeof response.errorcode != 'undefined')) { + return Promise.reject(response); + } + + return response; + }); + + this.ongoingRequests[cacheId] = promise; + + // Clear ongoing request after setting the promise (just in case it's already resolved). + return promise.finally(() => { + // Make sure we don't clear the promise of a newer request that ignores the cache. + if (this.ongoingRequests[cacheId] === promise) { + delete this.ongoingRequests[cacheId]; + } + }).then((response) => { + // We pass back a clone of the original object, this may prevent errors if in the callback the object is modified. + return CoreUtils.instance.clone(response); + }); + } + + /** + * Adds a request to the queue or calls it immediately when not using the queue. + * + * @param method The WebService method to be called. + * @param data Arguments to pass to the method. + * @param preSets Extra options related to the site. + * @param wsPreSets Extra options related to the WS call. + * @return Promise resolved with the response when the WS is called. + */ + protected callOrEnqueueRequest(method: string, data: any, preSets: CoreSiteWSPreSets, wsPreSets: CoreWSPreSets): Promise { + if (preSets.skipQueue || !this.wsAvailable('tool_mobile_call_external_functions')) { + return CoreWS.instance.call(method, data, wsPreSets); + } + + const cacheId = this.getCacheId(method, data); + + // Check if there is an identical request waiting in the queue (read requests only by default). + if (preSets.reusePending) { + const request = this.requestQueue.find((request) => request.cacheId == cacheId); + if (request) { + return request.deferred.promise; + } + } + + const request: RequestQueueItem = { + cacheId, + method, + data, + preSets, + wsPreSets, + deferred: CoreUtils.instance.promiseDefer(), + }; + + request.deferred.promise = new Promise((resolve, reject): void => { + request.deferred.resolve = resolve; + request.deferred.reject = reject; + }); + + return this.enqueueRequest(request); + } + + /** + * Adds a request to the queue. + * + * @param request The request to enqueue. + * @return Promise resolved with the response when the WS is called. + */ + protected enqueueRequest(request: RequestQueueItem): Promise { + + this.requestQueue.push(request); + + if (this.requestQueue.length >= CoreSite.REQUEST_QUEUE_LIMIT) { + this.processRequestQueue(); + } else if (!this.requestQueueTimeout) { + this.requestQueueTimeout = setTimeout(this.processRequestQueue.bind(this), CoreSite.REQUEST_QUEUE_DELAY); + } + + return request.deferred.promise; + } + + /** + * Call the enqueued web service requests. + */ + protected processRequestQueue(): void { + this.logger.debug(`Processing request queue (${this.requestQueue.length} requests)`); + + // Clear timeout if set. + if (this.requestQueueTimeout) { + clearTimeout(this.requestQueueTimeout); + this.requestQueueTimeout = null; + } + + // Extract all requests from the queue. + const requests = this.requestQueue; + this.requestQueue = []; + + if (requests.length == 1 && !CoreSite.REQUEST_QUEUE_FORCE_WS) { + // Only one request, do a regular web service call. + CoreWS.instance.call(requests[0].method, requests[0].data, requests[0].wsPreSets).then((data) => { + requests[0].deferred.resolve(data); + }).catch((error) => { + requests[0].deferred.reject(error); + }); + + return; + } + + const data = { + requests: requests.map((request) => { + const args = {}; + const settings = {}; + + // Separate WS settings from function arguments. + Object.keys(request.data).forEach((key) => { + let value = request.data[key]; + const match = /^moodlews(setting.*)$/.exec(key); + if (match) { + if (match[1] == 'settingfilter' || match[1] == 'settingfileurl') { + // Undo special treatment of these settings in CoreWSProvider.convertValuesToString. + value = (value == 'true' ? '1' : '0'); + } + settings[match[1]] = value; + } else { + args[key] = value; + } + }); + + return { + function: request.method, + arguments: JSON.stringify(args), + ...settings + }; + }) + }; + + const wsPresets: CoreWSPreSets = { + siteUrl: this.siteUrl, + wsToken: this.token, + }; + + CoreWS.instance.call('tool_mobile_call_external_functions', data, wsPresets).then((data) => { + if (!data || !data.responses) { + return Promise.reject(null); + } + + requests.forEach((request, i) => { + const response = data.responses[i]; + + if (!response) { + // Request not executed, enqueue again. + this.enqueueRequest(request); + } else if (response.error) { + request.deferred.reject(CoreTextUtils.instance.parseJSON(response.exception)); + } else { + let responseData = CoreTextUtils.instance.parseJSON(response.data); + // Match the behaviour of CoreWSProvider.call when no response is expected. + const responseExpected = typeof wsPresets.responseExpected == 'undefined' || wsPresets.responseExpected; + if (!responseExpected && (responseData == null || responseData === '')) { + responseData = {}; + } + request.deferred.resolve(responseData); + } + }); + + }).catch((error) => { + // Error not specific to a single request, reject all promises. + requests.forEach((request) => { + request.deferred.reject(error); + }); + }); + } + + /** + * Check if a WS is available in this site. + * + * @param method WS name. + * @param checkPrefix When true also checks with the compatibility prefix. + * @return Whether the WS is available. + */ + wsAvailable(method: string, checkPrefix: boolean = true): boolean { + if (typeof this.infos == 'undefined') { + return false; + } + + if (this.infos.functionsByName[method]) { + return true; + } + + // Let's try again with the compatibility prefix. + if (checkPrefix) { + return this.wsAvailable(CoreConstants.WS_PREFIX + method, false); + } + + return false; + } + + /** + * Get cache ID. + * + * @param method The WebService method. + * @param data Arguments to pass to the method. + * @return Cache ID. + */ + protected getCacheId(method: string, data: any): string { + return Md5.hashAsciiStr(method + ':' + CoreUtils.instance.sortAndStringify(data)); + } + + /** + * Get the cache ID used in Ionic 1 version of the app. + * + * @param method The WebService method. + * @param data Arguments to pass to the method. + * @return Cache ID. + */ + protected getCacheOldId(method: string, data: any): string { + return Md5.hashAsciiStr(method + ':' + JSON.stringify(data)); + } + + /** + * Get a WS response from cache. + * + * @param method The WebService method to be called. + * @param data Arguments to pass to the method. + * @param preSets Extra options. + * @param emergency Whether it's an "emergency" cache call (WS call failed). + * @param originalData Arguments to pass to the method before being converted to strings. + * @return Promise resolved with the WS response. + */ + protected getFromCache(method: string, data: any, preSets: CoreSiteWSPreSets, emergency?: boolean, originalData?: any) + : Promise { + if (!this.db || !preSets.getFromCache) { + return Promise.reject(null); + } + + const id = this.getCacheId(method, data); + let promise; + + if (preSets.getCacheUsingCacheKey || (emergency && preSets.getEmergencyCacheUsingCacheKey)) { + promise = this.db.getRecords(CoreSite.WS_CACHE_TABLE, { key: preSets.cacheKey }).then((entries) => { + if (!entries.length) { + // Cache key not found, get by params sent. + return this.db.getRecord(CoreSite.WS_CACHE_TABLE, { 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++) { + const entry = entries[i]; + if (entry.id == id) { + return entry; + } + } + } + + return entries[0]; + }); + } else { + promise = this.db.getRecord(CoreSite.WS_CACHE_TABLE, { id }).catch(() => { + // Entry not found, try to get it using the old ID. + const oldId = this.getCacheOldId(method, originalData || {}); + + return this.db.getRecord(CoreSite.WS_CACHE_TABLE, { id: oldId }).then((entry) => { + // Update the entry ID to use the new one. + this.db.updateRecords(CoreSite.WS_CACHE_TABLE, { id }, {id: oldId}); + + return entry; + }); + }); + } + + return promise.then((entry) => { + const now = Date.now(); + let expirationTime; + + preSets.omitExpires = preSets.omitExpires || preSets.forceOffline || !CoreApp.instance.isOnline(); + + if (!preSets.omitExpires) { + expirationTime = entry.expirationTime + this.getExpirationDelay(preSets.updateFrequency); + + if (now > expirationTime) { + this.logger.debug('Cached element found, but it is expired'); + + return Promise.reject(null); + } + } + + if (typeof entry != 'undefined' && typeof entry.data != 'undefined') { + if (!expirationTime) { + this.logger.info(`Cached element found, id: ${id}. Expiration time ignored.`); + } else { + const expires = (expirationTime - now) / 1000; + this.logger.info(`Cached element found, id: ${id}. Expires in expires in ${expires} seconds`); + } + + return CoreTextUtils.instance.parseJSON(entry.data, {}); + } + + return Promise.reject(null); + }); + } + + /** + * Gets the size of cached data for a specific component or component instance. + * + * @param component Component name + * @param componentId Optional component id (if not included, returns sum for whole component) + * @return Promise resolved when we have calculated the size + */ + getComponentCacheSize(component: string, componentId?: number): Promise { + const params: any[] = [component]; + let extraClause = ''; + if (componentId !== undefined && componentId !== null) { + params.push(componentId); + extraClause = ' AND componentId = ?'; + } + + return this.db.getFieldSql('SELECT SUM(length(data)) FROM ' + CoreSite.WS_CACHE_TABLE + + ' WHERE component = ?' + extraClause, params); + } + + /** + * Save a WS response to cache. + * + * @param method The WebService method. + * @param data Arguments to pass to the method. + * @param response The WS response. + * @param preSets Extra options. + * @return Promise resolved when the response is saved. + */ + protected saveToCache(method: string, data: any, response: any, preSets: CoreSiteWSPreSets): Promise { + if (!this.db) { + return Promise.reject(null); + } + + let promise; + + 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(() => { + // Since 3.7, the expiration time contains the time the entry is modified instead of the expiration time. + // We decided to reuse this field to prevent modifying the database table. + const id = this.getCacheId(method, data); + const entry: any = { + id, + data: JSON.stringify(response), + expirationTime: Date.now() + }; + + if (preSets.cacheKey) { + entry.key = preSets.cacheKey; + } + + if (preSets.component) { + entry.component = preSets.component; + if (preSets.componentId) { + entry.componentId = preSets.componentId; + } + } + + return this.db.insertRecord(CoreSite.WS_CACHE_TABLE, entry); + }); + } + + /** + * Delete a WS cache entry or entries. + * + * @param method The WebService method to be called. + * @param data Arguments to pass to the method. + * @param preSets Extra options. + * @param allCacheKey True to delete all entries with the cache key, false to delete only by ID. + * @return Promise resolved when the entries are deleted. + */ + protected deleteFromCache(method: string, data: any, preSets: CoreSiteWSPreSets, allCacheKey?: boolean): Promise { + if (!this.db) { + return Promise.reject(null); + } + + const id = this.getCacheId(method, data); + + if (allCacheKey) { + return this.db.deleteRecords(CoreSite.WS_CACHE_TABLE, { key: preSets.cacheKey }); + } + + return this.db.deleteRecords(CoreSite.WS_CACHE_TABLE, { id }); + } + + /** + * Deletes WS cache entries for all methods relating to a specific component (and + * optionally component id). + * + * @param component Component name. + * @param componentId Component id. + * @return Promise resolved when the entries are deleted. + */ + async deleteComponentFromCache(component: string, componentId?: number): Promise { + if (!component) { + return; + } + + if (!this.db) { + throw new Error('Site DB not initialized'); + } + + const params = { + component, + } as any; + if (componentId) { + params.componentId = componentId; + } + + return this.db.deleteRecords(CoreSite.WS_CACHE_TABLE, params); + } + + /* + * Uploads a file using Cordova File API. + * + * @param filePath File path. + * @param options File upload options. + * @param onProgress Function to call on progress. + * @return Promise resolved when uploaded. + */ + uploadFile(filePath: string, options: CoreWSFileUploadOptions, onProgress?: (event: ProgressEvent) => any): Promise { + if (!options.fileArea) { + options.fileArea = 'draft'; + } + + return CoreWS.instance.uploadFile(filePath, options, { + siteUrl: this.siteUrl, + wsToken: this.token + }, onProgress); + } + + /** + * Invalidates all the cache entries. + * + * @return 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(CoreSite.WS_CACHE_TABLE, { expirationTime: 0 }).finally(() => { + CoreEvents.instance.trigger(CoreEventsProvider.WS_CACHE_INVALIDATED, {}, this.getId()); + }); + } + + /** + * Invalidates all the cache entries with a certain key. + * + * @param key Key to search. + * @return 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(CoreSite.WS_CACHE_TABLE, { expirationTime: 0 }, { key }); + } + + /** + * Invalidates all the cache entries in an array of keys. + * + * @param keys Keys to search. + * @return 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(); + } + + const 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 key Key to search. + * @return 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); + + const sql = 'UPDATE ' + CoreSite.WS_CACHE_TABLE + ' SET expirationTime=0 WHERE key LIKE ?'; + + return this.db.execute(sql, [key + '%']); + } + + /** + * Check if tokenpluginfile can be used, and fix the URL afterwards. + * + * @param url The url to be fixed. + * @return Promise resolved with the fixed URL. + */ + checkAndFixPluginfileURL(url: string): Promise { + return this.checkTokenPluginFile(url).then(() => { + return this.fixPluginfileURL(url); + }); + } + + /** + * Generic function for adding the wstoken to Moodle urls and for pointing to the correct script. + * Uses CoreUtilsProvider.fixPluginfileURL, passing site's token. + * + * @param url The url to be fixed. + * @return Fixed URL. + */ + fixPluginfileURL(url: string): string { + const accessKey = this.tokenPluginFileWorks || typeof this.tokenPluginFileWorks == 'undefined' ? + this.infos && this.infos.userprivateaccesskey : undefined; + + return CoreUrlUtils.instance.fixPluginfileURL(url, this.token, this.siteUrl, accessKey); + } + + /** + * Deletes site's DB. + * + * @return Promise to be resolved when the DB is deleted. + */ + deleteDB(): Promise { + return CoreDB.instance.deleteDB('Site-' + this.id); + } + + /** + * Deletes site's folder. + * + * @return Promise to be resolved when the DB is deleted. + */ + deleteFolder(): Promise { + if (CoreFile.instance.isAvailable()) { + const siteFolder = CoreFile.instance.getSiteFolder(this.id); + + return CoreFile.instance.removeDir(siteFolder).catch(() => { + // Ignore any errors, CoreFileProvider.removeDir fails if folder doesn't exists. + }); + } else { + return Promise.resolve(); + } + } + + /** + * Get space usage of the site. + * + * @return Promise resolved with the site space usage (size). + */ + getSpaceUsage(): Promise { + if (CoreFile.instance.isAvailable()) { + const siteFolderPath = CoreFile.instance.getSiteFolder(this.id); + + return CoreFile.instance.getDirectorySize(siteFolderPath).catch(() => { + return 0; + }); + } else { + return Promise.resolve(0); + } + } + + /** + * Gets an approximation of the cache table usage of the site. + * + * Currently this is just the total length of the data fields in the cache table. + * + * @return Promise resolved with the total size of all data in the cache table (bytes) + */ + getCacheUsage(): Promise { + return this.db.getFieldSql('SELECT SUM(length(data)) FROM ' + CoreSite.WS_CACHE_TABLE); + } + + /** + * Gets a total of the file and cache usage. + * + * @return Promise with the total of getSpaceUsage and getCacheUsage + */ + async getTotalUsage(): Promise { + const space = await this.getSpaceUsage(); + const cache = await this.getCacheUsage(); + + return space + cache; + } + + /** + * Returns the URL to the documentation of the app, based on Moodle version and current language. + * + * @param page Docs page to go to. + * @return Promise resolved with the Moodle docs URL. + */ + getDocsUrl(page?: string): Promise { + const release = this.infos.release ? this.infos.release : undefined; + + return CoreUrlUtils.instance.getDocsUrl(release, page); + } + + /** + * Returns a url to link an specific page on the site. + * + * @param path Path of the url to go to. + * @param params Object with the params to add. + * @param anchor Anchor text if needed. + * @return URL with params. + */ + createSiteUrl(path: string, params?: {[key: string]: any}, anchor?: string): string { + return CoreUrlUtils.instance.addParamsToUrl(this.siteUrl + path, params, anchor); + } + + /** + * Check if the local_mobile plugin is installed in the Moodle site. + * + * @param retrying True if we're retrying the check. + * @return Promise resolved when the check is done. + */ + async checkLocalMobilePlugin(retrying?: boolean): Promise { + const checkUrl = this.siteUrl + '/local/mobile/check.php'; + const service = CoreConfigConstants.wsextservice; + + if (!service) { + // External service not defined. + return { code: 0 }; + } + + let data; + + try { + const response = await CoreWS.instance.sendHTTPRequest(checkUrl, { + method: 'post', + data: { service }, + }); + + data = response.body; + } catch (ex) { + return { code: 0 }; + } + + if (data === null) { + // This probably means that the server was configured to return null for non-existing URLs. Not installed. + return { code: 0 }; + } + + if (typeof data != 'undefined' && data.errorcode === 'requirecorrectaccess') { + if (!retrying) { + this.siteUrl = CoreUrlUtils.instance.addOrRemoveWWW(this.siteUrl); + + return this.checkLocalMobilePlugin(true); + } else { + throw data.error; + } + } else if (typeof data == 'undefined' || typeof data.code == 'undefined') { + // The local_mobile returned something we didn't expect. Let's assume it's not installed. + return { code: 0, warning: 'core.login.localmobileunexpectedresponse' }; + } + + const code = parseInt(data.code, 10); + if (data.error) { + switch (code) { + case 1: + // Site in maintenance mode. + throw Translate.instance.instant('core.login.siteinmaintenance'); + case 2: + // Web services not enabled. + throw Translate.instance.instant('core.login.webservicesnotenabled'); + case 3: + // Extended service not enabled, but the official is enabled. + return { code: 0 }; + case 4: + // Neither extended or official services enabled. + throw Translate.instance.instant('core.login.mobileservicesnotenabled'); + default: + throw Translate.instance.instant('core.unexpectederror'); + } + } else { + return { code, service, coreSupported: !!data.coresupported }; + } + } + + /** + * Check if local_mobile has been installed in Moodle. + * + * @return 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.WS_PREFIX) != -1) { + appUsesLocalMobile = true; + } + }); + + return appUsesLocalMobile; + } + + /** + * Check if local_mobile has been installed in Moodle but the app is not using it. + * + * @return 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') { + // The local_mobile NOT installed. Reject. + return Promise.reject(null); + } + + return data; + }); + } + + /** + * Check if a URL belongs to this site. + * + * @param url URL to check. + * @return Whether the URL belongs to this site. + */ + containsUrl(url: string): boolean { + if (!url) { + return false; + } + + const siteUrl = CoreTextUtils.instance.addEndingSlash(CoreUrlUtils.instance.removeProtocolAndWWW(this.siteUrl)); + url = CoreTextUtils.instance.addEndingSlash(CoreUrlUtils.instance.removeProtocolAndWWW(url)); + + return url.indexOf(siteUrl) == 0; + } + + /** + * Get the public config of this site. + * + * @return Promise resolved with public config. Rejected with an object if error, see CoreWSProvider.callAjax. + */ + getPublicConfig(): Promise { + const preSets: CoreWSAjaxPreSets = { + siteUrl: this.siteUrl + }; + + return CoreWS.instance.callAjax('tool_mobile_get_public_config', {}, preSets).catch((error) => { + + if ((!this.getInfo() || this.isVersionGreaterEqualThan('3.8')) && error && error.errorcode == 'codingerror') { + // This error probably means that there is a redirect in the site. Try to use a GET request. + preSets.noLogin = true; + preSets.useGet = true; + + return CoreWS.instance.callAjax('tool_mobile_get_public_config', {}, preSets).catch((error2) => { + if (this.getInfo() && this.isVersionGreaterEqualThan('3.8')) { + // GET is supported, return the second error. + return Promise.reject(error2); + } else { + // GET not supported or we don't know if it's supported. Return first error. + return Promise.reject(error); + } + }); + } + + return Promise.reject(error); + }).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 url The URL to open. + * @param alertMessage If defined, an alert will be shown before opening the browser. + * @return 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 url The URL to open. + * @param alertMessage If defined, an alert will be shown before opening the browser. + * @return 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 url The URL to open. + * @param options Override default options passed to InAppBrowser. + * @param alertMessage If defined, an alert will be shown before opening the inappbrowser. + * @return Promise resolved when done. + */ + 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 url The URL to open. + * @param options Override default options passed to inappbrowser. + * @param alertMessage If defined, an alert will be shown before opening the inappbrowser. + * @return Promise resolved when done. + */ + 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 inApp True to open it in InAppBrowser, false to open in browser. + * @param url The URL to open. + * @param options Override default options passed to $cordovaInAppBrowser#open. + * @param alertMessage If defined, an alert will be shown before opening the browser/inappbrowser. + * @return Promise resolved when done. Resolve param is returned only if inApp=true. + */ + openWithAutoLogin(inApp: boolean, url: string, options?: any, alertMessage?: string): Promise { + // Get the URL to open. + return this.getAutoLoginUrl(url).then((url) => { + if (!alertMessage) { + // Just open the URL. + if (inApp) { + return CoreUtils.instance.openInApp(url, options); + } else { + return CoreUtils.instance.openInBrowser(url); + } + } + + // Show an alert first. + return CoreDomUtils.instance.showAlert(Translate.instance.instant('core.notice'), alertMessage, undefined, 3000) + .then((alert) => { + + return new Promise((resolve, reject): void => { + const subscription = alert.didDismiss.subscribe(() => { + subscription && subscription.unsubscribe(); + + if (inApp) { + resolve(CoreUtils.instance.openInApp(url, options)); + } else { + resolve(CoreUtils.instance.openInBrowser(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 inApp True to open it in InAppBrowser, false to open in browser. + * @param url The URL to open. + * @param options Override default options passed to inappbrowser. + * @param alertMessage If defined, an alert will be shown before opening the browser/inappbrowser. + * @return Promise resolved when done. Resolve param is returned only if inApp=true. + */ + openWithAutoLoginIfSameSite(inApp: boolean, url: string, options?: any, alertMessage?: string) + : Promise { + if (this.containsUrl(url)) { + return this.openWithAutoLogin(inApp, url, options, alertMessage); + } else { + if (inApp) { + CoreUtils.instance.openInApp(url, options); + } else { + CoreUtils.instance.openInBrowser(url); + } + + return Promise.resolve(null); + } + } + + /** + * Get the config of this site. + * It is recommended to use getStoredConfig instead since it's faster and doesn't use network. + * + * @param name Name of the setting to get. If not set or false, all settings will be returned. + * @param ignoreCache True if it should ignore cached data. + * @return Promise resolved with site config. + */ + getConfig(name?: string, ignoreCache?: boolean): Promise { + const 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 (const 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. + const settings = {}; + config.settings.forEach((setting) => { + settings[setting.name] = setting.value; + }); + + return settings; + } + }); + } + + /** + * Invalidates config WS call. + * + * @return Promise resolved when the data is invalidated. + */ + invalidateConfig(): Promise { + return this.invalidateWsCacheForKey(this.getConfigCacheKey()); + } + + /** + * Get cache key for getConfig WS calls. + * + * @return Cache key. + */ + protected getConfigCacheKey(): string { + return 'tool_mobile_get_config'; + } + + /** + * Get the stored config of this site. + * + * @param name Name of the setting to get. If not set, all settings will be returned. + * @return 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 name Name of the feature to check. + * @return Whether it's disabled. + */ + isFeatureDisabled(name: string): boolean { + const disabledFeatures = this.getStoredConfig('tool_mobile_disabledfeatures'); + if (!disabledFeatures) { + return false; + } + + const regEx = new RegExp('(,|^)' + CoreTextUtils.instance.escapeForRegex(name) + '(,|$)', 'g'); + + return !!disabledFeatures.match(regEx); + } + + /** + * Calculate if offline is disabled in the site. + */ + calculateOfflineDisabled(): void { + this.offlineDisabled = this.isFeatureDisabled('NoDelegate_CoreOffline'); + } + + /** + * Get whether offline is disabled in the site. + * + * @return Whether it's disabled. + */ + isOfflineDisabled(): boolean { + return this.offlineDisabled; + } + + /** + * 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 versions Version or list of versions to check. + * @return 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++) { + const 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; + } + + /** + * Given a URL, convert it to a URL that will auto-login if supported. + * + * @param url The URL to convert. + * @param showModal Whether to show a loading modal. + * @return Promise resolved with the converted URL. + */ + getAutoLoginUrl(url: string, showModal: boolean = true): Promise { + + if (!this.privateToken || !this.wsAvailable('tool_mobile_get_autologin_key') || (this.lastAutoLogin && + CoreTimeUtils.instance.timestamp() - this.lastAutoLogin < CoreConstants.SECONDS_MINUTE * 6)) { + // No private token, WS not available or last auto-login was less than 6 minutes ago. Don't change the URL. + + return Promise.resolve(url); + } + + const userId = this.getUserId(); + const params = { + privatetoken: this.privateToken, + }; + let modal; + + if (showModal) { + modal = CoreDomUtils.instance.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, return the same URL. + return url; + } + + this.lastAutoLogin = CoreTimeUtils.instance.timestamp(); + + return data.autologinurl + '?userid=' + userId + '&key=' + data.key + '&urltogo=' + encodeURIComponent(url); + }).catch(() => { + + // Couldn't get autologin key, return the same URL. + return url; + }).finally(() => { + modal && modal.dismiss(); + }); + } + + /** + * 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 version Release version to convert to version number. + * @return Version number, 0 if invalid. + */ + protected getVersionNumber(version: string): number { + const data = this.getMajorAndMinor(version); + + if (!data) { + // Invalid version. + return 0; + } + + if (typeof this.MOODLE_RELEASES[data.major] == 'undefined') { + // Major version not found. Use the last one. + data.major = Object.keys(this.MOODLE_RELEASES).slice(-1); + } + + return this.MOODLE_RELEASES[data.major] + data.minor; + } + + /** + * Given a release version, return the major and minor versions. + * + * @param version Release version (e.g. '3.1.0'). + * @return Object with major and minor. Returns false if invalid version. + */ + protected getMajorAndMinor(version: string): 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], 10) || 0 + }; + } + + /** + * Given a release version, return the next major version number. + * + * @param version Release version (e.g. '3.1.0'). + * @return Next major version number. + */ + protected getNextMajorVersionNumber(version: string): number { + const data = this.getMajorAndMinor(version), + releases = Object.keys(this.MOODLE_RELEASES); + let position; + + 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.MOODLE_RELEASES[releases[position]]; + } + + return this.MOODLE_RELEASES[releases[position + 1]]; + } + + /** + * Deletes a site setting. + * + * @param name The config name. + * @return Promise resolved when done. + */ + deleteSiteConfig(name: string): Promise { + return this.db.deleteRecords(CoreSite.CONFIG_TABLE, { name }); + } + + /** + * Get a site setting on local device. + * + * @param name The config name. + * @param defaultValue Default value to use if the entry is not found. + * @return Resolves upon success along with the config data. Reject on failure. + */ + getLocalSiteConfig(name: string, defaultValue?: any): Promise { + return this.db.getRecord(CoreSite.CONFIG_TABLE, { name }).then((entry) => { + return entry.value; + }).catch((error) => { + if (typeof defaultValue != 'undefined') { + return defaultValue; + } + + return Promise.reject(error); + }); + } + + /** + * Set a site setting on local device. + * + * @param name The config name. + * @param value The config value. Can only store number or strings. + * @return Promise resolved when done. + */ + setLocalSiteConfig(name: string, value: number | string): Promise { + return this.db.insertRecord(CoreSite.CONFIG_TABLE, { name, value }); + } + + /** + * Get a certain cache expiration delay. + * + * @param updateFrequency The update frequency of the entry. + * @return Expiration delay. + */ + getExpirationDelay(updateFrequency?: number): number { + let expirationDelay = this.UPDATE_FREQUENCIES[updateFrequency] || this.UPDATE_FREQUENCIES[CoreSite.FREQUENCY_USUALLY]; + + if (CoreApp.instance.isNetworkAccessLimited()) { + // Not WiFi, increase the expiration delay a 50% to decrease the data usage in this case. + expirationDelay *= 1.5; + } + + return expirationDelay; + } + + /* + * Check if tokenpluginfile script works in the site. + * + * @param url URL to check. + * @return Promise resolved with boolean: whether it works or not. + */ + checkTokenPluginFile(url: string): Promise { + if (!CoreUrlUtils.instance.canUseTokenPluginFile(url, this.siteUrl, this.infos && this.infos.userprivateaccesskey)) { + // Cannot use tokenpluginfile. + return Promise.resolve(false); + } else if (typeof this.tokenPluginFileWorks != 'undefined') { + // Already checked. + return Promise.resolve(this.tokenPluginFileWorks); + } else if (this.tokenPluginFileWorksPromise) { + // Check ongoing, use the same promise. + return this.tokenPluginFileWorksPromise; + } else if (!CoreApp.instance.isOnline()) { + // Not online, cannot check it. Assume it's working, but don't save the result. + return Promise.resolve(true); + } + + url = this.fixPluginfileURL(url); + + this.tokenPluginFileWorksPromise = CoreWS.instance.performHead(url).then((result) => { + return result.status >= 200 && result.status < 300; + }).catch((error) => { + // Error performing head request. + return false; + }).then((result) => { + this.tokenPluginFileWorks = result; + + return result; + }); + + return this.tokenPluginFileWorksPromise; + } +} + +/** + * PreSets accepted by the WS call. + */ +export type CoreSiteWSPreSets = { + /** + * Get the value from the cache if it's still valid. + */ + getFromCache?: boolean; + + /** + * Save the result to the cache. + */ + saveToCache?: boolean; + + /** + * Ignore cache expiration. + */ + omitExpires?: boolean; + + /** + * Use the cache when a request fails. Defaults to true. + */ + emergencyCache?: boolean; + + /** + * If true, the app won't call the WS. If the data isn't cached, the call will fail. + */ + forceOffline?: boolean; + + /** + * Extra key to add to the cache when storing this call, to identify the entry. + */ + cacheKey?: string; + + /** + * Whether it should use cache key to retrieve the cached data instead of the request params. + */ + getCacheUsingCacheKey?: boolean; + + /** + * Same as getCacheUsingCacheKey, but for emergency cache. + */ + getEmergencyCacheUsingCacheKey?: boolean; + + /** + * If true, the cache entry will be deleted if the WS call returns an exception. + */ + deleteCacheIfWSError?: boolean; + + /** + * Whether it should only be 1 entry for this cache key (all entries with same key will be deleted). + */ + uniqueCacheKey?: boolean; + + /** + * Whether to filter WS response (moodlewssettingfilter). Defaults to true. + */ + filter?: boolean; + + /** + * Whether to rewrite URLs (moodlewssettingfileurl). Defaults to true. + */ + rewriteurls?: boolean; + + /** + * Defaults to true. Set to false when the expected response is null. + */ + responseExpected?: boolean; + + /** + * Defaults to 'object'. Use it when you expect a type that's not an object|array. + */ + typeExpected?: string; + + /** + * Wehther a pending request in the queue matching the same function and arguments can be reused instead of adding + * a new request to the queue. Defaults to true for read requests. + */ + reusePending?: boolean; + + /** + * Whether the request will be be sent immediately as a single request. Defaults to false. + */ + skipQueue?: boolean; + + /** + * Cache the response if it returns an errorcode present in this list. + */ + cacheErrors?: string[]; + + /** + * Update frequency. This value determines how often the cached data will be updated. Possible values: + * CoreSite.FREQUENCY_USUALLY, CoreSite.FREQUENCY_OFTEN, CoreSite.FREQUENCY_SOMETIMES, CoreSite.FREQUENCY_RARELY. + * Defaults to CoreSite.FREQUENCY_USUALLY. + */ + updateFrequency?: number; + + /** + * Component name. Optionally included if this request is being made on behalf of a specific + * component (e.g. activity). + */ + component?: string; + + /** + * Component id. Optionally included when 'component' is set. + */ + componentId?: number; +}; + +/** + * Response of checking local_mobile status. + */ +export type LocalMobileResponse = { + /** + * Code to identify the authentication method to use. + */ + code: number; + + /** + * Name of the service to use. + */ + service?: string; + + /** + * Code of the warning message. + */ + warning?: string; + + /** + * Whether core SSO is supported. + */ + coreSupported?: boolean; +}; + +/** + * Info of a request waiting in the queue. + */ +type RequestQueueItem = { + cacheId: string; + method: string; + data: any; + preSets: CoreSiteWSPreSets; + wsPreSets: CoreWSPreSets; + deferred: PromiseDefer; +}; diff --git a/src/app/classes/sqlitedb.ts b/src/app/classes/sqlitedb.ts new file mode 100644 index 000000000..c7aadbefc --- /dev/null +++ b/src/app/classes/sqlitedb.ts @@ -0,0 +1,1027 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// 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 { SQLiteObject } from '@ionic-native/sqlite/ngx'; + +import { SQLite, Platform } from '@singletons/core.singletons'; + +/** + * Schema of a table. + */ +export interface SQLiteDBTableSchema { + /** + * The table name. + */ + name: string; + + /** + * The columns to create in the table. + */ + columns: SQLiteDBColumnSchema[]; + + /** + * Names of columns that are primary key. Use it for compound primary keys. + */ + primaryKeys?: string[]; + + /** + * List of sets of unique columns. E.g: [['section', 'title'], ['author', 'title']]. + */ + uniqueKeys?: string[][]; + + /** + * List of foreign keys. + */ + foreignKeys?: SQLiteDBForeignKeySchema[]; + + /** + * Check constraint for the table. + */ + tableCheck?: string; +} + +/** + * Schema of a column. + */ +export interface SQLiteDBColumnSchema { + /** + * Column's name. + */ + name: string; + + /** + * Column's type. + */ + type?: 'INTEGER' | 'REAL' | 'TEXT' | 'BLOB'; + + /** + * Whether the column is a primary key. Use it only if primary key is a single column. + */ + primaryKey?: boolean; + + /** + * Whether it should be autoincremented. Only if primaryKey is true. + */ + autoIncrement?: boolean; + + /** + * True if column shouldn't be null. + */ + notNull?: boolean; + + /** + * WWhether the column is unique. + */ + unique?: boolean; + + /** + * Check constraint for the column. + */ + check?: string; + + /** + * Default value for the column. + */ + default?: string; +} + +/** + * Schema of a foreign key. + */ +export interface SQLiteDBForeignKeySchema { + /** + * Columns to include in this foreign key. + */ + columns: string[]; + + /** + * The external table referenced by this key. + */ + table: string; + + /** + * List of referenced columns from the referenced table. + */ + foreignColumns?: string[]; + + /** + * Text with the actions to apply to the foreign key. + */ + actions?: string; +} + +/** + * Class to interact with the local database. + * + * @description + * This class allows creating and interacting with a SQLite database. + * + * You need to supply some dependencies when creating the instance: + * this.db = new SQLiteDB('MyDB'); + */ +export class SQLiteDB { + db: SQLiteObject; + promise: Promise; + + /** + * Create and open the database. + * + * @param name Database name. + */ + constructor(public name: string) { + this.init(); + } + + /** + * Helper function to create a table if it doesn't exist. + * + * @param name The table name. + * @param columns The columns to create in the table. + * @param primaryKeys Names of columns that are primary key. Use it for compound primary keys. + * @param uniqueKeys List of sets of unique columns. E.g: [['section', 'title'], ['author', 'title']]. + * @param foreignKeys List of foreign keys. + * @param tableCheck Check constraint for the table. + * @return SQL query. + */ + buildCreateTableSql(name: string, columns: SQLiteDBColumnSchema[], primaryKeys?: string[], uniqueKeys?: string[][], + foreignKeys?: SQLiteDBForeignKeySchema[], tableCheck?: string): string { + const columnsSql = []; + let sql = `CREATE TABLE IF NOT EXISTS ${name} (`; + + // First define all the columns. + for (const index in columns) { + const column = columns[index]; + let columnSql: string = column.name || ''; + + if (column.type) { + columnSql += ' ' + column.type; + } + + if (column.primaryKey) { + columnSql += ' PRIMARY KEY'; + if (column.autoIncrement) { + columnSql += ' AUTOINCREMENT'; + } + } + + if (column.notNull) { + columnSql += ' NOT NULL'; + } + + if (column.unique) { + columnSql += ' UNIQUE'; + } + + if (column.check) { + columnSql += ` CHECK (${column.check})`; + } + + if (typeof column.default != 'undefined') { + columnSql += ` DEFAULT ${column.default}`; + } + + columnsSql.push(columnSql); + } + sql += columnsSql.join(', '); + + // Now add the table constraints. + + if (primaryKeys && primaryKeys.length) { + sql += `, PRIMARY KEY (${primaryKeys.join(', ')})`; + } + + if (uniqueKeys && uniqueKeys.length) { + for (const index in uniqueKeys) { + const setOfKeys = uniqueKeys[index]; + if (setOfKeys && setOfKeys.length) { + sql += `, UNIQUE (${setOfKeys.join(', ')})`; + } + } + } + + if (tableCheck) { + sql += `, CHECK (${tableCheck})`; + } + + for (const index in foreignKeys) { + const foreignKey = foreignKeys[index]; + + if (!foreignKey.columns || !!foreignKey.columns.length) { + return; + } + + sql += `, FOREIGN KEY (${foreignKey.columns.join(', ')}) REFERENCES ${foreignKey.table} `; + + if (foreignKey.foreignColumns && foreignKey.foreignColumns.length) { + sql += `(${foreignKey.foreignColumns.join(', ')})`; + } + + if (foreignKey.actions) { + sql += ` ${foreignKey.actions}`; + } + } + + return sql + ')'; + } + + /** + * Close the database. + * + * @return Promise resolved when done. + */ + async close(): Promise { + await this.ready(); + + return this.db.close(); + } + + /** + * Count the records in a table where all the given conditions met. + * + * @param table The table to query. + * @param conditions The conditions to build the where clause. Must not contain numeric indexes. + * @return Promise resolved with the count of records returned from the specified criteria. + */ + countRecords(table: string, conditions?: object): Promise { + const selectAndParams = this.whereClause(conditions); + + return this.countRecordsSelect(table, selectAndParams[0], selectAndParams[1]); + } + + /** + * Count the records in a table which match a particular WHERE clause. + * + * @param table The table to query. + * @param select A fragment of SQL to be used in a where clause in the SQL call. + * @param params An array of sql parameters. + * @param countItem The count string to be used in the SQL call. Default is COUNT('x'). + * @return Promise resolved with the count of records returned from the specified criteria. + */ + countRecordsSelect(table: string, select: string = '', params?: any, countItem: string = 'COUNT(\'x\')'): Promise { + if (select) { + select = 'WHERE ' + select; + } + + return this.countRecordsSql(`SELECT ${countItem} FROM ${table} ${select}`, params); + } + + /** + * Get the result of a SQL SELECT COUNT(...) query. + * + * Given a query that counts rows, return that count. + * + * @param sql The SQL string you wish to be executed. + * @param params An array of sql parameters. + * @return Promise resolved with the count. + */ + countRecordsSql(sql: string, params?: any): Promise { + return this.getFieldSql(sql, params).then((count) => { + if (typeof count != 'number' || count < 0) { + return 0; + } + + return count; + }); + } + + /** + * Create a table if it doesn't exist. + * + * @param name The table name. + * @param columns The columns to create in the table. + * @param primaryKeys Names of columns that are primary key. Use it for compound primary keys. + * @param uniqueKeys List of sets of unique columns. E.g: [['section', 'title'], ['author', 'title']]. + * @param foreignKeys List of foreign keys. + * @param tableCheck Check constraint for the table. + * @return Promise resolved when success. + */ + createTable(name: string, columns: SQLiteDBColumnSchema[], primaryKeys?: string[], uniqueKeys?: string[][], + foreignKeys?: SQLiteDBForeignKeySchema[], tableCheck?: string): Promise { + const sql = this.buildCreateTableSql(name, columns, primaryKeys, uniqueKeys, foreignKeys, tableCheck); + + return this.execute(sql); + } + + /** + * Create a table if it doesn't exist from a schema. + * + * @param table Table schema. + * @return Promise resolved when success. + */ + createTableFromSchema(table: SQLiteDBTableSchema): Promise { + return this.createTable(table.name, table.columns, table.primaryKeys, table.uniqueKeys, + table.foreignKeys, table.tableCheck); + } + + /** + * Create several tables if they don't exist from a list of schemas. + * + * @param tables List of table schema. + * @return Promise resolved when success. + */ + createTablesFromSchema(tables: SQLiteDBTableSchema[]): Promise { + const promises = []; + tables.forEach((table) => { + promises.push(this.createTableFromSchema(table)); + }); + + return Promise.all(promises); + } + + /** + * Delete the records from a table where all the given conditions met. + * If conditions not specified, table is truncated. + * + * @param table The table to delete from. + * @param conditions The conditions to build the where clause. Must not contain numeric indexes. + * @return Promise resolved when done. + */ + deleteRecords(table: string, conditions?: object): Promise { + if (conditions === null || typeof conditions == 'undefined') { + // No conditions, delete the whole table. + return this.execute(`DELETE FROM ${table}`); + } + + const selectAndParams = this.whereClause(conditions); + + return this.deleteRecordsSelect(table, selectAndParams[0], selectAndParams[1]); + } + + /** + * Delete the records from a table where one field match one list of values. + * + * @param table The table to delete from. + * @param field The name of a field. + * @param values The values field might take. + * @return Promise resolved when done. + */ + deleteRecordsList(table: string, field: string, values: any[]): Promise { + const selectAndParams = this.whereClauseList(field, values); + + return this.deleteRecordsSelect(table, selectAndParams[0], selectAndParams[1]); + } + + /** + * Delete one or more records from a table which match a particular WHERE clause. + * + * @param table The table to delete from. + * @param select A fragment of SQL to be used in a where clause in the SQL call. + * @param params Array of sql parameters. + * @return Promise resolved when done. + */ + deleteRecordsSelect(table: string, select: string = '', params?: any[]): Promise { + if (select) { + select = 'WHERE ' + select; + } + + return this.execute(`DELETE FROM ${table} ${select}`, params); + } + + /** + * Drop a table if it exists. + * + * @param name The table name. + * @return Promise resolved when success. + */ + dropTable(name: string): Promise { + return this.execute(`DROP TABLE IF EXISTS ${name}`); + } + + /** + * 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 sql SQL query to execute. + * @param params Query parameters. + * @return Promise resolved with the result. + */ + async execute(sql: string, params?: any[]): Promise { + await this.ready(); + + return this.db.executeSql(sql, params); + } + + /** + * 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 sqlStatements SQL statements to execute. + * @return Promise resolved with the result. + */ + async executeBatch(sqlStatements: any[]): Promise { + await this.ready(); + + return this.db.sqlBatch(sqlStatements); + } + + /** + * Format the data to insert in the database. Removes undefined entries so they are stored as null instead of 'undefined'. + * + * @param data Data to insert. + */ + protected formatDataToInsert(data: object): void { + if (!data) { + return; + } + + // Remove undefined entries and convert null to "NULL". + for (const name in data) { + const value = data[name]; + if (typeof value == 'undefined') { + delete data[name]; + } + } + } + + /** + * Get all the records from a table. + * + * @param table The table to query. + * @return 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. + * + * @param table The table to query. + * @param field The field to return the value of. + * @param conditions The conditions to build the where clause. Must not contain numeric indexes. + * @return Promise resolved with the field's value. + */ + getField(table: string, field: string, conditions?: object): Promise { + const selectAndParams = this.whereClause(conditions); + + return this.getFieldSelect(table, field, selectAndParams[0], selectAndParams[1]); + } + + /** + * Get a single field value from a table record which match a particular WHERE clause. + * + * @param table The table to query. + * @param field The field to return the value of. + * @param select A fragment of SQL to be used in a where clause returning one row with one column. + * @param params Array of sql parameters. + * @return Promise resolved with the field's value. + */ + getFieldSelect(table: string, field: string, select: string = '', params?: any[]): Promise { + if (select) { + select = 'WHERE ' + select; + } + + return this.getFieldSql(`SELECT ${field} FROM ${table} ${select}`, params); + } + + /** + * Get a single field value (first field) using a SQL statement. + * + * @param sql The SQL query returning one row with one column. + * @param params An array of sql parameters. + * @return Promise resolved with the field's value. + */ + async getFieldSql(sql: string, params?: any[]): Promise { + const record = await this.getRecordSql(sql, params); + if (!record) { + return Promise.reject(null); + } + + return record[Object.keys(record)[0]]; + } + + /** + * Constructs 'IN()' or '=' sql fragment + * + * @param items A single value or array of values for the expression. It doesn't accept objects. + * @param equal True means we want to equate to the constructed expression. + * @param onEmptyItems This defines the behavior when the array of items provided is empty. Defaults to false, + * meaning return empty. Other values will become part of the returned SQL fragment. + * @return A list containing the constructed sql fragment and an array of parameters. + */ + getInOrEqual(items: any, equal: boolean = true, onEmptyItems?: any): any[] { + let sql; + let params; + + if (typeof onEmptyItems == 'undefined') { + onEmptyItems = false; + } + + // Default behavior, return empty data on empty array. + if (Array.isArray(items) && !items.length && onEmptyItems === false) { + return ['', []]; + } + + // Handle onEmptyItems on empty array of items. + if (Array.isArray(items) && !items.length) { + if (onEmptyItems === null) { // Special case, NULL value. + sql = equal ? ' IS NULL' : ' IS NOT NULL'; + + return [sql, []]; + } else { + items = [onEmptyItems]; // Rest of cases, prepare items for processing. + } + } + + if (!Array.isArray(items) || items.length == 1) { + sql = equal ? '= ?' : '<> ?'; + params = Array.isArray(items) ? items : [items]; + } else { + sql = (equal ? '' : 'NOT ') + 'IN (' + ',?'.repeat(items.length).substr(1) + ')'; + params = items; + } + + return [sql, params]; + } + + /** + * Get the database name. + * + * @return Database name. + */ + getName(): string { + return this.name; + } + + /** + * Get a single database record where all the given conditions met. + * + * @param table The table to query. + * @param conditions The conditions to build the where clause. Must not contain numeric indexes. + * @param fields A comma separated list of fields to return. + * @return Promise resolved with the record, rejected if not found. + */ + getRecord(table: string, conditions?: object, fields: string = '*'): Promise { + const selectAndParams = this.whereClause(conditions); + + return this.getRecordSelect(table, selectAndParams[0], selectAndParams[1], fields); + } + + /** + * Get a single database record as an object which match a particular WHERE clause. + * + * @param table The table to query. + * @param select A fragment of SQL to be used in a where clause in the SQL call. + * @param params An array of sql parameters. + * @param fields A comma separated list of fields to return. + * @return Promise resolved with the record, rejected if not found. + */ + getRecordSelect(table: string, select: string = '', params: any[] = [], fields: string = '*'): Promise { + if (select) { + select = ' WHERE ' + select; + } + + return this.getRecordSql(`SELECT ${fields} FROM ${table} ${select}`, params); + } + + /** + * Get a single database record as an object using a SQL statement. + * + * The SQL statement should normally only return one record. + * It is recommended to use getRecordsSql() if more matches possible! + * + * @param sql The SQL string you wish to be executed, should normally only return one record. + * @param params List of sql parameters + * @return Promise resolved with the records. + */ + async getRecordSql(sql: string, params?: any[]): Promise { + const result = await this.getRecordsSql(sql, params, 0, 1); + if (!result || !result.length) { + // Not found, reject. + return Promise.reject(null); + } + + return result[0]; + } + + /** + * Get a number of records where all the given conditions met. + * + * @param table The table to query. + * @param conditions The conditions to build the where clause. Must not contain numeric indexes. + * @param sort An order to sort the results in. + * @param fields A comma separated list of fields to return. + * @param limitFrom Return a subset of records, starting at this point. + * @param limitNum Return a subset comprising this many records in total. + * @return Promise resolved with the records. + */ + getRecords(table: string, conditions?: object, sort: string = '', fields: string = '*', limitFrom: number = 0, + limitNum: number = 0): Promise { + const selectAndParams = this.whereClause(conditions); + + return this.getRecordsSelect(table, selectAndParams[0], selectAndParams[1], sort, fields, limitFrom, limitNum); + } + + /** + * Get a number of records where one field match one list of values. + * + * @param table The database table to be checked against. + * @param field The name of a field. + * @param values The values field might take. + * @param sort An order to sort the results in. + * @param fields A comma separated list of fields to return. + * @param limitFrom Return a subset of records, starting at this point. + * @param limitNum Return a subset comprising this many records in total. + * @return Promise resolved with the records. + */ + getRecordsList(table: string, field: string, values: any[], sort: string = '', fields: string = '*', limitFrom: number = 0, + limitNum: number = 0): Promise { + const selectAndParams = this.whereClauseList(field, values); + + return this.getRecordsSelect(table, selectAndParams[0], selectAndParams[1], sort, fields, limitFrom, limitNum); + } + + /** + * Get a number of records which match a particular WHERE clause. + * + * @param table The table to query. + * @param select A fragment of SQL to be used in a where clause in the SQL call. + * @param params An array of sql parameters. + * @param sort An order to sort the results in. + * @param fields A comma separated list of fields to return. + * @param limitFrom Return a subset of records, starting at this point. + * @param limitNum Return a subset comprising this many records in total. + * @return Promise resolved with the records. + */ + getRecordsSelect(table: string, select: string = '', params: any[] = [], sort: string = '', fields: string = '*', + limitFrom: number = 0, limitNum: number = 0): Promise { + if (select) { + select = ' WHERE ' + select; + } + if (sort) { + sort = ' ORDER BY ' + sort; + } + + const sql = `SELECT ${fields} FROM ${table} ${select} ${sort}`; + + return this.getRecordsSql(sql, params, limitFrom, limitNum); + } + + /** + * Get a number of records using a SQL statement. + * + * @param sql The SQL select query to execute. + * @param params List of sql parameters + * @param limitFrom Return a subset of records, starting at this point. + * @param limitNum Return a subset comprising this many records. + * @return Promise resolved with the records. + */ + async getRecordsSql(sql: string, params?: any[], limitFrom?: number, limitNum?: number): Promise { + const limits = this.normaliseLimitFromNum(limitFrom, limitNum); + + if (limits[0] || limits[1]) { + if (limits[1] < 1) { + limits[1] = Number.MAX_VALUE; + } + sql += ' LIMIT ' + limits[0] + ', ' + limits[1]; + } + + const result = await this.execute(sql, params); + // Retrieve the records. + const records = []; + for (let i = 0; i < result.rows.length; i++) { + records.push(result.rows.item(i)); + } + + return records; + } + + /** + * Given a data object, returns the SQL query and the params to insert that record. + * + * @param table The database table. + * @param data A data object with values for one or more fields in the record. + * @return Array with the SQL query and the params. + */ + protected getSqlInsertQuery(table: string, data: object): any[] { + this.formatDataToInsert(data); + + const keys = Object.keys(data); + const fields = keys.join(','); + const questionMarks = ',?'.repeat(keys.length).substr(1); + + return [ + `INSERT OR REPLACE INTO ${table} (${fields}) VALUES (${questionMarks})`, + keys.map((key) => data[key]) + ]; + } + + /** + * Initialize the database. + */ + init(): void { + this.promise = Platform.instance.ready().then(() => { + return SQLite.instance.create({ + name: this.name, + location: 'default' + }); + }).then((db: SQLiteObject) => { + this.db = db; + }); + } + + /** + * Insert a record into a table and return the "rowId" field. + * + * @param table The database table to be inserted into. + * @param data A data object with values for one or more fields in the record. + * @return Promise resolved with new rowId. Please notice this rowId is internal from SQLite. + */ + async insertRecord(table: string, data: object): Promise { + const sqlAndParams = this.getSqlInsertQuery(table, data); + const result = await this.execute(sqlAndParams[0], sqlAndParams[1]); + + return result.insertId; + } + + /** + * Insert multiple records into database as fast as possible. + * + * @param table The database table to be inserted into. + * @param dataObjects List of objects to be inserted. + * @return Promise resolved when done. + */ + insertRecords(table: string, dataObjects: object[]): Promise { + if (!Array.isArray(dataObjects)) { + return Promise.reject(null); + } + + const statements = []; + + dataObjects.forEach((dataObject) => { + statements.push(this.getSqlInsertQuery(table, dataObject)); + }); + + return this.executeBatch(statements); + } + + /** + * Insert multiple records into database from another table. + * + * @param table The database table to be inserted into. + * @param source The database table to get the records from. + * @param conditions The conditions to build the where clause. Must not contain numeric indexes. + * @param fields A comma separated list of fields to return. + * @return Promise resolved when done. + */ + insertRecordsFrom(table: string, source: string, conditions?: object, fields: string = '*'): Promise { + const selectAndParams = this.whereClause(conditions); + const select = selectAndParams[0] ? 'WHERE ' + selectAndParams[0] : ''; + const params = selectAndParams[1]; + + return this.execute(`INSERT INTO ${table} SELECT ${fields} FROM ${source} ${select}`, params); + } + + /** + * Ensures that limit params are numeric and positive integers, to be passed to the database. + * We explicitly treat null, '' and -1 as 0 in order to provide compatibility with how limit + * values have been passed historically. + * + * @param limitFrom Where to start results from. + * @param limitNum How many results to return. + * @return Normalised limit params in array: [limitFrom, limitNum]. + */ + normaliseLimitFromNum(limitFrom: any, limitNum: any): number[] { + // We explicilty treat these cases as 0. + if (typeof limitFrom == 'undefined' || limitFrom === null || limitFrom === '' || limitFrom === -1) { + limitFrom = 0; + } + if (typeof limitNum == 'undefined' || limitNum === null || limitNum === '' || limitNum === -1) { + limitNum = 0; + } + + limitFrom = parseInt(limitFrom, 10); + limitNum = parseInt(limitNum, 10); + limitFrom = Math.max(0, limitFrom); + limitNum = Math.max(0, limitNum); + + return [limitFrom, limitNum]; + } + + /** + * Open the database. Only needed if it was closed before, a database is automatically opened when created. + * + * @return Promise resolved when open. + */ + async open(): Promise { + await this.ready(); + + return this.db.open(); + } + + /** + * Wait for the DB to be ready. + * + * @return Promise resolved when ready. + */ + ready(): Promise { + return this.promise; + } + + /** + * Test whether a record exists in a table where all the given conditions met. + * + * @param table The table to check. + * @param conditions The conditions to build the where clause. Must not contain numeric indexes. + * @return Promise resolved if exists, rejected otherwise. + */ + async recordExists(table: string, conditions?: object): Promise { + const record = await this.getRecord(table, conditions); + if (!record) { + return Promise.reject(null); + } + } + + /** + * Test whether any records exists in a table which match a particular WHERE clause. + * + * @param table The table to query. + * @param select A fragment of SQL to be used in a where clause in the SQL call. + * @param params An array of sql parameters. + * @return Promise resolved if exists, rejected otherwise. + */ + async recordExistsSelect(table: string, select: string = '', params: any[] = []): Promise { + const record = await this.getRecordSelect(table, select, params); + if (!record) { + return Promise.reject(null); + } + } + + /** + * Test whether a SQL SELECT statement returns any records. + * + * @param sql The SQL query returning one row with one column. + * @param params An array of sql parameters. + * @return Promise resolved if exists, rejected otherwise. + */ + async recordExistsSql(sql: string, params?: any[]): Promise { + const record = await this.getRecordSql(sql, params); + if (!record) { + return Promise.reject(null); + } + } + + /** + * Test whether a table exists.. + * + * @param name The table name. + * @return Promise resolved if exists, rejected otherwise. + */ + async tableExists(name: string): Promise { + await this.recordExists('sqlite_master', {type: 'table', tbl_name: name}); + } + + /** + * Update one or more records in a table. + * + * @param string table The database table to update. + * @param data An object with the fields to update: fieldname=>fieldvalue. + * @param conditions The conditions to build the where clause. Must not contain numeric indexes. + * @return Promise resolved when updated. + */ + updateRecords(table: string, data: any, conditions?: any): Promise { + + this.formatDataToInsert(data); + + if (!data || !Object.keys(data).length) { + // No fields to update, consider it's done. + return Promise.resolve(); + } + + const whereAndParams = this.whereClause(conditions); + const sets = []; + let sql; + let params; + + for (const key in data) { + sets.push(`${key} = ?`); + } + + sql = `UPDATE ${table} SET ${sets.join(', ')} WHERE ${whereAndParams[0]}`; + // Create the list of params using the "data" object and the params for the where clause. + params = Object.keys(data).map((key) => data[key]).concat(whereAndParams[1]); + + return this.execute(sql, params); + } + + /** + * Update one or more records in a table. It accepts a WHERE clause as a string. + * + * @param string table The database table to update. + * @param data An object with the fields to update: fieldname=>fieldvalue. + * @param where Where clause. Must not include the "WHERE" word. + * @param whereParams Params for the where clause. + * @return Promise resolved when updated. + */ + updateRecordsWhere(table: string, data: any, where?: string, whereParams?: any[]): Promise { + if (!data || !Object.keys(data).length) { + // No fields to update, consider it's done. + return Promise.resolve(); + } + + const sets = []; + let sql; + let params; + + for (const key in data) { + sets.push(`${key} = ?`); + } + + sql = `UPDATE ${table} SET ${sets.join(', ')}`; + if (where) { + sql += ` WHERE ${where}`; + } + + // Create the list of params using the "data" object and the params for the where clause. + params = Object.keys(data).map((key) => data[key]); + if (where && whereParams) { + params = params.concat(whereParams); + } + + return this.execute(sql, params); + } + + /** + * Returns the SQL WHERE conditions. + * + * @param conditions The conditions to build the where clause. Must not contain numeric indexes. + * @return An array list containing sql 'where' part and 'params'. + */ + whereClause(conditions: any = {}): any[] { + if (!conditions || !Object.keys(conditions).length) { + return ['1 = 1', []]; + } + + const where = []; + const params = []; + + for (const key in conditions) { + const value = conditions[key]; + + if (typeof value == 'undefined' || value === null) { + where.push(key + ' IS NULL'); + } else { + where.push(key + ' = ?'); + params.push(value); + } + } + + return [where.join(' AND '), params]; + } + + /** + * Returns SQL WHERE conditions for the ..._list group of methods. + * + * @param field The name of a field. + * @param values The values field might take. + * @return An array containing sql 'where' part and 'params'. + */ + whereClauseList(field: string, values: any[]): any[] { + if (!values || !values.length) { + return ['1 = 2', []]; // Fake condition, won't return rows ever. + } + + const params = []; + let select = ''; + + values.forEach((value) => { + if (typeof value == 'boolean') { + value = Number(value); + } + + if (typeof value == 'undefined' || value === null) { + select = field + ' IS NULL'; + } else { + params.push(value); + } + }); + + if (params && params.length) { + if (select !== '') { + select = select + ' OR '; + } + + if (params.length == 1) { + select = select + field + ' = ?'; + } else { + const questionMarks = ',?'.repeat(params.length).substr(1); + select = select + field + ' IN (' + questionMarks + ')'; + } + } + + return [select, params]; + } +} diff --git a/src/app/singletons/core.singletons.ts b/src/app/singletons/core.singletons.ts index 819fda31c..78f49bffa 100644 --- a/src/app/singletons/core.singletons.ts +++ b/src/app/singletons/core.singletons.ts @@ -12,9 +12,32 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Injector } from '@angular/core'; +import { Injector, NgZone as NgZoneService } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; -import { SplashScreen as SplashScreenPlugin } from '@ionic-native/splash-screen/ngx'; +import { Platform as PlatformService } from '@ionic/angular'; + +import { Clipboard as ClipboardService } from '@ionic-native/clipboard/ngx'; +import { Diagnostic as DiagnosticService } from '@ionic-native/diagnostic/ngx'; +import { Device as DeviceService } from '@ionic-native/device/ngx'; +import { File as FileService } from '@ionic-native/file/ngx'; +import { FileOpener as FileOpenerService } from '@ionic-native/file-opener/ngx'; +import { FileTransfer as FileTransferService } from '@ionic-native/file-transfer/ngx'; +import { Geolocation as GeolocationService } from '@ionic-native/geolocation/ngx'; +import { Globalization as GlobalizationService } from '@ionic-native/globalization/ngx'; +import { InAppBrowser as InAppBrowserService } from '@ionic-native/in-app-browser/ngx'; +import { Keyboard as KeyboardService } from '@ionic-native/keyboard/ngx'; +import { LocalNotifications as LocalNotificationsService } from '@ionic-native/local-notifications/ngx'; +import { Network as NetworkService } from '@ionic-native/network/ngx'; +import { Push as PushService } from '@ionic-native/push/ngx'; +import { QRScanner as QRScannerService } from '@ionic-native/qr-scanner/ngx'; +import { StatusBar as StatusBarService } from '@ionic-native/status-bar/ngx'; +import { SplashScreen as SplashScreenService } from '@ionic-native/splash-screen/ngx'; +import { SQLite as SQLiteService } from '@ionic-native/sqlite/ngx'; +import { WebIntent as WebIntentService } from '@ionic-native/web-intent/ngx'; +import { Zip as ZipService } from '@ionic-native/zip/ngx'; + +import { TranslateService } from '@ngx-translate/core'; import { CoreSingletonsFactory, CoreInjectionToken, CoreSingletonClass } from '@classes/singletons-factory'; @@ -39,4 +62,31 @@ export function makeSingleton(injectionToken: CoreInjectionToken