diff --git a/src/app/app.module.ts b/src/app/app.module.ts index f01239125..d8ecaa5da 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -21,8 +21,32 @@ import { IonicModule, IonicRouteStrategy } from '@ionic/angular'; import { AppComponent } from './app.component'; import { AppRoutingModule } from './app-routing.module'; +// Import core services. import { CoreAppProvider } from '@services/app'; +import { CoreConfigProvider } from '@services/config'; +import { CoreCronDelegate } from '@services/cron'; +import { CoreDbProvider } from '@services/db'; +import { CoreEventsProvider } from '@services/events'; +import { CoreFileHelperProvider } from '@services/file-helper'; +import { CoreFileSessionProvider } from '@services/file-session'; +import { CoreFileProvider } from '@services/file'; +import { CoreFilepoolProvider } from '@services/filepool'; +import { CoreGeolocationProvider } from '@services/geolocation'; +import { CoreGroupsProvider } from '@services/groups'; import { CoreInitDelegate } from '@services/init'; +import { CoreLangProvider } from '@services/lang'; +import { CoreLocalNotificationsProvider } from '@services/local-notifications'; +import { CorePluginFileDelegate } from '@services/plugin-file-delegate'; +import { CoreSitesProvider } from '@services/sites'; +import { CoreSyncProvider } from '@services/sync'; +import { CoreUpdateManager } from '@services/update-manager'; +import { CoreWSProvider } from '@services/ws'; +import { CoreDomUtilsProvider } from '@services/utils/dom'; +import { CoreIframeUtilsProvider } from '@services/utils/iframe'; +import { CoreMimetypeUtilsProvider } from '@services/utils/mimetype'; +import { CoreTextUtilsProvider } from '@services/utils/text'; +import { CoreTimeUtilsProvider } from '@services/utils/time'; +import { CoreUrlUtilsProvider } from '@services/utils/url'; import { CoreUtilsProvider } from '@services/utils/utils'; import { CoreEmulatorModule } from '@core/emulator/emulator.module'; @@ -43,7 +67,30 @@ import { setSingletonsInjector } from '@singletons/core.singletons'; providers: [ { provide: RouteReuseStrategy, useClass: IonicRouteStrategy }, CoreAppProvider, + CoreConfigProvider, + CoreCronDelegate, + CoreDbProvider, + CoreEventsProvider, + CoreFileHelperProvider, + CoreFileSessionProvider, + CoreFileProvider, + CoreFilepoolProvider, + CoreGeolocationProvider, + CoreGroupsProvider, CoreInitDelegate, + CoreLangProvider, + CoreLocalNotificationsProvider, + CorePluginFileDelegate, + CoreSitesProvider, + CoreSyncProvider, + CoreUpdateManager, + CoreWSProvider, + CoreDomUtilsProvider, + CoreIframeUtilsProvider, + CoreMimetypeUtilsProvider, + CoreTextUtilsProvider, + CoreTimeUtilsProvider, + CoreUrlUtilsProvider, CoreUtilsProvider, ], bootstrap: [AppComponent], diff --git a/src/app/config.json b/src/app/config.json new file mode 100644 index 000000000..4749c6bb2 --- /dev/null +++ b/src/app/config.json @@ -0,0 +1,110 @@ +{ + "app_id": "com.moodle.moodlemobile", + "appname": "Moodle Mobile", + "desktopappname": "Moodle Desktop", + "versioncode": 3930, + "versionname": "3.9.3-dev", + "cache_update_frequency_usually": 420000, + "cache_update_frequency_often": 1200000, + "cache_update_frequency_sometimes": 3600000, + "cache_update_frequency_rarely": 43200000, + "default_lang": "en", + "languages": { + "ar": "عربي", + "bg": "Български", + "ca": "Català", + "cs": "Čeština", + "da": "Dansk", + "de": "Deutsch", + "de-du": "Deutsch - Du", + "el": "Ελληνικά", + "en": "English", + "en-us": "English - United States", + "es": "Español", + "es-mx": "Español - México", + "eu": "Euskara", + "fa": "فارسی", + "fi": "Suomi", + "fr": "Français", + "he": "עברית", + "hi": "हिंदी", + "hr": "Hrvatski", + "hu": "magyar", + "id": "Indonesian", + "it": "Italiano", + "ja": "日本語", + "km": "ខ្មែរ", + "kn": "ಕನ್ನಡ", + "ko": "한국어", + "lt": "Lietuvių", + "lv": "Latviešu", + "mn": "Монгол", + "mr": "मराठी", + "nl": "Nederlands", + "no": "Norsk - bokmål", + "pl": "Polski", + "pt": "Português - Portugal", + "pt-br": "Português - Brasil", + "ro": "Română", + "ru": "Русский", + "sl": "Slovenščina", + "sr-cr": "Српски", + "sr-lt": "Srpski", + "sv": "Svenska", + "tg": "Тоҷикӣ", + "tr": "Türkçe", + "uk": "Українська", + "vi": "Vietnamese", + "zh-cn": "简体中文", + "zh-tw": "正體中文" + }, + "wsservice": "moodle_mobile_app", + "wsextservice": "local_mobile", + "demo_sites": { + "student": { + "url": "https:\/\/school.moodledemo.net", + "username": "student", + "password": "moodle" + }, + "teacher": { + "url": "https:\/\/school.moodledemo.net", + "username": "teacher", + "password": "moodle" + } + }, + "font_sizes": [ + 62.5, + 75.89, + 93.75 + ], + "customurlscheme": "moodlemobile", + "siteurl": "", + "sitename": "", + "multisitesdisplay": "", + "sitefindersettings": {}, + "onlyallowlistedsites": false, + "skipssoconfirmation": false, + "forcedefaultlanguage": false, + "privacypolicy": "https:\/\/moodle.net\/moodle-app-privacy\/", + "notificoncolor": "#f98012", + "statusbarbg": false, + "statusbarlighttext": false, + "statusbarbgios": "#f98012", + "statusbarlighttextios": true, + "statusbarbgandroid": "#df7310", + "statusbarlighttextandroid": true, + "statusbarbgremotetheme": "#000000", + "statusbarlighttextremotetheme": true, + "enableanalytics": false, + "enableonboarding": true, + "forceColorScheme": "", + "forceLoginLogo": false, + "ioswebviewscheme": "moodleappfs", + "appstores": { + "android": "com.moodle.moodlemobile", + "ios": "id633359593", + "windows": "moodle-desktop\/9p9bwvhdc8c8", + "mac": "id1255924440", + "linux": "https:\/\/download.moodle.org\/desktop\/download.php?platform=linux&arch=64" + } +} diff --git a/src/app/core/emulator/classes/sqlitedb.ts b/src/app/core/emulator/classes/sqlitedb.ts new file mode 100644 index 000000000..d40b02a5c --- /dev/null +++ b/src/app/core/emulator/classes/sqlitedb.ts @@ -0,0 +1,166 @@ +// (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. + +/* tslint:disable:no-console */ + +import { SQLiteDB } from '@classes/sqlitedb'; + +/** + * Class to mock the interaction with the SQLite database. + */ +export class SQLiteDBMock extends SQLiteDB { + promise: Promise; + + /** + * Create and open the database. + * + * @param name Database name. + */ + constructor(public name: string) { + super(name); + } + + /** + * Close the database. + * + * @return Promise resolved when done. + */ + close(): Promise { + // WebSQL databases aren't closed. + return Promise.resolve(); + } + + /** + * Drop all the data in the database. + * + * @return Promise resolved when done. + */ + emptyDatabase(): Promise { + return new Promise((resolve, reject): void => { + this.db.transaction((tx) => { + // Query all tables from sqlite_master that we have created and can modify. + const args = []; + const query = `SELECT * FROM sqlite_master + WHERE name NOT LIKE 'sqlite\\_%' escape '\\' AND name NOT LIKE '\\_%' escape '\\'`; + + tx.executeSql(query, args, (tx, result) => { + if (result.rows.length <= 0) { + // No tables to delete, stop. + resolve(); + + return; + } + + // Drop all the tables. + const promises = []; + + for (let i = 0; i < result.rows.length; i++) { + promises.push(new Promise((resolve, reject): void => { + // Drop the table. + const name = JSON.stringify(result.rows.item(i).name); + tx.executeSql('DROP TABLE ' + name, [], resolve, reject); + })); + } + + Promise.all(promises).then(resolve, reject); + }, reject); + }); + }); + } + + /** + * 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. + */ + execute(sql: string, params?: any[]): Promise { + return new Promise((resolve, reject): void => { + // With WebSQL, all queries must be run in a transaction. + this.db.transaction((tx) => { + tx.executeSql(sql, params, (tx, results) => { + resolve(results); + }, (tx, error) => { + console.error(sql, params, error); + reject(error); + }); + }); + }); + } + + /** + * 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. + */ + executeBatch(sqlStatements: any[]): Promise { + return new Promise((resolve, reject): void => { + // Create a transaction to execute the queries. + this.db.transaction((tx) => { + const promises = []; + + // Execute all the queries. Each statement can be a string or an array. + sqlStatements.forEach((statement) => { + promises.push(new Promise((resolve, reject): void => { + let query; + let params; + + if (Array.isArray(statement)) { + query = statement[0]; + params = statement[1]; + } else { + query = statement; + params = null; + } + + tx.executeSql(query, params, (tx, results) => { + resolve(results); + }, (tx, error) => { + console.error(query, params, error); + reject(error); + }); + })); + }); + + Promise.all(promises).then(resolve, reject); + }); + }); + } + + /** + * Initialize the database. + */ + init(): void { + // This DB is for desktop apps, so use a big size to be sure it isn't filled. + this.db = ( window).openDatabase(this.name, '1.0', this.name, 500 * 1024 * 1024); + this.promise = Promise.resolve(); + } + + /** + * Open the database. Only needed if it was closed before, a database is automatically opened when created. + * + * @return Promise resolved when done. + */ + open(): Promise { + // WebSQL databases can't closed, so the open method isn't needed. + return Promise.resolve(); + } + +} diff --git a/src/app/services/app.ts b/src/app/services/app.ts index 3f2b03e85..7da59749b 100644 --- a/src/app/services/app.ts +++ b/src/app/services/app.ts @@ -12,36 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Injectable } from '@angular/core'; +import { Injectable, NgZone, ApplicationRef } from '@angular/core'; +import { Connection } from '@ionic-native/network/ngx'; -import { makeSingleton } from '@singletons/core.singletons'; +import { CoreDB } from '@services/db'; +import { CoreEvents, CoreEventsProvider } from '@services/events'; +import { SQLiteDB, SQLiteDBTableSchema } from '@classes/sqlitedb'; +import CoreConfigConstants from '@app/config.json'; + +import { makeSingleton, Keyboard, Network, StatusBar, Platform } from '@singletons/core.singletons'; import { CoreLogger } from '@singletons/logger'; -/** - * Data stored for a redirect to another page/site. - */ -export type CoreRedirectData = { - /** - * ID of the site to load. - */ - siteId?: string; - - /** - * Name of the page to redirect to. - */ - page?: string; - - /** - * Params to pass to the page. - */ - params?: any; - - /** - * Timestamp when this redirect was last modified. - */ - timemodified?: number; -}; - /** * Factory to provide some global functionalities, like access to the global app database. * @description @@ -54,10 +35,531 @@ export type CoreRedirectData = { */ @Injectable() export class CoreAppProvider { - protected logger: CoreLogger; + protected DBNAME = 'MoodleMobile'; + protected db: SQLiteDB; + protected logger; + protected ssoAuthenticationPromise: Promise; + protected isKeyboardShown = false; + protected _isKeyboardOpening = false; + protected _isKeyboardClosing = false; + protected backActions = []; + protected mainMenuId = 0; + protected mainMenuOpen: number; + protected forceOffline = false; + + // Variables for DB. + protected createVersionsTableReady: Promise; + protected SCHEMA_VERSIONS_TABLE = 'schema_versions'; + protected versionsTableSchema: SQLiteDBTableSchema = { + name: this.SCHEMA_VERSIONS_TABLE, + columns: [ + { + name: 'name', + type: 'TEXT', + primaryKey: true, + }, + { + name: 'version', + type: 'INTEGER', + }, + ], + }; + + constructor(appRef: ApplicationRef, + zone: NgZone) { - constructor() { this.logger = CoreLogger.getInstance('CoreAppProvider'); + this.db = CoreDB.instance.getDB(this.DBNAME); + + // Create the schema versions table. + this.createVersionsTableReady = this.db.createTableFromSchema(this.versionsTableSchema); + + Keyboard.instance.onKeyboardShow().subscribe((data) => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + zone.run(() => { + document.body.classList.add('keyboard-is-open'); + this.setKeyboardShown(true); + // Error on iOS calculating size. + // More info: https://github.com/ionic-team/ionic-plugin-keyboard/issues/276 . + CoreEvents.instance.trigger(CoreEventsProvider.KEYBOARD_CHANGE, data.keyboardHeight); + }); + }); + Keyboard.instance.onKeyboardHide().subscribe((data) => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + zone.run(() => { + document.body.classList.remove('keyboard-is-open'); + this.setKeyboardShown(false); + CoreEvents.instance.trigger(CoreEventsProvider.KEYBOARD_CHANGE, 0); + }); + }); + Keyboard.instance.onKeyboardWillShow().subscribe((data) => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + zone.run(() => { + this._isKeyboardOpening = true; + this._isKeyboardClosing = false; + }); + }); + Keyboard.instance.onKeyboardWillHide().subscribe((data) => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + zone.run(() => { + this._isKeyboardOpening = false; + this._isKeyboardClosing = true; + }); + }); + + // this.platform.registerBackButtonAction(() => { + // this.backButtonAction(); + // }, 100); + + // Export the app provider and appRef to control the application in Behat tests. + if (CoreAppProvider.isAutomated()) { + ( window).appProvider = this; + ( window).appRef = appRef; + } + } + + /** + * Returns whether the user agent is controlled by automation. I.e. Behat testing. + * + * @return True if the user agent is controlled by automation, false otherwise. + */ + static isAutomated(): boolean { + return !!navigator.webdriver; + } + + /** + * Check if the browser supports mediaDevices.getUserMedia. + * + * @return Whether the function is supported. + */ + canGetUserMedia(): boolean { + return !!(navigator && navigator.mediaDevices && navigator.mediaDevices.getUserMedia); + } + + /** + * Check if the browser supports MediaRecorder. + * + * @return Whether the function is supported. + */ + canRecordMedia(): boolean { + return !!( window).MediaRecorder; + } + + /** + * Closes the keyboard. + */ + closeKeyboard(): void { + if (this.isMobile()) { + Keyboard.instance.hide(); + } + } + + /** + * Install and upgrade a certain schema. + * + * @param schema The schema to create. + * @return Promise resolved when done. + */ + async createTablesFromSchema(schema: CoreAppSchema): Promise { + this.logger.debug(`Apply schema to app DB: ${schema.name}`); + + let oldVersion; + + try { + // Wait for the schema versions table to be created. + await this.createVersionsTableReady; + + // Fetch installed version of the schema. + const entry = await this.db.getRecord(this.SCHEMA_VERSIONS_TABLE, {name: schema.name}); + oldVersion = entry.version; + } catch (error) { + // No installed version yet. + oldVersion = 0; + } + + if (oldVersion >= schema.version) { + // Version already installed, nothing else to do. + return; + } + + this.logger.debug(`Migrating schema '${schema.name}' of app DB from version ${oldVersion} to ${schema.version}`); + + if (schema.tables) { + await this.db.createTablesFromSchema(schema.tables); + } + if (schema.migrate) { + await schema.migrate(this.db, oldVersion); + } + + // Set installed version. + await this.db.insertRecord(this.SCHEMA_VERSIONS_TABLE, {name: schema.name, version: schema.version}); + } + + /** + * Get the application global database. + * + * @return App's DB. + */ + getDB(): SQLiteDB { + return this.db; + } + + /** + * Get an ID for a main menu. + * + * @return Main menu ID. + */ + getMainMenuId(): number { + return this.mainMenuId++; + } + + /** + * Get app store URL. + * + * @param storesConfig Config params to send the user to the right place. + * @return Store URL. + */ + getAppStoreUrl(storesConfig: CoreStoreConfig): string { + if (this.isMac() && storesConfig.mac) { + return 'itms-apps://itunes.apple.com/app/' + storesConfig.mac; + } + + if (this.isWindows() && storesConfig.windows) { + return 'https://www.microsoft.com/p/' + storesConfig.windows; + } + + if (this.isLinux() && storesConfig.linux) { + return storesConfig.linux; + } + + if (this.isDesktop() && storesConfig.desktop) { + return storesConfig.desktop; + } + + if (this.isIOS() && storesConfig.ios) { + return 'itms-apps://itunes.apple.com/app/' + storesConfig.ios; + } + + if (this.isAndroid() && storesConfig.android) { + return 'market://details?id=' + storesConfig.android; + } + + if (this.isMobile() && storesConfig.mobile) { + return storesConfig.mobile; + } + + return storesConfig.default || null; + } + + /** + * Checks if the app is running in a 64 bits desktop environment (not browser). + * + * @return Whether the app is running in a 64 bits desktop environment (not browser). + */ + is64Bits(): boolean { + const process = ( window).process; + + return this.isDesktop() && process.arch == 'x64'; + } + + /** + * Checks if the app is running in an Android mobile or tablet device. + * + * @return Whether the app is running in an Android mobile or tablet device. + */ + isAndroid(): boolean { + return this.isMobile() && Platform.instance.is('android'); + } + + /** + * Checks if the app is running in a desktop environment (not browser). + * + * @return Whether the app is running in a desktop environment (not browser). + */ + isDesktop(): boolean { + const process = ( window).process; + + return !!(process && process.versions && typeof process.versions.electron != 'undefined'); + } + + /** + * Checks if the app is running in an iOS mobile or tablet device. + * + * @return Whether the app is running in an iOS mobile or tablet device. + */ + isIOS(): boolean { + return this.isMobile() && !Platform.instance.is('android'); + } + + /** + * Check if the keyboard is closing. + * + * @return Whether keyboard is closing (animating). + */ + isKeyboardClosing(): boolean { + return this._isKeyboardClosing; + } + + /** + * Check if the keyboard is being opened. + * + * @return Whether keyboard is opening (animating). + */ + isKeyboardOpening(): boolean { + return this._isKeyboardOpening; + } + + /** + * Check if the keyboard is visible. + * + * @return Whether keyboard is visible. + */ + isKeyboardVisible(): boolean { + return this.isKeyboardShown; + } + + /** + * Check if the app is running in a Linux environment. + * + * @return Whether it's running in a Linux environment. + */ + isLinux(): boolean { + if (!this.isDesktop()) { + return false; + } + + try { + // @todo return require('os').platform().indexOf('linux') === 0; + } catch (ex) { + return false; + } + } + + /** + * Check if the app is running in a Mac OS environment. + * + * @return Whether it's running in a Mac OS environment. + */ + isMac(): boolean { + if (!this.isDesktop()) { + return false; + } + + try { + // @todo return require('os').platform().indexOf('darwin') === 0; + } catch (ex) { + return false; + } + } + + /** + * Check if the main menu is open. + * + * @return Whether the main menu is open. + */ + isMainMenuOpen(): boolean { + return typeof this.mainMenuOpen != 'undefined'; + } + + /** + * Checks if the app is running in a mobile or tablet device (Cordova). + * + * @return Whether the app is running in a mobile or tablet device. + */ + isMobile(): boolean { + return Platform.instance.is('cordova'); + } + + /** + * Checks if the current window is wider than a mobile. + * + * @return Whether the app the current window is wider than a mobile. + */ + isWide(): boolean { + return Platform.instance.width() > 768; + } + + /** + * Returns whether we are online. + * + * @return Whether the app is online. + */ + isOnline(): boolean { + if (this.forceOffline) { + return false; + } + + let online = Network.instance.type !== null && Number(Network.instance.type) != Connection.NONE && + Number(Network.instance.type) != Connection.UNKNOWN; + // Double check we are not online because we cannot rely 100% in Cordova APIs. Also, check it in browser. + if (!online && navigator.onLine) { + online = true; + } + + return online; + } + + /** + * Check if device uses a limited connection. + * + * @return Whether the device uses a limited connection. + */ + isNetworkAccessLimited(): boolean { + const type = Network.instance.type; + if (type === null) { + // Plugin not defined, probably in browser. + return false; + } + + const limited = [Connection.CELL_2G, Connection.CELL_3G, Connection.CELL_4G, Connection.CELL]; + + return limited.indexOf(Number(type)) > -1; + } + + /** + * Check if device uses a wifi connection. + * + * @return Whether the device uses a wifi connection. + */ + isWifi(): boolean { + return this.isOnline() && !this.isNetworkAccessLimited(); + } + + /** + * Check if the app is running in a Windows environment. + * + * @return Whether it's running in a Windows environment. + */ + isWindows(): boolean { + if (!this.isDesktop()) { + return false; + } + + try { + // @todo return require('os').platform().indexOf('win') === 0; + } catch (ex) { + return false; + } + } + + /** + * Open the keyboard. + */ + openKeyboard(): void { + // Open keyboard is not supported in desktop and in iOS. + if (this.isAndroid()) { + Keyboard.instance.show(); + } + } + + /** + * Set keyboard shown or hidden. + * + * @param Whether the keyboard is shown or hidden. + */ + protected setKeyboardShown(shown: boolean): void { + this.isKeyboardShown = shown; + this._isKeyboardOpening = false; + this._isKeyboardClosing = false; + } + + /** + * Set a main menu as open or not. + * + * @param id Main menu ID. + * @param open Whether it's open or not. + */ + setMainMenuOpen(id: number, open: boolean): void { + if (open) { + this.mainMenuOpen = id; + CoreEvents.instance.trigger(CoreEventsProvider.MAIN_MENU_OPEN); + } else if (this.mainMenuOpen == id) { + delete this.mainMenuOpen; + } + } + + /** + * Start an SSO authentication process. + * Please notice that this function should be called when the app receives the new token from the browser, + * NOT when the browser is opened. + */ + startSSOAuthentication(): void { + let cancelTimeout; + let resolvePromise; + + this.ssoAuthenticationPromise = new Promise((resolve, reject): void => { + resolvePromise = resolve; + + // Resolve it automatically after 10 seconds (it should never take that long). + cancelTimeout = setTimeout(() => { + this.finishSSOAuthentication(); + }, 10000); + }); + + // Store the resolve function in the promise itself. + ( this.ssoAuthenticationPromise).resolve = resolvePromise; + + // If the promise is resolved because finishSSOAuthentication is called, stop the cancel promise. + this.ssoAuthenticationPromise.then(() => { + clearTimeout(cancelTimeout); + }); + } + + /** + * Finish an SSO authentication process. + */ + finishSSOAuthentication(): void { + if (this.ssoAuthenticationPromise) { + ( this.ssoAuthenticationPromise).resolve && ( this.ssoAuthenticationPromise).resolve(); + this.ssoAuthenticationPromise = undefined; + } + } + + /** + * Check if there's an ongoing SSO authentication process. + * + * @return Whether there's a SSO authentication ongoing. + */ + isSSOAuthenticationOngoing(): boolean { + return !!this.ssoAuthenticationPromise; + } + + /** + * Returns a promise that will be resolved once SSO authentication finishes. + * + * @return Promise resolved once SSO authentication finishes. + */ + waitForSSOAuthentication(): Promise { + return this.ssoAuthenticationPromise || Promise.resolve(); + } + + /** + * Wait until the application is resumed. + * + * @param timeout Maximum time to wait, use null to wait forever. + */ + async waitForResume(timeout: number | null = null): Promise { + let resolve: (value?: any) => void; + let resumeSubscription: any; + let timeoutId: NodeJS.Timer | false; + + const promise = new Promise((r): any => resolve = r); + const stopWaiting = (): any => { + if (!resolve) { + return; + } + + resolve(); + resumeSubscription.unsubscribe(); + timeoutId && clearTimeout(timeoutId); + + resolve = null; + }; + + resumeSubscription = Platform.instance.resume.subscribe(stopWaiting); + timeoutId = timeout ? setTimeout(stopWaiting, timeout) : false; + + await promise; } /** @@ -107,6 +609,195 @@ export class CoreAppProvider { } } } + + /** + * The back button event is triggered when the user presses the native + * platform's back button, also referred to as the "hardware" back button. + * This event is only used within Cordova apps running on Android and + * Windows platforms. This event is not fired on iOS since iOS doesn't come + * with a hardware back button in the same sense an Android or Windows device + * does. + * + * Registering a hardware back button action and setting a priority allows + * apps to control which action should be called when the hardware back + * button is pressed. This method decides which of the registered back button + * actions has the highest priority and should be called. + * + * @param fn Called when the back button is pressed, + * if this registered action has the highest priority. + * @param priority Set the priority for this action. All actions sorted by priority will be executed since one of + * them returns true. + * * Priorities higher or equal than 1000 will go before closing modals + * * Priorities lower than 500 will only be executed if you are in the first state of the app (before exit). + * @return A function that, when called, will unregister + * the back button action. + */ + registerBackButtonAction(fn: any, priority: number = 0): any { + const action = { fn, priority }; + + this.backActions.push(action); + + this.backActions.sort((a, b) => { + return b.priority - a.priority; + }); + + return (): boolean => { + const index = this.backActions.indexOf(action); + + return index >= 0 && !!this.backActions.splice(index, 1); + }; + } + + /** + * Set StatusBar color depending on platform. + */ + setStatusBarColor(): void { + if (typeof CoreConfigConstants.statusbarbgios == 'string' && this.isIOS()) { + // IOS Status bar properties. + StatusBar.instance.overlaysWebView(false); + StatusBar.instance.backgroundColorByHexString(CoreConfigConstants.statusbarbgios); + CoreConfigConstants.statusbarlighttextios ? StatusBar.instance.styleLightContent() : StatusBar.instance.styleDefault(); + } else if (typeof CoreConfigConstants.statusbarbgandroid == 'string' && this.isAndroid()) { + // Android Status bar properties. + StatusBar.instance.backgroundColorByHexString(CoreConfigConstants.statusbarbgandroid); + CoreConfigConstants.statusbarlighttextandroid ? + StatusBar.instance.styleLightContent() : StatusBar.instance.styleDefault(); + } else if (typeof CoreConfigConstants.statusbarbg == 'string') { + // Generic Status bar properties. + this.isIOS() && StatusBar.instance.overlaysWebView(false); + StatusBar.instance.backgroundColorByHexString(CoreConfigConstants.statusbarbg); + CoreConfigConstants.statusbarlighttext ? StatusBar.instance.styleLightContent() : StatusBar.instance.styleDefault(); + } else { + // Default Status bar properties. + this.isAndroid() ? StatusBar.instance.styleLightContent() : StatusBar.instance.styleDefault(); + } + } + + /** + * Reset StatusBar color if any was set. + */ + resetStatusBarColor(): void { + if (typeof CoreConfigConstants.statusbarbgremotetheme == 'string' && + ((typeof CoreConfigConstants.statusbarbgios == 'string' && this.isIOS()) || + (typeof CoreConfigConstants.statusbarbgandroid == 'string' && this.isAndroid()) || + typeof CoreConfigConstants.statusbarbg == 'string')) { + // If the status bar has been overriden and there's a fallback color for remote themes, use it now. + this.isIOS() && StatusBar.instance.overlaysWebView(false); + StatusBar.instance.backgroundColorByHexString(CoreConfigConstants.statusbarbgremotetheme); + CoreConfigConstants.statusbarlighttextremotetheme ? + StatusBar.instance.styleLightContent() : StatusBar.instance.styleDefault(); + } + } + + /** + * Set value of forceOffline flag. If true, the app will think the device is offline. + * + * @param value Value to set. + */ + setForceOffline(value: boolean): void { + this.forceOffline = !!value; + } } export class CoreApp extends makeSingleton(CoreAppProvider) {} + +/** + * Data stored for a redirect to another page/site. + */ +export type CoreRedirectData = { + /** + * ID of the site to load. + */ + siteId?: string; + + /** + * Name of the page to redirect to. + */ + page?: string; + + /** + * Params to pass to the page. + */ + params?: any; + + /** + * Timestamp when this redirect was last modified. + */ + timemodified?: number; +}; + +/** + * Store config data. + */ +export type CoreStoreConfig = { + /** + * ID of the Apple store where the desktop Mac app is uploaded. + */ + mac?: string; + + /** + * ID of the Windows store where the desktop Windows app is uploaded. + */ + windows?: string; + + /** + * Url with the desktop linux download link. + */ + linux?: string; + + /** + * Fallback URL when the desktop options is not set. + */ + desktop?: string; + + /** + * ID of the Apple store where the mobile iOS app is uploaded. + */ + ios?: string; + + /** + * ID of the Google play store where the android app is uploaded. + */ + android?: string; + + /** + * Fallback URL when the mobile options is not set. + */ + mobile?: string; + + /** + * Fallback URL when the other fallbacks options are not set. + */ + default?: string; +}; + +/** + * App DB schema and migration function. + */ +export type CoreAppSchema = { + /** + * Name of the schema. + */ + name: string; + + /** + * Latest version of the schema (integer greater than 0). + */ + version: number; + + /** + * Tables to create when installing or upgrading the schema. + */ + tables?: SQLiteDBTableSchema[]; + + /** + * Migrates the schema to the latest version. + * + * Called when installing and upgrading the schema, after creating the defined tables. + * + * @param db The affected DB. + * @param oldVersion Old version of the schema or 0 if not installed. + * @return Promise resolved when done. + */ + migrate?(db: SQLiteDB, oldVersion: number): Promise; +}; diff --git a/src/app/services/config.ts b/src/app/services/config.ts new file mode 100644 index 000000000..5ea413e6a --- /dev/null +++ b/src/app/services/config.ts @@ -0,0 +1,108 @@ +// (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 { CoreApp, CoreAppSchema } from '@services/app'; +import { SQLiteDB } from '@classes/sqlitedb'; +import { makeSingleton } from '@singletons/core.singletons'; + +/** + * Factory to provide access to dynamic and permanent config and settings. + * It should not be abused into a temporary storage. + */ +@Injectable() +export class CoreConfigProvider { + protected appDB: SQLiteDB; + protected TABLE_NAME = 'core_config'; + protected tableSchema: CoreAppSchema = { + name: 'CoreConfigProvider', + version: 1, + tables: [ + { + name: this.TABLE_NAME, + columns: [ + { + name: 'name', + type: 'TEXT', + unique: true, + notNull: true + }, + { + name: 'value' + }, + ], + }, + ], + }; + + protected dbReady: Promise; // Promise resolved when the app DB is initialized. + + constructor() { + this.appDB = CoreApp.instance.getDB(); + this.dbReady = CoreApp.instance.createTablesFromSchema(this.tableSchema).catch(() => { + // Ignore errors. + }); + } + + /** + * Deletes an app setting. + * + * @param name The config name. + * @return Promise resolved when done. + */ + async delete(name: string): Promise { + await this.dbReady; + + return this.appDB.deleteRecords(this.TABLE_NAME, { name }); + } + + /** + * Get an app setting. + * + * @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. + */ + async get(name: string, defaultValue?: any): Promise { + await this.dbReady; + + try { + const entry = await this.appDB.getRecord(this.TABLE_NAME, { name }); + + return entry.value; + } catch (error) { + if (typeof defaultValue != 'undefined') { + return defaultValue; + } + + throw error; + } + } + + /** + * Set an app setting. + * + * @param name The config name. + * @param value The config value. Can only store number or strings. + * @return Promise resolved when done. + */ + async set(name: string, value: number | string): Promise { + await this.dbReady; + + return this.appDB.insertRecord(this.TABLE_NAME, { name, value }); + } +} + +export class CoreConfig extends makeSingleton(CoreConfigProvider) {} diff --git a/src/app/services/cron.ts b/src/app/services/cron.ts new file mode 100644 index 000000000..903949234 --- /dev/null +++ b/src/app/services/cron.ts @@ -0,0 +1,564 @@ +// (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, NgZone } from '@angular/core'; + +import { CoreApp, CoreAppProvider, CoreAppSchema } from '@services/app'; +import { CoreConfig } from '@services/config'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreConstants } from '@core/constants'; +import { SQLiteDB } from '@classes/sqlitedb'; + +import { makeSingleton, Network } from '@singletons/core.singletons'; +import { CoreLogger } from '@singletons/logger'; + +/** + * Interface that all cron handlers must implement. + */ +export interface CoreCronHandler { + /** + * A name to identify the handler. + */ + name: string; + + /** + * Whether the handler is running. Used internally by the provider, there's no need to set it. + */ + running?: boolean; + + /** + * Timeout ID for the handler scheduling. Used internally by the provider, there's no need to set it. + */ + timeout?: number; + + /** + * Returns handler's interval in milliseconds. Defaults to CoreCronDelegate.DEFAULT_INTERVAL. + * + * @return Interval time (in milliseconds). + */ + getInterval?(): number; + + /** + * Check whether the process uses network or not. True if not defined. + * + * @return Whether the process uses network or not + */ + usesNetwork?(): boolean; + + /** + * Check whether it's a synchronization process or not. True if not defined. + * + * @return Whether it's a synchronization process or not. + */ + isSync?(): boolean; + + /** + * Check whether the sync can be executed manually. Call isSync if not defined. + * + * @return Whether the sync can be executed manually. + */ + canManualSync?(): boolean; + + /** + * Execute the process. + * + * @param siteId ID of the site affected. If not defined, all sites. + * @param force Determines if it's a forced execution. + * @return Promise resolved when done. If the promise is rejected, this function will be called again often, + * it shouldn't be abused. + */ + execute?(siteId?: string, force?: boolean): Promise; +} + +/* + * Service to handle cron processes. The registered processes will be executed every certain time. +*/ +@Injectable() +export class CoreCronDelegate { + // Constants. + static DEFAULT_INTERVAL = 3600000; // Default interval is 1 hour. + static MIN_INTERVAL = 300000; // Minimum interval is 5 minutes. + static DESKTOP_MIN_INTERVAL = 60000; // Minimum interval in desktop is 1 minute. + static MAX_TIME_PROCESS = 120000; // Max time a process can block the queue. Defaults to 2 minutes. + + // Variables for database. + protected CRON_TABLE = 'cron'; + protected tableSchema: CoreAppSchema = { + name: 'CoreCronDelegate', + version: 1, + tables: [ + { + name: this.CRON_TABLE, + columns: [ + { + name: 'id', + type: 'TEXT', + primaryKey: true + }, + { + name: 'value', + type: 'INTEGER' + }, + ], + }, + ], + }; + + protected logger; + protected appDB: SQLiteDB; + protected dbReady: Promise; // Promise resolved when the app DB is initialized. + protected handlers: { [s: string]: CoreCronHandler } = {}; + protected queuePromise = Promise.resolve(); + + constructor(zone: NgZone) { + this.logger = CoreLogger.getInstance('CoreCronDelegate'); + + this.appDB = CoreApp.instance.getDB(); + this.dbReady = CoreApp.instance.createTablesFromSchema(this.tableSchema).catch(() => { + // Ignore errors. + }); + + // When the app is re-connected, start network handlers that were stopped. + Network.instance.onConnect().subscribe(() => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + zone.run(() => { + this.startNetworkHandlers(); + }); + }); + + // Export the sync provider so Behat tests can trigger cron tasks without waiting. + if (CoreAppProvider.isAutomated()) { + ( window).cronProvider = this; + } + } + + /** + * Try to execute a handler. It will schedule the next execution once done. + * If the handler cannot be executed or it fails, it will be re-executed after mmCoreCronMinInterval. + * + * @param name Name of the handler. + * @param force Wether the execution is forced (manual sync). + * @param siteId Site ID. If not defined, all sites. + * @return Promise resolved if handler is executed successfully, rejected otherwise. + */ + protected checkAndExecuteHandler(name: string, force?: boolean, siteId?: string): Promise { + if (!this.handlers[name] || !this.handlers[name].execute) { + // Invalid handler. + this.logger.debug('Cannot execute handler because is invalid: ' + name); + + return Promise.reject(null); + } + + const usesNetwork = this.handlerUsesNetwork(name); + const isSync = !force && this.isHandlerSync(name); + let promise; + + if (usesNetwork && !CoreApp.instance.isOnline()) { + // Offline, stop executing. + this.logger.debug('Cannot execute handler because device is offline: ' + name); + this.stopHandler(name); + + return Promise.reject(null); + } + + if (isSync) { + // Check network connection. + promise = CoreConfig.instance.get(CoreConstants.SETTINGS_SYNC_ONLY_ON_WIFI, false).then((syncOnlyOnWifi) => { + return !syncOnlyOnWifi || CoreApp.instance.isWifi(); + }); + } else { + promise = Promise.resolve(true); + } + + return promise.then((execute: boolean) => { + if (!execute) { + // Cannot execute in this network connection, retry soon. + this.logger.debug('Cannot execute handler because device is using limited connection: ' + name); + this.scheduleNextExecution(name, CoreCronDelegate.MIN_INTERVAL); + + return Promise.reject(null); + } + + // Add the execution to the queue. + this.queuePromise = this.queuePromise.catch(() => { + // Ignore errors in previous handlers. + }).then(() => { + return this.executeHandler(name, force, siteId).then(() => { + this.logger.debug(`Execution of handler '${name}' was a success.`); + + return this.setHandlerLastExecutionTime(name, Date.now()).then(() => { + this.scheduleNextExecution(name); + }); + }, (error) => { + // Handler call failed. Retry soon. + this.logger.error(`Execution of handler '${name}' failed.`, error); + this.scheduleNextExecution(name, CoreCronDelegate.MIN_INTERVAL); + + return Promise.reject(null); + }); + }); + + return this.queuePromise; + }); + } + + /** + * Run a handler, cancelling the execution if it takes more than MAX_TIME_PROCESS. + * + * @param name Name of the handler. + * @param force Wether the execution is forced (manual sync). + * @param siteId Site ID. If not defined, all sites. + * @return Promise resolved when the handler finishes or reaches max time, rejected if it fails. + */ + protected executeHandler(name: string, force?: boolean, siteId?: string): Promise { + return new Promise((resolve, reject): void => { + let cancelTimeout; + + this.logger.debug('Executing handler: ' + name); + + // Wrap the call in Promise.resolve to make sure it's a promise. + Promise.resolve(this.handlers[name].execute(siteId, force)).then(resolve).catch(reject).finally(() => { + clearTimeout(cancelTimeout); + }); + + cancelTimeout = setTimeout(() => { + // The handler took too long. Resolve because we don't want to retry soon. + this.logger.debug(`Resolving execution of handler '${name}' because it took too long.`); + resolve(); + }, CoreCronDelegate.MAX_TIME_PROCESS); + }); + } + + /** + * Force execution of synchronization cron tasks without waiting for the scheduled time. + * Please notice that some tasks may not be executed depending on the network connection and sync settings. + * + * @param siteId Site ID. If not defined, all sites. + * @return Promise resolved if all handlers are executed successfully, rejected otherwise. + */ + forceSyncExecution(siteId?: string): Promise { + const promises = []; + + for (const name in this.handlers) { + if (this.isHandlerManualSync(name)) { + // Now force the execution of the handler. + promises.push(this.forceCronHandlerExecution(name, siteId)); + } + } + + return CoreUtils.instance.allPromises(promises); + } + + /** + * Force execution of a cron tasks without waiting for the scheduled time. + * Please notice that some tasks may not be executed depending on the network connection and sync settings. + * + * @param name If provided, the name of the handler. + * @param siteId Site ID. If not defined, all sites. + * @return Promise resolved if handler has been executed successfully, rejected otherwise. + */ + forceCronHandlerExecution(name?: string, siteId?: string): Promise { + const handler = this.handlers[name]; + + // Mark the handler as running (it might be running already). + handler.running = true; + + // Cancel pending timeout. + clearTimeout(handler.timeout); + delete handler.timeout; + + // Now force the execution of the handler. + return this.checkAndExecuteHandler(name, true, siteId); + } + + /** + * Get a handler's interval. + * + * @param name Handler's name. + * @return Handler's interval. + */ + protected getHandlerInterval(name: string): number { + if (!this.handlers[name] || !this.handlers[name].getInterval) { + // Invalid, return default. + return CoreCronDelegate.DEFAULT_INTERVAL; + } + + // Don't allow intervals lower than the minimum. + const minInterval = CoreApp.instance.isDesktop() ? CoreCronDelegate.DESKTOP_MIN_INTERVAL : CoreCronDelegate.MIN_INTERVAL; + const handlerInterval = this.handlers[name].getInterval(); + + if (!handlerInterval) { + return CoreCronDelegate.DEFAULT_INTERVAL; + } else { + return Math.max(minInterval, handlerInterval); + } + } + + /** + * Get a handler's last execution ID. + * + * @param name Handler's name. + * @return Handler's last execution ID. + */ + protected getHandlerLastExecutionId(name: string): string { + return 'last_execution_' + name; + } + + /** + * Get a handler's last execution time. If not defined, return 0. + * + * @param name Handler's name. + * @return Promise resolved with the handler's last execution time. + */ + protected async getHandlerLastExecutionTime(name: string): Promise { + await this.dbReady; + + const id = this.getHandlerLastExecutionId(name); + + try { + const entry = await this.appDB.getRecord(this.CRON_TABLE, { id }); + const time = parseInt(entry.value, 10); + + return isNaN(time) ? 0 : time; + } catch (err) { + return 0; // Not set, return 0. + } + } + + /** + * Check if a handler uses network. Defaults to true. + * + * @param name Handler's name. + * @return True if handler uses network or not defined, false otherwise. + */ + protected handlerUsesNetwork(name: string): boolean { + if (!this.handlers[name] || !this.handlers[name].usesNetwork) { + // Invalid, return default. + return true; + } + + return this.handlers[name].usesNetwork(); + } + + /** + * Check if there is any manual sync handler registered. + * + * @return Whether it has at least 1 manual sync handler. + */ + hasManualSyncHandlers(): boolean { + for (const name in this.handlers) { + if (this.isHandlerManualSync(name)) { + return true; + } + } + + return false; + } + + /** + * Check if there is any sync handler registered. + * + * @return Whether it has at least 1 sync handler. + */ + hasSyncHandlers(): boolean { + for (const name in this.handlers) { + if (this.isHandlerSync(name)) { + return true; + } + } + + return false; + } + + /** + * Check if a handler can be manually synced. Defaults will use isSync instead. + * + * @param name Handler's name. + * @return True if handler is a sync process and can be manually executed or not defined, false otherwise. + */ + protected isHandlerManualSync(name: string): boolean { + if (!this.handlers[name] || !this.handlers[name].canManualSync) { + // Invalid, return default. + return this.isHandlerSync(name); + } + + return this.handlers[name].canManualSync(); + } + + /** + * Check if a handler is a sync process. Defaults to true. + * + * @param name Handler's name. + * @return True if handler is a sync process or not defined, false otherwise. + */ + protected isHandlerSync(name: string): boolean { + if (!this.handlers[name] || !this.handlers[name].isSync) { + // Invalid, return default. + return true; + } + + return this.handlers[name].isSync(); + } + + /** + * Register a handler to be executed every certain time. + * + * @param handler The handler to register. + */ + register(handler: CoreCronHandler): void { + if (!handler || !handler.name) { + // Invalid handler. + return; + } + if (typeof this.handlers[handler.name] != 'undefined') { + this.logger.debug(`The cron handler '${handler.name}' is already registered.`); + + return; + } + + this.logger.debug(`Register handler '${handler.name}' in cron.`); + + handler.running = false; + this.handlers[handler.name] = handler; + + // Start the handler. + this.startHandler(handler.name); + } + + /** + * Schedule a next execution for a handler. + * + * @param name Name of the handler. + * @param time Time to the next execution. If not supplied it will be calculated using the last execution and + * the handler's interval. This param should be used only if it's really necessary. + */ + protected scheduleNextExecution(name: string, time?: number): void { + if (!this.handlers[name]) { + // Invalid handler. + return; + } + if (this.handlers[name].timeout) { + // There's already a pending timeout. + return; + } + + let promise; + + if (time) { + promise = Promise.resolve(time); + } else { + // Get last execution time to check when do we need to execute it. + promise = this.getHandlerLastExecutionTime(name).then((lastExecution) => { + const interval = this.getHandlerInterval(name); + const nextExecution = lastExecution + interval; + + return nextExecution - Date.now(); + }); + } + + promise.then((nextExecution) => { + this.logger.debug(`Scheduling next execution of handler '${name}' in '${nextExecution}' ms`); + if (nextExecution < 0) { + nextExecution = 0; // Big negative numbers aren't executed immediately. + } + + this.handlers[name].timeout = window.setTimeout(() => { + delete this.handlers[name].timeout; + this.checkAndExecuteHandler(name).catch(() => { + // Ignore errors. + }); + }, nextExecution); + }); + } + + /** + * Set a handler's last execution time. + * + * @param name Handler's name. + * @param time Time to set. + * @return Promise resolved when the execution time is saved. + */ + protected async setHandlerLastExecutionTime(name: string, time: number): Promise { + await this.dbReady; + + const id = this.getHandlerLastExecutionId(name); + const entry = { + id, + value: time + }; + + return this.appDB.insertRecord(this.CRON_TABLE, entry); + } + + /** + * Start running a handler periodically. + * + * @param name Name of the handler. + */ + protected startHandler(name: string): void { + if (!this.handlers[name]) { + // Invalid handler. + this.logger.debug(`Cannot start handler '${name}', is invalid.`); + + return; + } + + if (this.handlers[name].running) { + this.logger.debug(`Handler '${name}', is already running.`); + + return; + } + + this.handlers[name].running = true; + + this.scheduleNextExecution(name); + } + + /** + * Start running periodically the handlers that use network. + */ + startNetworkHandlers(): void { + for (const name in this.handlers) { + if (this.handlerUsesNetwork(name)) { + this.startHandler(name); + } + } + } + + /** + * Stop running a handler periodically. + * + * @param name Name of the handler. + */ + protected stopHandler(name: string): void { + if (!this.handlers[name]) { + // Invalid handler. + this.logger.debug(`Cannot stop handler '${name}', is invalid.`); + + return; + } + + if (!this.handlers[name].running) { + this.logger.debug(`Cannot stop handler '${name}', it's not running.`); + + return; + } + + this.handlers[name].running = false; + clearTimeout(this.handlers[name].timeout); + delete this.handlers[name].timeout; + } +} + +export class CoreCron extends makeSingleton(CoreCronDelegate) {} diff --git a/src/app/services/db.ts b/src/app/services/db.ts new file mode 100644 index 000000000..1244ed1fa --- /dev/null +++ b/src/app/services/db.ts @@ -0,0 +1,85 @@ +// (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 { SQLiteDB } from '@classes/sqlitedb'; +import { SQLiteDBMock } from '@core/emulator/classes/sqlitedb'; +import { makeSingleton, SQLite, Platform } from '@singletons/core.singletons'; + +/** + * This service allows interacting with the local database to store and retrieve data. + */ +@Injectable() +export class CoreDbProvider { + + protected dbInstances = {}; + + constructor() { } + + /** + * Get or create a database object. + * + * The database objects are cached statically. + * + * @param name DB name. + * @param forceNew True if it should always create a new instance. + * @return DB. + */ + getDB(name: string, forceNew?: boolean): SQLiteDB { + if (typeof this.dbInstances[name] === 'undefined' || forceNew) { + if (Platform.instance.is('cordova')) { + this.dbInstances[name] = new SQLiteDB(name); + } else { + this.dbInstances[name] = new SQLiteDBMock(name); + } + } + + return this.dbInstances[name]; + } + + /** + * Delete a DB. + * + * @param name DB name. + * @return Promise resolved when the DB is deleted. + */ + deleteDB(name: string): Promise { + let promise; + + if (typeof this.dbInstances[name] != 'undefined') { + // Close the database first. + promise = this.dbInstances[name].close(); + } else { + promise = Promise.resolve(); + } + + return promise.then(() => { + const db = this.dbInstances[name]; + delete this.dbInstances[name]; + + if (Platform.instance.is('cordova')) { + return SQLite.instance.deleteDatabase({ + name, + location: 'default' + }); + } else { + // In WebSQL we cannot delete the database, just empty it. + return db.emptyDatabase(); + } + }); + } +} + +export class CoreDB extends makeSingleton(CoreDbProvider) {} diff --git a/src/app/services/events.ts b/src/app/services/events.ts new file mode 100644 index 000000000..8e4afa295 --- /dev/null +++ b/src/app/services/events.ts @@ -0,0 +1,208 @@ +// (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 { Subject } from 'rxjs'; + +import { CoreLogger } from '@singletons/logger'; +import { makeSingleton } from '@singletons/core.singletons'; + +/** + * Observer instance to stop listening to an event. + */ +export interface CoreEventObserver { + /** + * Stop the observer. + */ + off: () => void; +} + +/* + * Service to send and listen to events. + */ +@Injectable() +export class CoreEventsProvider { + static SESSION_EXPIRED = 'session_expired'; + static PASSWORD_CHANGE_FORCED = 'password_change_forced'; + static USER_NOT_FULLY_SETUP = 'user_not_fully_setup'; + static SITE_POLICY_NOT_AGREED = 'site_policy_not_agreed'; + static LOGIN = 'login'; + static LOGOUT = 'logout'; + static LANGUAGE_CHANGED = 'language_changed'; + static NOTIFICATION_SOUND_CHANGED = 'notification_sound_changed'; + static SITE_ADDED = 'site_added'; + static SITE_UPDATED = 'site_updated'; + static SITE_DELETED = 'site_deleted'; + static COMPLETION_MODULE_VIEWED = 'completion_module_viewed'; + static USER_DELETED = 'user_deleted'; + static PACKAGE_STATUS_CHANGED = 'package_status_changed'; + static COURSE_STATUS_CHANGED = 'course_status_changed'; + static SECTION_STATUS_CHANGED = 'section_status_changed'; + static COMPONENT_FILE_ACTION = 'component_file_action'; + static SITE_PLUGINS_LOADED = 'site_plugins_loaded'; + static SITE_PLUGINS_COURSE_RESTRICT_UPDATED = 'site_plugins_course_restrict_updated'; + static LOGIN_SITE_CHECKED = 'login_site_checked'; + static LOGIN_SITE_UNCHECKED = 'login_site_unchecked'; + static IAB_LOAD_START = 'inappbrowser_load_start'; + static IAB_EXIT = 'inappbrowser_exit'; + static APP_LAUNCHED_URL = 'app_launched_url'; // App opened with a certain URL (custom URL scheme). + static FILE_SHARED = 'file_shared'; + static KEYBOARD_CHANGE = 'keyboard_change'; + static CORE_LOADING_CHANGED = 'core_loading_changed'; + static ORIENTATION_CHANGE = 'orientation_change'; + static LOAD_PAGE_MAIN_MENU = 'load_page_main_menu'; + static SEND_ON_ENTER_CHANGED = 'send_on_enter_changed'; + static MAIN_MENU_OPEN = 'main_menu_open'; + static SELECT_COURSE_TAB = 'select_course_tab'; + static WS_CACHE_INVALIDATED = 'ws_cache_invalidated'; + static SITE_STORAGE_DELETED = 'site_storage_deleted'; + static FORM_ACTION = 'form_action'; + static ACTIVITY_DATA_SENT = 'activity_data_sent'; + + protected logger: CoreLogger; + protected observables: { [s: string]: Subject } = {}; + protected uniqueEvents = {}; + + constructor() { + this.logger = CoreLogger.getInstance('CoreEventsProvider'); + } + + /** + * Listen for a certain event. To stop listening to the event: + * let observer = eventsProvider.on('something', myCallBack); + * ... + * observer.off(); + * + * @param eventName Name of the event to listen to. + * @param callBack Function to call when the event is triggered. + * @param siteId Site where to trigger the event. Undefined won't check the site. + * @return Observer to stop listening. + */ + on(eventName: string, callBack: (value: any) => void, siteId?: string): CoreEventObserver { + // If it's a unique event and has been triggered already, call the callBack. + // We don't need to create an observer because the event won't be triggered again. + if (this.uniqueEvents[eventName]) { + callBack(this.uniqueEvents[eventName].data); + + // Return a fake observer to prevent errors. + return { + off: (): void => { + // Nothing to do. + } + }; + } + + this.logger.debug(`New observer listening to event '${eventName}'`); + + if (typeof this.observables[eventName] == 'undefined') { + // No observable for this event, create a new one. + this.observables[eventName] = new Subject(); + } + + const subscription = this.observables[eventName].subscribe((value: any) => { + if (!siteId || value.siteId == siteId) { + callBack(value); + } + }); + + // Create and return a CoreEventObserver. + return { + off: (): void => { + this.logger.debug(`Stop listening to event '${eventName}'`); + subscription.unsubscribe(); + } + }; + } + + /** + * Listen for several events. To stop listening to the events: + * let observer = eventsProvider.onMultiple(['something', 'another'], myCallBack); + * ... + * observer.off(); + * + * @param eventNames Names of the events to listen to. + * @param callBack Function to call when any of the events is triggered. + * @param siteId Site where to trigger the event. Undefined won't check the site. + * @return Observer to stop listening. + */ + onMultiple(eventNames: string[], callBack: (value: any) => void, siteId?: string): CoreEventObserver { + + const observers = eventNames.map((name) => { + return this.on(name, callBack, siteId); + }); + + // Create and return a CoreEventObserver. + return { + off: (): void => { + observers.forEach((observer) => { + observer.off(); + }); + } + }; + } + + /** + * Triggers an event, notifying all the observers. + * + * @param event Name of the event to trigger. + * @param data Data to pass to the observers. + * @param siteId Site where to trigger the event. Undefined means no Site. + */ + trigger(eventName: string, data?: any, siteId?: string): void { + this.logger.debug(`Event '${eventName}' triggered.`); + if (this.observables[eventName]) { + if (siteId) { + if (!data) { + data = {}; + } + data.siteId = siteId; + } + this.observables[eventName].next(data); + } + } + + /** + * Triggers a unique event, notifying all the observers. If the event has already been triggered, don't do anything. + * + * @param event Name of the event to trigger. + * @param data Data to pass to the observers. + * @param siteId Site where to trigger the event. Undefined means no Site. + */ + triggerUnique(eventName: string, data: any, siteId?: string): void { + if (this.uniqueEvents[eventName]) { + this.logger.debug(`Unique event '${eventName}' ignored because it was already triggered.`); + } else { + this.logger.debug(`Unique event '${eventName}' triggered.`); + + if (siteId) { + if (!data) { + data = {}; + } + data.siteId = siteId; + } + + // Store the data so it can be passed to observers that register from now on. + this.uniqueEvents[eventName] = { + data, + }; + + // Now pass the data to observers. + if (this.observables[eventName]) { + this.observables[eventName].next(data); + } + } + } +} + +export class CoreEvents extends makeSingleton(CoreEventsProvider) {} diff --git a/src/app/services/file-helper.ts b/src/app/services/file-helper.ts new file mode 100644 index 000000000..f0348a9a5 --- /dev/null +++ b/src/app/services/file-helper.ts @@ -0,0 +1,378 @@ +// (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 { CoreApp } from '@services/app'; +import { CoreFile } from '@services/file'; +import { CoreFilepool } from '@services/filepool'; +import { CoreSites } from '@services/sites'; +import { CoreWS } from '@services/ws'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreConstants } from '@core/constants'; +import { makeSingleton, Translate } from '@singletons/core.singletons'; + +/** + * Provider to provide some helper functions regarding files and packages. + */ +@Injectable() +export class CoreFileHelperProvider { + + /** + * Convenience function to open a file, downloading it if needed. + * + * @param file The file to download. + * @param component The component to link the file to. + * @param componentId An ID to use in conjunction with the component. + * @param state The file's state. If not provided, it will be calculated. + * @param onProgress Function to call on progress. + * @param siteId The site ID. If not defined, current site. + * @return Resolved on success. + */ + async downloadAndOpenFile(file: any, component: string, componentId: string | number, state?: string, + onProgress?: (event: any) => any, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const fileUrl = this.getFileUrl(file); + const timemodified = this.getFileTimemodified(file); + + if (!this.isOpenableInApp(file)) { + await this.showConfirmOpenUnsupportedFile(); + } + + let url = await this.downloadFileIfNeeded(file, fileUrl, component, componentId, timemodified, state, onProgress, siteId); + + if (!url) { + return; + } + + if (!CoreUrlUtils.instance.isLocalFileUrl(url)) { + /* In iOS, if we use the same URL in embedded browser and background download then the download only + downloads a few bytes (cached ones). Add a hash to the URL so both URLs are different. */ + url = url + '#moodlemobile-embedded'; + + try { + await CoreUtils.instance.openOnlineFile(url); + + return; + } catch (error) { + // Error opening the file, some apps don't allow opening online files. + if (!CoreFile.instance.isAvailable()) { + throw error; + } + + // Get the state. + if (!state) { + state = await CoreFilepool.instance.getFileStateByUrl(siteId, fileUrl, timemodified); + } + + if (state == CoreConstants.DOWNLOADING) { + throw new Error(Translate.instance.instant('core.erroropenfiledownloading')); + } + + if (state === CoreConstants.NOT_DOWNLOADED) { + // File is not downloaded, download and then return the local URL. + url = await this.downloadFile(fileUrl, component, componentId, timemodified, onProgress, file, siteId); + } else { + // File is outdated and can't be opened in online, return the local URL. + url = await CoreFilepool.instance.getInternalUrlByUrl(siteId, fileUrl); + } + } + } + + return CoreUtils.instance.openFile(url); + } + + /** + * Download a file if it needs to be downloaded. + * + * @param file The file to download. + * @param fileUrl The file URL. + * @param component The component to link the file to. + * @param componentId An ID to use in conjunction with the component. + * @param timemodified The time this file was modified. + * @param state The file's state. If not provided, it will be calculated. + * @param onProgress Function to call on progress. + * @param siteId The site ID. If not defined, current site. + * @return Resolved with the URL to use on success. + */ + protected downloadFileIfNeeded(file: any, fileUrl: string, component?: string, componentId?: string | number, + timemodified?: number, state?: string, onProgress?: (event: any) => any, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + return CoreSites.instance.getSite(siteId).then((site) => { + return site.checkAndFixPluginfileURL(fileUrl); + }).then((fixedUrl) => { + + if (CoreFile.instance.isAvailable()) { + let promise; + if (state) { + promise = Promise.resolve(state); + } else { + // Calculate the state. + promise = CoreFilepool.instance.getFileStateByUrl(siteId, fileUrl, timemodified); + } + + return promise.then((state) => { + // The file system is available. + const isWifi = CoreApp.instance.isWifi(); + const isOnline = CoreApp.instance.isOnline(); + + if (state == CoreConstants.DOWNLOADED) { + // File is downloaded, get the local file URL. + return CoreFilepool.instance.getUrlByUrl( + siteId, fileUrl, component, componentId, timemodified, false, false, file); + } else { + if (!isOnline && !this.isStateDownloaded(state)) { + // Not downloaded and user is offline, reject. + return Promise.reject(Translate.instance.instant('core.networkerrormsg')); + } + + if (onProgress) { + // This call can take a while. Send a fake event to notify that we're doing some calculations. + onProgress({calculating: true}); + } + + return CoreFilepool.instance.shouldDownloadBeforeOpen(fixedUrl, file.filesize).then(() => { + if (state == CoreConstants.DOWNLOADING) { + // It's already downloading, stop. + return; + } + + // Download and then return the local URL. + return this.downloadFile(fileUrl, component, componentId, timemodified, onProgress, file, siteId); + }, () => { + // Start the download if in wifi, but return the URL right away so the file is opened. + if (isWifi) { + this.downloadFile(fileUrl, component, componentId, timemodified, onProgress, file, siteId); + } + + if (!this.isStateDownloaded(state) || isOnline) { + // Not downloaded or online, return the online URL. + return fixedUrl; + } else { + // Outdated but offline, so we return the local URL. + return CoreFilepool.instance.getUrlByUrl( + siteId, fileUrl, component, componentId, timemodified, false, false, file); + } + }); + } + }); + } else { + // Use the online URL. + return fixedUrl; + } + }); + } + + /** + * Download the file. + * + * @param fileUrl The file URL. + * @param component The component to link the file to. + * @param componentId An ID to use in conjunction with the component. + * @param timemodified The time this file was modified. + * @param onProgress Function to call on progress. + * @param file The file to download. + * @param siteId The site ID. If not defined, current site. + * @return Resolved with internal URL on success, rejected otherwise. + */ + downloadFile(fileUrl: string, component?: string, componentId?: string | number, timemodified?: number, + onProgress?: (event: any) => any, file?: any, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + // Get the site and check if it can download files. + return CoreSites.instance.getSite(siteId).then((site) => { + if (!site.canDownloadFiles()) { + return Promise.reject(Translate.instance.instant('core.cannotdownloadfiles')); + } + + return CoreFilepool.instance.downloadUrl(siteId, fileUrl, false, component, componentId, + timemodified, onProgress, undefined, file).catch((error) => { + + // Download failed, check the state again to see if the file was downloaded before. + return CoreFilepool.instance.getFileStateByUrl(siteId, fileUrl, timemodified).then((state) => { + if (this.isStateDownloaded(state)) { + return CoreFilepool.instance.getInternalUrlByUrl(siteId, fileUrl); + } else { + return Promise.reject(error); + } + }); + }); + }); + } + + /** + * Get the file's URL. + * + * @param file The file. + */ + getFileUrl(file: any): string { + return file.fileurl || file.url; + } + + /** + * Get the file's timemodified. + * + * @param file The file. + */ + getFileTimemodified(file: any): number { + return file.timemodified || 0; + } + + /** + * Check if a state is downloaded or outdated. + * + * @param state The state to check. + */ + isStateDownloaded(state: string): boolean { + return state === CoreConstants.DOWNLOADED || state === CoreConstants.OUTDATED; + } + + /** + * Whether the file has to be opened in browser (external repository). + * The file must have a mimetype attribute. + * + * @param file The file to check. + * @return Whether the file should be opened in browser. + */ + shouldOpenInBrowser(file: any): boolean { + if (!file || !file.isexternalfile || !file.mimetype) { + return false; + } + + const mimetype = file.mimetype; + if (mimetype.indexOf('application/vnd.google-apps.') != -1) { + // Google Docs file, always open in browser. + return true; + } + + if (file.repositorytype == 'onedrive') { + // In OneDrive, open in browser the office docs + return mimetype.indexOf('application/vnd.openxmlformats-officedocument') != -1 || + mimetype == 'text/plain' || mimetype == 'document/unknown'; + } + + return false; + } + + /** + * Calculate the total size of the given files. + * + * @param files The files to check. + * @return Total files size. + */ + async getTotalFilesSize(files: any[]): Promise { + let totalSize = 0; + + for (const file of files) { + totalSize += await this.getFileSize(file); + } + + return totalSize; + } + + /** + * Calculate the file size. + * + * @param file The file to check. + * @return File size. + */ + async getFileSize(file: any): Promise { + if (file.filesize) { + return file.filesize; + } + + // If it's a remote file. First check if we have the file downloaded since it's more reliable. + if (file.filename && !file.name) { + try { + const siteId = CoreSites.instance.getCurrentSiteId(); + + const path = await CoreFilepool.instance.getFilePathByUrl(siteId, file.fileurl); + const fileEntry = await CoreFile.instance.getFile(path); + const fileObject = await CoreFile.instance.getFileObjectFromFileEntry(fileEntry); + + return fileObject.size; + } catch (error) { + // Error getting the file, maybe it's not downloaded. Get remote size. + const size = await CoreWS.instance.getRemoteFileSize(file.fileurl); + + if (size === -1) { + throw new Error('Couldn\'t determine file size: ' + file.fileurl); + } + + return size; + } + } + + // If it's a local file, get its size. + if (file.name) { + const fileObject = await CoreFile.instance.getFileObjectFromFileEntry(file); + + return fileObject.size; + } + + throw new Error('Couldn\'t determine file size: ' + file.fileurl); + } + + /** + * Is the file openable in app. + * + * @param file The file to check. + * @return bool. + */ + isOpenableInApp(file: {filename?: string, name?: string}): boolean { + const re = /(?:\.([^.]+))?$/; + + const ext = re.exec(file.filename || file.name)[1]; + + return !this.isFileTypeExcludedInApp(ext); + } + + /** + * Show a confirm asking the user if we wants to open the file. + * + * @param onlyDownload Whether the user is only downloading the file, not opening it. + * @return Promise resolved if confirmed, rejected otherwise. + */ + showConfirmOpenUnsupportedFile(onlyDownload?: boolean): Promise { + const message = Translate.instance.instant('core.cannotopeninapp' + (onlyDownload ? 'download' : '')); + const okButton = Translate.instance.instant(onlyDownload ? 'core.downloadfile' : 'core.openfile'); + + return CoreDomUtils.instance.showConfirm(message, undefined, okButton, undefined, { cssClass: 'core-modal-force-on-top' }); + } + + /** + * Is the file type excluded to open in app. + * + * @param file The file to check. + * @return bool. + */ + isFileTypeExcludedInApp(fileType: string): boolean { + const currentSite = CoreSites.instance.getCurrentSite(); + const fileTypeExcludeList = currentSite && currentSite.getStoredConfig('tool_mobile_filetypeexclusionlist'); + + if (!fileTypeExcludeList) { + return false; + } + + const regEx = new RegExp('(,|^)' + fileType + '(,|$)', 'g'); + + return !!fileTypeExcludeList.match(regEx); + } +} + +export class CoreFileHelper extends makeSingleton(CoreFileHelperProvider) {} diff --git a/src/app/services/file-session.ts b/src/app/services/file-session.ts new file mode 100644 index 000000000..64a60e890 --- /dev/null +++ b/src/app/services/file-session.ts @@ -0,0 +1,152 @@ +// (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 { CoreSites } from '@services/sites'; +import { makeSingleton } from '@singletons/core.singletons'; + +/** + * Helper to store some temporary data for file submission. + * + * It uses siteId and component name to index the files. + * Every component can provide a File area identifier to indentify every file list on the session. + * This value can be the activity id or a mix of name and numbers. + */ +@Injectable() +export class CoreFileSessionProvider { + protected files = {}; + + constructor() { } + + /** + * Add a file to the session. + * + * @param component Component Name. + * @param id File area identifier. + * @param file File to add. + * @param siteId Site ID. If not defined, current site. + */ + addFile(component: string, id: string | number, file: any, siteId?: string): void { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + this.initFileArea(component, id, siteId); + + this.files[siteId][component][id].push(file); + } + + /** + * Clear files stored in session. + * + * @param component Component Name. + * @param id File area identifier. + * @param siteId Site ID. If not defined, current site. + */ + clearFiles(component: string, id: string | number, siteId?: string): void { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + if (this.files[siteId] && this.files[siteId][component] && this.files[siteId][component][id]) { + this.files[siteId][component][id] = []; + } + } + + /** + * Get files stored in session. + * + * @param component Component Name. + * @param id File area identifier. + * @param siteId Site ID. If not defined, current site. + * @return Array of files in session. + */ + getFiles(component: string, id: string | number, siteId?: string): any[] { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + if (this.files[siteId] && this.files[siteId][component] && this.files[siteId][component][id]) { + return this.files[siteId][component][id]; + } + + return []; + } + + /** + * Initializes the filearea to store the file. + * + * @param component Component Name. + * @param id File area identifier. + * @param siteId Site ID. If not defined, current site. + */ + protected initFileArea(component: string, id: string | number, siteId?: string): void { + if (!this.files[siteId]) { + this.files[siteId] = {}; + } + + if (!this.files[siteId][component]) { + this.files[siteId][component] = {}; + } + + if (!this.files[siteId][component][id]) { + this.files[siteId][component][id] = []; + } + } + + /** + * Remove a file stored in session. + * + * @param component Component Name. + * @param id File area identifier. + * @param file File to remove. The instance should be exactly the same as the one stored in session. + * @param siteId Site ID. If not defined, current site. + */ + removeFile(component: string, id: string | number, file: any, siteId?: string): void { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + if (this.files[siteId] && this.files[siteId][component] && this.files[siteId][component][id]) { + const position = this.files[siteId][component][id].indexOf(file); + if (position != -1) { + this.files[siteId][component][id].splice(position, 1); + } + } + } + + /** + * Remove a file stored in session. + * + * @param component Component Name. + * @param id File area identifier. + * @param index Position of the file to remove. + * @param siteId Site ID. If not defined, current site. + */ + removeFileByIndex(component: string, id: string | number, index: number, siteId?: string): void { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + if (this.files[siteId] && this.files[siteId][component] && this.files[siteId][component][id] && index >= 0 && + index < this.files[siteId][component][id].length) { + this.files[siteId][component][id].splice(index, 1); + } + } + + /** + * Set a group of files in the session. + * + * @param component Component Name. + * @param id File area identifier. + * @param newFiles Files to set. + * @param siteId Site ID. If not defined, current site. + */ + setFiles(component: string, id: string | number, newFiles: any[], siteId?: string): void { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + this.initFileArea(component, id, siteId); + + this.files[siteId][component][id] = newFiles; + } +} + +export class CoreFileSession extends makeSingleton(CoreFileSessionProvider) {} diff --git a/src/app/services/file.ts b/src/app/services/file.ts new file mode 100644 index 000000000..d11cc422e --- /dev/null +++ b/src/app/services/file.ts @@ -0,0 +1,1277 @@ +// (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 { FileEntry, DirectoryEntry, Entry, Metadata } from '@ionic-native/file'; + +import { CoreApp } from '@services/app'; +import { CoreMimetypeUtils } from '@services/utils/mimetype'; +import { CoreTextUtils } from '@services/utils/text'; +import CoreConfigConstants from '@app/config.json'; + +import { CoreLogger } from '@singletons/logger'; +import { makeSingleton, File, Zip, Platform } from '@singletons/core.singletons'; + +/** + * Progress event used when writing a file data into a file. + */ +export type CoreFileProgressEvent = { + /** + * Whether the values are reliabñe. + */ + lengthComputable?: boolean; + + /** + * Number of treated bytes. + */ + loaded?: number; + + /** + * Total of bytes. + */ + total?: number; +}; + +/** + * Progress function. + */ +export type CoreFileProgressFunction = (event: CoreFileProgressEvent) => void; + +/** + * Factory to interact with the file system. + */ +@Injectable() +export class CoreFileProvider { + // Formats to read a file. + static FORMATTEXT = 0; + static FORMATDATAURL = 1; + static FORMATBINARYSTRING = 2; + static FORMATARRAYBUFFER = 3; + static FORMATJSON = 4; + + // Folders. + static SITESFOLDER = 'sites'; + static TMPFOLDER = 'tmp'; + + static CHUNK_SIZE = 1048576; // 1 MB. Same chunk size as Ionic Native. + + protected logger: CoreLogger; + protected initialized = false; + protected basePath = ''; + protected isHTMLAPI = false; + + constructor() { + + this.logger = CoreLogger.getInstance('CoreFileProvider'); + + if (CoreApp.instance.isAndroid() && !Object.getOwnPropertyDescriptor(FileReader.prototype, 'onloadend')) { + // Cordova File plugin creates some getters and setter for FileReader, but Ionic's polyfills override them in Android. + // Create the getters and setters again. This code comes from FileReader.js in cordova-plugin-file. + // @todo: Check if this is still needed. + this.defineGetterSetter(FileReader.prototype, 'readyState', function(): any { + return this._localURL ? this._readyState : this._realReader.readyState; + }); + + this.defineGetterSetter(FileReader.prototype, 'error', function(): any { + return this._localURL ? this._error : this._realReader.error; + }); + + this.defineGetterSetter(FileReader.prototype, 'result', function(): any { + return this._localURL ? this._result : this._realReader.result; + }); + + this.defineEvent('onloadstart'); + this.defineEvent('onprogress'); + this.defineEvent('onload'); + this.defineEvent('onerror'); + this.defineEvent('onloadend'); + this.defineEvent('onabort'); + } + } + + /** + * Define an event for FileReader. + * + * @param eventName Name of the event. + */ + protected defineEvent(eventName: string): void { + this.defineGetterSetter(FileReader.prototype, eventName, function(): any { + return this._realReader[eventName] || null; + }, function(value: any): void { + this._realReader[eventName] = value; + }); + } + + /** + * Define a getter and, optionally, a setter for a certain property in an object. + * + * @param obj Object to set the getter/setter for. + * @param key Name of the property where to set them. + * @param getFunc The getter function. + * @param setFunc The setter function. + */ + protected defineGetterSetter(obj: any, key: string, getFunc: () => any, setFunc?: (value?: any) => any): void { + if (Object.defineProperty) { + const desc: any = { + get: getFunc, + configurable: true + }; + + if (setFunc) { + desc.set = setFunc; + } + + Object.defineProperty(obj, key, desc); + } else { + obj.__defineGetter__(key, getFunc); + if (setFunc) { + obj.__defineSetter__(key, setFunc); + } + } + } + + /** + * Sets basePath to use with HTML API. Reserved for core use. + * + * @param path Base path to use. + */ + setHTMLBasePath(path: string): void { + this.isHTMLAPI = true; + this.basePath = path; + } + + /** + * Checks if we're using HTML API. + * + * @return True if uses HTML API, false otherwise. + */ + usesHTMLAPI(): boolean { + return this.isHTMLAPI; + } + + /** + * Initialize basePath based on the OS if it's not initialized already. + * + * @return Promise to be resolved when the initialization is finished. + */ + init(): Promise { + if (this.initialized) { + return Promise.resolve(); + } + + return Platform.instance.ready().then(() => { + + if (CoreApp.instance.isAndroid()) { + this.basePath = File.instance.externalApplicationStorageDirectory || this.basePath; + } else if (CoreApp.instance.isIOS()) { + this.basePath = File.instance.documentsDirectory || this.basePath; + } else if (!this.isAvailable() || this.basePath === '') { + this.logger.error('Error getting device OS.'); + + return Promise.reject(null); + } + + this.initialized = true; + this.logger.debug('FS initialized: ' + this.basePath); + }); + } + + /** + * Check if the plugin is available. + * + * @return Whether the plugin is available. + */ + isAvailable(): boolean { + return typeof window.resolveLocalFileSystemURL !== 'undefined'; + } + + /** + * Get a file. + * + * @param path Relative path to the file. + * @return Promise resolved when the file is retrieved. + */ + getFile(path: string): Promise { + return this.init().then(() => { + this.logger.debug('Get file: ' + path); + + return File.instance.resolveLocalFilesystemUrl(this.addBasePathIfNeeded(path)); + }).then((entry) => { + return entry; + }); + } + + /** + * Get a directory. + * + * @param path Relative path to the directory. + * @return Promise resolved when the directory is retrieved. + */ + getDir(path: string): Promise { + return this.init().then(() => { + this.logger.debug('Get directory: ' + path); + + return File.instance.resolveDirectoryUrl(this.addBasePathIfNeeded(path)); + }); + } + + /** + * Get site folder path. + * + * @param siteId Site ID. + * @return Site folder path. + */ + getSiteFolder(siteId: string): string { + return CoreFileProvider.SITESFOLDER + '/' + siteId; + } + + /** + * Create a directory or a file. + * + * @param isDirectory True if a directory should be created, false if it should create a file. + * @param path Relative path to the dir/file. + * @param failIfExists True if it should fail if the dir/file exists, false otherwise. + * @param base Base path to create the dir/file in. If not set, use basePath. + * @return Promise to be resolved when the dir/file is created. + */ + protected create(isDirectory: boolean, path: string, failIfExists?: boolean, base?: string): Promise { + return this.init().then(() => { + // Remove basePath if it's in the path. + path = this.removeStartingSlash(path.replace(this.basePath, '')); + base = base || this.basePath; + + if (path.indexOf('/') == -1) { + if (isDirectory) { + this.logger.debug('Create dir ' + path + ' in ' + base); + + return File.instance.createDir(base, path, !failIfExists); + } else { + this.logger.debug('Create file ' + path + ' in ' + base); + + return File.instance.createFile(base, path, !failIfExists); + } + } else { + // The file plugin doesn't allow creating more than 1 level at a time (e.g. tmp/folder). + // We need to create them 1 by 1. + const firstDir = path.substr(0, path.indexOf('/')); + const restOfPath = path.substr(path.indexOf('/') + 1); + + this.logger.debug('Create dir ' + firstDir + ' in ' + base); + + return File.instance.createDir(base, firstDir, true).then((newDirEntry) => { + return this.create(isDirectory, restOfPath, failIfExists, newDirEntry.toURL()); + }).catch((error) => { + this.logger.error('Error creating directory ' + firstDir + ' in ' + base); + + return Promise.reject(error); + }); + } + }); + } + + /** + * Create a directory. + * + * @param path Relative path to the directory. + * @param failIfExists True if it should fail if the directory exists, false otherwise. + * @return Promise to be resolved when the directory is created. + */ + createDir(path: string, failIfExists?: boolean): Promise { + return this.create(true, path, failIfExists); + } + + /** + * Create a file. + * + * @param path Relative path to the file. + * @param failIfExists True if it should fail if the file exists, false otherwise.. + * @return Promise to be resolved when the file is created. + */ + createFile(path: string, failIfExists?: boolean): Promise { + return this.create(false, path, failIfExists); + } + + /** + * Removes a directory and all its contents. + * + * @param path Relative path to the directory. + * @return Promise to be resolved when the directory is deleted. + */ + removeDir(path: string): Promise { + return this.init().then(() => { + // Remove basePath if it's in the path. + path = this.removeStartingSlash(path.replace(this.basePath, '')); + this.logger.debug('Remove directory: ' + path); + + return File.instance.removeRecursively(this.basePath, path); + }); + } + + /** + * Removes a file and all its contents. + * + * @param path Relative path to the file. + * @return Promise to be resolved when the file is deleted. + */ + removeFile(path: string): Promise { + return this.init().then(() => { + // Remove basePath if it's in the path. + path = this.removeStartingSlash(path.replace(this.basePath, '')); + this.logger.debug('Remove file: ' + path); + + return File.instance.removeFile(this.basePath, path).catch((error) => { + // The delete can fail if the path has encoded characters. Try again if that's the case. + const decodedPath = decodeURI(path); + + if (decodedPath != path) { + return File.instance.removeFile(this.basePath, decodedPath); + } else { + return Promise.reject(error); + } + }); + }); + } + + /** + * Removes a file given its FileEntry. + * + * @param fileEntry File Entry. + * @return Promise resolved when the file is deleted. + */ + removeFileByFileEntry(fileEntry: any): Promise { + return new Promise((resolve, reject): void => { + fileEntry.remove(resolve, reject); + }); + } + + /** + * Retrieve the contents of a directory (not subdirectories). + * + * @param path Relative path to the directory. + * @return Promise to be resolved when the contents are retrieved. + */ + getDirectoryContents(path: string): Promise { + return this.init().then(() => { + // Remove basePath if it's in the path. + path = this.removeStartingSlash(path.replace(this.basePath, '')); + this.logger.debug('Get contents of dir: ' + path); + + return File.instance.listDir(this.basePath, path); + }); + } + + /** + * Calculate the size of a directory or a file. + * + * @param entry Directory or file. + * @return Promise to be resolved when the size is calculated. + */ + protected getSize(entry: any): Promise { + return new Promise((resolve, reject): void => { + if (entry.isDirectory) { + const directoryReader = entry.createReader(); + directoryReader.readEntries((entries) => { + + const promises = []; + for (let i = 0; i < entries.length; i++) { + promises.push(this.getSize(entries[i])); + } + + Promise.all(promises).then((sizes) => { + + let directorySize = 0; + for (let i = 0; i < sizes.length; i++) { + const fileSize = Number(sizes[i]); + if (isNaN(fileSize)) { + reject(); + + return; + } + directorySize += fileSize; + } + resolve(directorySize); + + }, reject); + + }, reject); + + } else if (entry.isFile) { + entry.file((file) => { + resolve(file.size); + }, reject); + } + }); + } + + /** + * Calculate the size of a directory. + * + * @param path Relative path to the directory. + * @return Promise to be resolved when the size is calculated. + */ + getDirectorySize(path: string): Promise { + // Remove basePath if it's in the path. + path = this.removeStartingSlash(path.replace(this.basePath, '')); + + this.logger.debug('Get size of dir: ' + path); + + return this.getDir(path).then((dirEntry) => { + return this.getSize(dirEntry); + }); + } + + /** + * Calculate the size of a file. + * + * @param path Relative path to the file. + * @return Promise to be resolved when the size is calculated. + */ + getFileSize(path: string): Promise { + // Remove basePath if it's in the path. + path = this.removeStartingSlash(path.replace(this.basePath, '')); + + this.logger.debug('Get size of file: ' + path); + + return this.getFile(path).then((fileEntry) => { + return this.getSize(fileEntry); + }); + } + + /** + * Get file object from a FileEntry. + * + * @param path Relative path to the file. + * @return Promise to be resolved when the file is retrieved. + */ + getFileObjectFromFileEntry(entry: FileEntry): Promise { + return new Promise((resolve, reject): void => { + this.logger.debug('Get file object of: ' + entry.fullPath); + entry.file(resolve, reject); + }); + } + + /** + * Calculate the free space in the disk. + * Please notice that this function isn't reliable and it's not documented in the Cordova File plugin. + * + * @return Promise resolved with the estimated free space in bytes. + */ + calculateFreeSpace(): Promise { + return File.instance.getFreeDiskSpace().then((size) => { + if (CoreApp.instance.isIOS()) { + // In iOS the size is in bytes. + return Number(size); + } + + // The size is in KB, convert it to bytes. + return Number(size) * 1024; + }); + } + + /** + * Normalize a filename that usually comes URL encoded. + * + * @param filename The file name. + * @return The file name normalized. + */ + normalizeFileName(filename: string): string { + filename = CoreTextUtils.instance.decodeURIComponent(filename); + + return filename; + } + + /** + * Read a file from local file system. + * + * @param path Relative path to the file. + * @param format Format to read the file. Must be one of: + * FORMATTEXT + * FORMATDATAURL + * FORMATBINARYSTRING + * FORMATARRAYBUFFER + * FORMATJSON + * @return Promise to be resolved when the file is read. + */ + readFile(path: string, format: number = CoreFileProvider.FORMATTEXT): Promise { + // Remove basePath if it's in the path. + path = this.removeStartingSlash(path.replace(this.basePath, '')); + this.logger.debug('Read file ' + path + ' with format ' + format); + + switch (format) { + case CoreFileProvider.FORMATDATAURL: + return File.instance.readAsDataURL(this.basePath, path); + case CoreFileProvider.FORMATBINARYSTRING: + return File.instance.readAsBinaryString(this.basePath, path); + case CoreFileProvider.FORMATARRAYBUFFER: + return File.instance.readAsArrayBuffer(this.basePath, path); + case CoreFileProvider.FORMATJSON: + return File.instance.readAsText(this.basePath, path).then((text) => { + const parsed = CoreTextUtils.instance.parseJSON(text, null); + + if (parsed == null && text != null) { + return Promise.reject('Error parsing JSON file: ' + path); + } + + return parsed; + }); + default: + return File.instance.readAsText(this.basePath, path); + } + } + + /** + * Read file contents from a file data object. + * + * @param fileData File's data. + * @param format Format to read the file. Must be one of: + * FORMATTEXT + * FORMATDATAURL + * FORMATBINARYSTRING + * FORMATARRAYBUFFER + * FORMATJSON + * @return Promise to be resolved when the file is read. + */ + readFileData(fileData: any, format: number = CoreFileProvider.FORMATTEXT): Promise { + format = format || CoreFileProvider.FORMATTEXT; + this.logger.debug('Read file from file data with format ' + format); + + return new Promise((resolve, reject): void => { + const reader = new FileReader(); + + reader.onloadend = (evt): void => { + const target = evt.target; // Convert to to be able to use non-standard properties. + if (target.result !== undefined && target.result !== null) { + if (format == CoreFileProvider.FORMATJSON) { + // Convert to object. + const parsed = CoreTextUtils.instance.parseJSON(target.result, null); + + if (parsed == null) { + reject('Error parsing JSON file.'); + } + + resolve(parsed); + } else { + resolve(target.result); + } + } else if (target.error !== undefined && target.error !== null) { + reject(target.error); + } else { + reject({ code: null, message: 'READER_ONLOADEND_ERR' }); + } + }; + + // Check if the load starts. If it doesn't start in 3 seconds, reject. + // Sometimes in Android the read doesn't start for some reason, so the promise never finishes. + let hasStarted = false; + reader.onloadstart = (evt): void => { + hasStarted = true; + }; + setTimeout(() => { + if (!hasStarted) { + reject('Upload cannot start.'); + } + }, 3000); + + switch (format) { + case CoreFileProvider.FORMATDATAURL: + reader.readAsDataURL(fileData); + break; + case CoreFileProvider.FORMATBINARYSTRING: + reader.readAsBinaryString(fileData); + break; + case CoreFileProvider.FORMATARRAYBUFFER: + reader.readAsArrayBuffer(fileData); + break; + default: + reader.readAsText(fileData); + } + + }); + } + + /** + * Writes some data in a file. + * + * @param path Relative path to the file. + * @param data Data to write. + * @param append Whether to append the data to the end of the file. + * @return Promise to be resolved when the file is written. + */ + writeFile(path: string, data: any, append?: boolean): Promise { + return this.init().then(() => { + // Remove basePath if it's in the path. + path = this.removeStartingSlash(path.replace(this.basePath, '')); + this.logger.debug('Write file: ' + path); + + // Create file (and parent folders) to prevent errors. + return this.createFile(path).then((fileEntry) => { + if (this.isHTMLAPI && !CoreApp.instance.isDesktop() && + (typeof data == 'string' || data.toString() == '[object ArrayBuffer]')) { + // We need to write Blobs. + const type = CoreMimetypeUtils.instance.getMimeType(CoreMimetypeUtils.instance.getFileExtension(path)); + data = new Blob([data], { type: type || 'text/plain' }); + } + + return File.instance.writeFile(this.basePath, path, data, { replace: !append, append: !!append }).then(() => { + return fileEntry; + }); + }); + }); + } + + /** + * Write some file data into a filesystem file. + * It's done in chunks to prevent crashing the app for big files. + * Please notice Ionic Native writeFile function already splits by chunks, but it doesn't have an onProgress function. + * + * @param file The data to write. + * @param path Path where to store the data. + * @param onProgress Function to call on progress. + * @param offset Offset where to start reading from. + * @param append Whether to append the data to the end of the file. + * @return Promise resolved when done. + */ + async writeFileDataInFile(file: Blob, path: string, onProgress?: CoreFileProgressFunction, offset: number = 0, + append?: boolean): Promise { + + offset = offset || 0; + + try { + // Get the chunk to write. + const chunk = file.slice(offset, Math.min(offset + CoreFileProvider.CHUNK_SIZE, file.size)); + + const fileEntry = await this.writeFile(path, chunk, append); + + offset += CoreFileProvider.CHUNK_SIZE; + + onProgress && onProgress({ + lengthComputable: true, + loaded: offset, + total: file.size + }); + + if (offset >= file.size) { + // Done, stop. + return fileEntry; + } + + // Read the next chunk. + return this.writeFileDataInFile(file, path, onProgress, offset, true); + } catch (error) { + if (error && error.target && error.target.error) { + // Error returned by the writer, get the "real" error. + error = error.target.error; + } + + throw error; + } + } + + /** + * Gets a file that might be outside the app's folder. + * + * @param fullPath Absolute path to the file. + * @return Promise to be resolved when the file is retrieved. + */ + getExternalFile(fullPath: string): Promise { + return File.instance.resolveLocalFilesystemUrl(fullPath).then((entry) => { + return entry; + }); + } + + /** + * Calculate the size of a file. + * + * @param path Absolute path to the file. + * @return Promise to be resolved when the size is calculated. + */ + async getExternalFileSize(path: string): Promise { + const fileEntry = await this.getExternalFile(path); + + return this.getSize(fileEntry); + } + + /** + * Removes a file that might be outside the app's folder. + * + * @param fullPath Absolute path to the file. + * @return Promise to be resolved when the file is removed. + */ + removeExternalFile(fullPath: string): Promise { + const directory = fullPath.substring(0, fullPath.lastIndexOf('/')); + const filename = fullPath.substr(fullPath.lastIndexOf('/') + 1); + + return File.instance.removeFile(directory, filename); + } + + /** + * Get the base path where the application files are stored. + * + * @return Promise to be resolved when the base path is retrieved. + */ + getBasePath(): Promise { + return this.init().then(() => { + if (this.basePath.slice(-1) == '/') { + return this.basePath; + } else { + return this.basePath + '/'; + } + }); + } + + /** + * Get the base path where the application files are stored in the format to be used for downloads. + * iOS: Internal URL (cdvfile://). + * Others: basePath (file://) + * + * @return Promise to be resolved when the base path is retrieved. + */ + getBasePathToDownload(): Promise { + return this.init().then(() => { + if (CoreApp.instance.isIOS()) { + // In iOS we want the internal URL (cdvfile://localhost/persistent/...). + return File.instance.resolveDirectoryUrl(this.basePath).then((dirEntry) => { + return dirEntry.toInternalURL(); + }); + } else { + // In the other platforms we use the basePath as it is (file://...). + return this.basePath; + } + }); + } + + /** + * Get the base path where the application files are stored. Returns the value instantly, without waiting for it to be ready. + * + * @return Base path. If the service hasn't been initialized it will return an invalid value. + */ + getBasePathInstant(): string { + if (!this.basePath) { + return this.basePath; + } else if (this.basePath.slice(-1) == '/') { + return this.basePath; + } else { + return this.basePath + '/'; + } + } + + /** + * Move a dir. + * + * @param originalPath Path to the dir to move. + * @param newPath New path of the dir. + * @param destDirExists Set it to true if you know the directory where to put the dir exists. If false, the function will + * try to create it (slower). + * @return Promise resolved when the entry is moved. + */ + moveDir(originalPath: string, newPath: string, destDirExists?: boolean): Promise { + return this.copyOrMoveFileOrDir(originalPath, newPath, true, false, destDirExists); + } + + /** + * Move a file. + * + * @param originalPath Path to the file to move. + * @param newPath New path of the file. + * @param destDirExists Set it to true if you know the directory where to put the file exists. If false, the function will + * try to create it (slower). + * @return Promise resolved when the entry is moved. + */ + moveFile(originalPath: string, newPath: string, destDirExists?: boolean): Promise { + return this.copyOrMoveFileOrDir(originalPath, newPath, false, false, destDirExists); + } + + /** + * Copy a directory. + * + * @param from Path to the directory to move. + * @param to New path of the directory. + * @param destDirExists Set it to true if you know the directory where to put the dir exists. If false, the function will + * try to create it (slower). + * @return Promise resolved when the entry is copied. + */ + copyDir(from: string, to: string, destDirExists?: boolean): Promise { + return this.copyOrMoveFileOrDir(from, to, true, true, destDirExists); + } + + /** + * Copy a file. + * + * @param from Path to the file to move. + * @param to New path of the file. + * @param destDirExists Set it to true if you know the directory where to put the file exists. If false, the function will + * try to create it (slower). + * @return Promise resolved when the entry is copied. + */ + copyFile(from: string, to: string, destDirExists?: boolean): Promise { + return this.copyOrMoveFileOrDir(from, to, false, true, destDirExists); + } + + /** + * Copy or move a file or a directory. + * + * @param from Path to the file/dir to move. + * @param to New path of the file/dir. + * @param isDir Whether it's a dir or a file. + * @param copy Whether to copy. If false, it will move the file. + * @param destDirExists Set it to true if you know the directory where to put the file/dir exists. If false, the function will + * try to create it (slower). + * @return Promise resolved when the entry is copied. + */ + protected async copyOrMoveFileOrDir(from: string, to: string, isDir?: boolean, copy?: boolean, destDirExists?: boolean) + : Promise { + + const fileIsInAppFolder = this.isPathInAppFolder(from); + + if (!fileIsInAppFolder) { + return this.copyOrMoveExternalFile(from, to, copy); + } + + const moveCopyFn = copy ? + (isDir ? File.instance.copyDir.bind(File.instance) : File.instance.copyFile.bind(File.instance)) : + (isDir ? File.instance.moveDir.bind(File.instance) : File.instance.moveFile.bind(File.instance)); + + await this.init(); + + // Paths cannot start with "/". Remove basePath if present. + from = this.removeStartingSlash(from.replace(this.basePath, '')); + to = this.removeStartingSlash(to.replace(this.basePath, '')); + + const toFileAndDir = this.getFileAndDirectoryFromPath(to); + + if (toFileAndDir.directory && !destDirExists) { + // Create the target directory if it doesn't exist. + await this.createDir(toFileAndDir.directory); + } + + try { + const entry = await moveCopyFn(this.basePath, from, this.basePath, to); + + return entry; + } catch (error) { + // The copy can fail if the path has encoded characters. Try again if that's the case. + const decodedFrom = decodeURI(from); + const decodedTo = decodeURI(to); + + if (from != decodedFrom || to != decodedTo) { + return moveCopyFn(this.basePath, decodedFrom, this.basePath, decodedTo); + } else { + return Promise.reject(error); + } + } + } + + /** + * Extract the file name and directory from a given path. + * + * @param path Path to be extracted. + * @return Plain object containing the file name and directory. + * @description + * file.pdf -> directory: '', name: 'file.pdf' + * /file.pdf -> directory: '', name: 'file.pdf' + * path/file.pdf -> directory: 'path', name: 'file.pdf' + * path/ -> directory: 'path', name: '' + * path -> directory: '', name: 'path' + */ + getFileAndDirectoryFromPath(path: string): {directory: string, name: string} { + const file = { + directory: '', + name: '' + }; + + file.directory = path.substring(0, path.lastIndexOf('/')); + file.name = path.substr(path.lastIndexOf('/') + 1); + + return file; + } + + /** + * Get the internal URL of a file. + * Please notice that with WKWebView these URLs no longer work in mobile. Use fileEntry.toURL() along with convertFileSrc. + * + * @param fileEntry File Entry. + * @return Internal URL. + */ + getInternalURL(fileEntry: FileEntry): string { + if (!fileEntry.toInternalURL) { + // File doesn't implement toInternalURL, use toURL. + return fileEntry.toURL(); + } + + return fileEntry.toInternalURL(); + } + + /** + * Adds the basePath to a path if it doesn't have it already. + * + * @param path Path to treat. + * @return Path with basePath added. + */ + addBasePathIfNeeded(path: string): string { + if (path.indexOf(this.basePath) > -1) { + return path; + } else { + return CoreTextUtils.instance.concatenatePaths(this.basePath, path); + } + } + + /** + * Remove the base path from a path. If basePath isn't found, return false. + * + * @param path Path to treat. + * @return Path without basePath if basePath was found, undefined otherwise. + */ + removeBasePath(path: string): string { + if (path.indexOf(this.basePath) > -1) { + return path.replace(this.basePath, ''); + } + } + + /** + * Unzips a file. + * + * @param path Path to the ZIP file. + * @param destFolder Path to the destination folder. If not defined, a new folder will be created with the + * same location and name as the ZIP file (without extension). + * @param onProgress Function to call on progress. + * @param recreateDir Delete the dest directory before unzipping. Defaults to true. + * @return Promise resolved when the file is unzipped. + */ + unzipFile(path: string, destFolder?: string, onProgress?: (progress: any) => void, recreateDir: boolean = true): Promise { + // Get the source file. + let fileEntry: FileEntry; + + return this.getFile(path).then((fe) => { + fileEntry = fe; + + if (destFolder && recreateDir) { + // Make sure the dest dir doesn't exist already. + return this.removeDir(destFolder).catch(() => { + // Ignore errors. + }).then(() => { + // Now create the dir, otherwise if any of the ancestor dirs doesn't exist the unzip would fail. + return this.createDir(destFolder); + }); + } + }).then(() => { + // If destFolder is not set, use same location as ZIP file. We need to use absolute paths (including basePath). + destFolder = this.addBasePathIfNeeded(destFolder || CoreMimetypeUtils.instance.removeExtension(path)); + + return Zip.instance.unzip(fileEntry.toURL(), destFolder, onProgress); + }).then((result) => { + if (result == -1) { + return Promise.reject('Unzip failed.'); + } + }); + } + + /** + * Search a string or regexp in a file contents and replace it. The result is saved in the same file. + * + * @param path Path to the file. + * @param search Value to search. + * @param newValue New value. + * @return Promise resolved in success. + */ + replaceInFile(path: string, search: string | RegExp, newValue: string): Promise { + return this.readFile(path).then((content) => { + if (typeof content == 'undefined' || content === null || !content.replace) { + return Promise.reject(null); + } + + if (content.match(search)) { + content = content.replace(search, newValue); + + return this.writeFile(path, content); + } + }); + } + + /** + * Get a file/dir metadata given the file's entry. + * + * @param fileEntry FileEntry retrieved from getFile or similar. + * @return Promise resolved with metadata. + */ + getMetadata(fileEntry: Entry): Promise { + if (!fileEntry || !fileEntry.getMetadata) { + return Promise.reject(null); + } + + return new Promise((resolve, reject): void => { + fileEntry.getMetadata(resolve, reject); + }); + } + + /** + * Get a file/dir metadata given the path. + * + * @param path Path to the file/dir. + * @param isDir True if directory, false if file. + * @return Promise resolved with metadata. + */ + getMetadataFromPath(path: string, isDir?: boolean): Promise { + let promise; + if (isDir) { + promise = this.getDir(path); + } else { + promise = this.getFile(path); + } + + return promise.then((entry) => { + return this.getMetadata(entry); + }); + } + + /** + * Remove the starting slash of a path if it's there. E.g. '/sites/filepool' -> 'sites/filepool'. + * + * @param path Path. + * @return Path without a slash in the first position. + */ + removeStartingSlash(path: string): string { + if (path[0] == '/') { + return path.substr(1); + } + + return path; + } + + /** + * Convenience function to copy or move an external file. + * + * @param from Absolute path to the file to copy/move. + * @param to Relative new path of the file (inside the app folder). + * @param copy True to copy, false to move. + * @return Promise resolved when the entry is copied/moved. + */ + protected copyOrMoveExternalFile(from: string, to: string, copy?: boolean): Promise { + // Get the file to copy/move. + return this.getExternalFile(from).then((fileEntry) => { + // Create the destination dir if it doesn't exist. + const dirAndFile = this.getFileAndDirectoryFromPath(to); + + return this.createDir(dirAndFile.directory).then((dirEntry) => { + // Now copy/move the file. + return new Promise((resolve, reject): void => { + if (copy) { + fileEntry.copyTo(dirEntry, dirAndFile.name, resolve, reject); + } else { + fileEntry.moveTo(dirEntry, dirAndFile.name, resolve, reject); + } + }); + }); + }); + } + + /** + * Copy a file from outside of the app folder to somewhere inside the app folder. + * + * @param from Absolute path to the file to copy. + * @param to Relative new path of the file (inside the app folder). + * @return Promise resolved when the entry is copied. + */ + copyExternalFile(from: string, to: string): Promise { + return this.copyOrMoveExternalFile(from, to, true); + } + + /** + * Move a file from outside of the app folder to somewhere inside the app folder. + * + * @param from Absolute path to the file to move. + * @param to Relative new path of the file (inside the app folder). + * @return Promise resolved when the entry is moved. + */ + moveExternalFile(from: string, to: string): Promise { + return this.copyOrMoveExternalFile(from, to, false); + } + + /** + * Get a unique file name inside a folder, adding numbers to the file name if needed. + * + * @param dirPath Path to the destination folder. + * @param fileName File name that wants to be used. + * @param defaultExt Default extension to use if no extension found in the file. + * @return Promise resolved with the unique file name. + */ + getUniqueNameInFolder(dirPath: string, fileName: string, defaultExt?: string): Promise { + // Get existing files in the folder. + return this.getDirectoryContents(dirPath).then((entries) => { + const files = {}; + let num = 1; + let fileNameWithoutExtension = CoreMimetypeUtils.instance.removeExtension(fileName); + let extension = CoreMimetypeUtils.instance.getFileExtension(fileName) || defaultExt; + + // Clean the file name. + fileNameWithoutExtension = CoreTextUtils.instance.removeSpecialCharactersForFiles( + CoreTextUtils.instance.decodeURIComponent(fileNameWithoutExtension)); + + // Index the files by name. + entries.forEach((entry) => { + files[entry.name.toLowerCase()] = entry; + }); + + // Format extension. + if (extension) { + extension = '.' + extension; + } else { + extension = ''; + } + + let newName = fileNameWithoutExtension + extension; + if (typeof files[newName.toLowerCase()] == 'undefined') { + // No file with the same name. + return newName; + } else { + // Repeated name. Add a number until we find a free name. + do { + newName = fileNameWithoutExtension + '(' + num + ')' + extension; + num++; + } while (typeof files[newName.toLowerCase()] != 'undefined'); + + // Ask the user what he wants to do. + return newName; + } + }).catch(() => { + // Folder doesn't exist, name is unique. Clean it and return it. + return CoreTextUtils.instance.removeSpecialCharactersForFiles(CoreTextUtils.instance.decodeURIComponent(fileName)); + }); + } + + /** + * Remove app temporary folder. + * + * @return Promise resolved when done. + */ + clearTmpFolder(): Promise { + return this.removeDir(CoreFileProvider.TMPFOLDER).catch(() => { + // Ignore errors because the folder might not exist. + }); + } + + /** + * Given a folder path and a list of used files, remove all the files of the folder that aren't on the list of used files. + * + * @param dirPath Folder path. + * @param files List of used files. + * @return Promise resolved when done, rejected if failure. + */ + removeUnusedFiles(dirPath: string, files: any[]): Promise { + // Get the directory contents. + return this.getDirectoryContents(dirPath).then((contents) => { + if (!contents.length) { + return; + } + + const filesMap = {}; + const promises = []; + + // Index the received files by fullPath and ignore the invalid ones. + files.forEach((file) => { + if (file.fullPath) { + filesMap[file.fullPath] = file; + } + }); + + // Check which of the content files aren't used anymore and delete them. + contents.forEach((file) => { + if (!filesMap[file.fullPath]) { + // File isn't used, delete it. + promises.push(this.removeFileByFileEntry(file)); + } + }); + + return Promise.all(promises); + }).catch(() => { + // Ignore errors, maybe it doesn't exist. + }); + } + + /** + * Check if a file is inside the app's folder. + * + * @param path The absolute path of the file to check. + * @return Whether the file is in the app's folder. + */ + isFileInAppFolder(path: string): boolean { + return path.indexOf(this.basePath) != -1; + } + + /** + * Get the path to the www folder at runtime based on the WebView URL. + * + * @return Path. + */ + getWWWPath(): string { + const position = window.location.href.indexOf('index.html'); + + if (position != -1) { + return window.location.href.substr(0, position); + } + + return window.location.href; + } + + /** + * Get the full path to the www folder. + * + * @return Path. + */ + getWWWAbsolutePath(): string { + if (cordova && cordova.file && cordova.file.applicationDirectory) { + return CoreTextUtils.instance.concatenatePaths(cordova.file.applicationDirectory, 'www'); + } + + // Cannot use Cordova to get it, use the WebView URL. + return this.getWWWPath(); + } + + /** + * Helper function to call Ionic WebView convertFileSrc only in the needed platforms. + * This is needed to make files work with the Ionic WebView plugin. + * + * @param src Source to convert. + * @return Converted src. + */ + convertFileSrc(src: string): string { + return CoreApp.instance.isIOS() ? ( window).Ionic.WebView.convertFileSrc(src) : src; + } + + /** + * Undo the conversion of convertFileSrc. + * + * @param src Source to unconvert. + * @return Unconverted src. + */ + unconvertFileSrc(src: string): string { + if (!CoreApp.instance.isIOS()) { + return src; + } + + return src.replace(CoreConfigConstants.ioswebviewscheme + '://localhost/_app_file_', 'file://'); + } + + /** + * Check if a certain path is in the app's folder (basePath). + * + * @param path Path to check. + * @return Whether it's in the app folder. + */ + protected isPathInAppFolder(path: string): boolean { + return !path || !path.match(/^[a-z0-9]+:\/\//i) || path.indexOf(this.basePath) != -1; + } +} + +export class CoreFile extends makeSingleton(CoreFileProvider) {} diff --git a/src/app/services/filepool.ts b/src/app/services/filepool.ts new file mode 100644 index 000000000..1373615b3 --- /dev/null +++ b/src/app/services/filepool.ts @@ -0,0 +1,3251 @@ +// (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 { Md5 } from 'ts-md5/dist/md5'; + +import { CoreApp, CoreAppSchema } from '@services/app'; +import { CoreEvents, CoreEventsProvider } from '@services/events'; +import { CoreFile } from '@services/file'; +import { CoreInit } from '@services/init'; +import { CorePluginFile } from '@services/plugin-file-delegate'; +import { CoreSites, CoreSiteSchema } from '@services/sites'; +import { CoreWS, CoreWSExternalFile } from '@services/ws'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreMimetypeUtils } from '@services/utils/mimetype'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreUtils } from '@services/utils/utils'; +import { SQLiteDB } from '@classes/sqlitedb'; +import { CoreConstants } from '@core/constants'; +import { makeSingleton, Network, NgZone } from '@singletons/core.singletons'; +import { CoreLogger } from '@singletons/logger'; + +/* + * Factory for handling downloading files and retrieve downloaded files. + * + * @description + * This factory is responsible for handling downloading files. + * + * The two main goals of this is to keep the content available offline, and improve the user experience by caching + * the content locally. + */ +@Injectable() +export class CoreFilepoolProvider { + // Constants. + protected QUEUE_PROCESS_INTERVAL = 0; + protected FOLDER = 'filepool'; + protected WIFI_DOWNLOAD_THRESHOLD = 20971520; // 20MB. + protected DOWNLOAD_THRESHOLD = 2097152; // 2MB. + protected QUEUE_RUNNING = 'CoreFilepool:QUEUE_RUNNING'; + protected QUEUE_PAUSED = 'CoreFilepool:QUEUE_PAUSED'; + protected ERR_QUEUE_IS_EMPTY = 'CoreFilepoolError:ERR_QUEUE_IS_EMPTY'; + protected ERR_FS_OR_NETWORK_UNAVAILABLE = 'CoreFilepoolError:ERR_FS_OR_NETWORK_UNAVAILABLE'; + protected ERR_QUEUE_ON_PAUSE = 'CoreFilepoolError:ERR_QUEUE_ON_PAUSE'; + protected FILE_UPDATE_UNKNOWN_WHERE_CLAUSE = + 'isexternalfile = 1 OR ((revision IS NULL OR revision = 0) AND (timemodified IS NULL OR timemodified = 0))'; + + // Variables for database. + protected QUEUE_TABLE = 'filepool_files_queue'; // Queue of files to download. + protected FILES_TABLE = 'filepool_files'; // Downloaded files. + protected LINKS_TABLE = 'filepool_files_links'; // Links between downloaded files and components. + protected PACKAGES_TABLE = 'filepool_packages'; // Downloaded packages (sets of files). + protected appTablesSchema: CoreAppSchema = { + name: 'CoreFilepoolProvider', + version: 1, + tables: [ + { + name: this.QUEUE_TABLE, + columns: [ + { + name: 'siteId', + type: 'TEXT' + }, + { + name: 'fileId', + type: 'TEXT' + }, + { + name: 'added', + type: 'INTEGER' + }, + { + name: 'priority', + type: 'INTEGER' + }, + { + name: 'url', + type: 'TEXT' + }, + { + name: 'revision', + type: 'INTEGER' + }, + { + name: 'timemodified', + type: 'INTEGER' + }, + { + name: 'isexternalfile', + type: 'INTEGER' + }, + { + name: 'repositorytype', + type: 'TEXT' + }, + { + name: 'path', + type: 'TEXT' + }, + { + name: 'links', + type: 'TEXT' + }, + ], + primaryKeys: ['siteId', 'fileId'], + }, + ], + }; + protected siteSchema: CoreSiteSchema = { + name: 'CoreFilepoolProvider', + version: 1, + tables: [ + { + name: this.FILES_TABLE, + columns: [ + { + name: 'fileId', + type: 'TEXT', + primaryKey: true + }, + { + name: 'url', + type: 'TEXT', + notNull: true + }, + { + name: 'revision', + type: 'INTEGER' + }, + { + name: 'timemodified', + type: 'INTEGER' + }, + { + name: 'stale', + type: 'INTEGER' + }, + { + name: 'downloadTime', + type: 'INTEGER' + }, + { + name: 'isexternalfile', + type: 'INTEGER' + }, + { + name: 'repositorytype', + type: 'TEXT' + }, + { + name: 'path', + type: 'TEXT' + }, + { + name: 'extension', + type: 'TEXT' + } + ] + }, + { + name: this.LINKS_TABLE, + columns: [ + { + name: 'fileId', + type: 'TEXT' + }, + { + name: 'component', + type: 'TEXT' + }, + { + name: 'componentId', + type: 'TEXT' + } + ], + primaryKeys: ['fileId', 'component', 'componentId'] + }, + { + name: this.PACKAGES_TABLE, + columns: [ + { + name: 'id', + type: 'TEXT', + primaryKey: true + }, + { + name: 'component', + type: 'TEXT' + }, + { + name: 'componentId', + type: 'TEXT' + }, + { + name: 'status', + type: 'TEXT' + }, + { + name: 'previous', + type: 'TEXT' + }, + { + name: 'updated', + type: 'INTEGER' + }, + { + name: 'downloadTime', + type: 'INTEGER' + }, + { + name: 'previousDownloadTime', + type: 'INTEGER' + }, + { + name: 'extra', + type: 'TEXT' + } + ] + } + ] + }; + + protected logger: CoreLogger; + protected appDB: SQLiteDB; + protected dbReady: Promise; // Promise resolved when the app DB is initialized. + protected tokenRegex = new RegExp('(\\?|&)token=([A-Za-z0-9]*)'); + protected queueState: string; + protected urlAttributes = [ + this.tokenRegex, + new RegExp('(\\?|&)forcedownload=[0-1]'), + new RegExp('(\\?|&)preview=[A-Za-z0-9]+'), + new RegExp('(\\?|&)offline=[0-1]', 'g') + ]; + protected queueDeferreds = {}; // To handle file downloads using the queue. + protected sizeCache = {}; // A "cache" to store file sizes to prevent performing too many HEAD requests. + // Variables to prevent downloading packages/files twice at the same time. + protected packagesPromises = {}; + protected filePromises: { [s: string]: { [s: string]: Promise } } = {}; + + constructor() { + this.logger = CoreLogger.getInstance('CoreFilepoolProvider'); + + this.appDB = CoreApp.instance.getDB(); + this.dbReady = CoreApp.instance.createTablesFromSchema(this.appTablesSchema).catch(() => { + // Ignore errors. + }); + + CoreSites.instance.registerSiteSchema(this.siteSchema); + + CoreInit.instance.ready().then(() => { + // Waiting for the app to be ready to start processing the queue. + this.checkQueueProcessing(); + + // Start queue when device goes online. + Network.instance.onConnect().subscribe(() => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + NgZone.instance.run(() => { + this.checkQueueProcessing(); + }); + }); + }); + } + + /** + * Link a file with a component. + * + * @param siteId The site ID. + * @param fileId The file ID. + * @param component The component to link the file to. + * @param componentId An ID to use in conjunction with the component. + * @return Promise resolved on success. + */ + protected addFileLink(siteId: string, fileId: string, component: string, componentId?: string | number): Promise { + if (!component) { + return Promise.reject(null); + } + + componentId = this.fixComponentId(componentId); + + return CoreSites.instance.getSiteDb(siteId).then((db) => { + const newEntry = { + fileId, + component, + componentId: componentId || '' + }; + + return db.insertRecord(this.LINKS_TABLE, newEntry); + }); + } + + /** + * Link a file with a component by URL. + * + * @param siteId The site ID. + * @param fileUrl The file Url. + * @param component The component to link the file to. + * @param componentId An ID to use in conjunction with the component. + * @return Promise resolved on success. + * @description + * Use this method to create a link between a URL and a component. You usually do not need to call this manually since + * downloading a file automatically does this. Note that this method does not check if the file exists in the pool. + */ + addFileLinkByUrl(siteId: string, fileUrl: string, component: string, componentId?: string | number): Promise { + return this.fixPluginfileURL(siteId, fileUrl).then((file) => { + const fileId = this.getFileIdByUrl(file.fileurl); + + return this.addFileLink(siteId, fileId, component, componentId); + }); + } + + /** + * Link a file with several components. + * + * @param siteId The site ID. + * @param fileId The file ID. + * @param links Array of objects containing the component and optionally componentId. + * @return Promise resolved on success. + */ + protected addFileLinks(siteId: string, fileId: string, links: CoreFilepoolComponentLink[]): Promise { + const promises = []; + links.forEach((link) => { + promises.push(this.addFileLink(siteId, fileId, link.component, link.componentId)); + }); + + return Promise.all(promises); + } + + /** + * Add files to queue using a URL. + * + * @param siteId The site ID. + * @param files Array of files to add. + * @param component The component to link the file to. + * @param componentId An ID to use in conjunction with the component (optional). + * @return Resolved on success. + */ + addFilesToQueue(siteId: string, files: any[], component?: string, componentId?: string | number): Promise { + return this.downloadOrPrefetchFiles(siteId, files, true, false, component, componentId); + } + + /** + * Add a file to the pool. + * + * @param siteId The site ID. + * @param fileId The file ID. + * @param data Additional information to store about the file (timemodified, url, ...). See FILES_TABLE schema. + * @return Promise resolved on success. + */ + protected addFileToPool(siteId: string, fileId: string, data: any): Promise { + const values = Object.assign({}, data); + values.fileId = fileId; + + return CoreSites.instance.getSiteDb(siteId).then((db) => { + return db.insertRecord(this.FILES_TABLE, values); + }); + } + + /** + * Adds a hash to a filename if needed. + * + * @param url The URL of the file, already treated (decoded, without revision, etc.). + * @param filename The filename. + * @return The filename with the hash. + */ + protected addHashToFilename(url: string, filename: string): string { + // Check if the file already has a hash. If a file is downloaded and re-uploaded with the app it will have a hash already. + const matches = filename.match(/_[a-f0-9]{32}/g); + + if (matches && matches.length) { + // There is at least 1 match. Get the last one. + const hash = matches[matches.length - 1]; + const treatedUrl = url.replace(hash, ''); // Remove the hash from the URL. + + // Check that the hash is valid. + if ('_' + Md5.hashAsciiStr('url:' + treatedUrl) == hash) { + // The data found is a hash of the URL, don't need to add it again. + return filename; + } + } + + return filename + '_' + Md5.hashAsciiStr('url:' + url); + } + + /** + * Add a file to the queue. + * + * @param siteId The site ID. + * @param fileId The file ID. + * @param url The absolute URL to the file. + * @param priority The priority this file should get in the queue (range 0-999). + * @param revision The revision of the file. + * @param timemodified The time this file was modified. Can be used to check file state. + * @param filePath Filepath to download the file to. If not defined, download to the filepool folder. + * @param options Extra options (isexternalfile, repositorytype). + * @param link The link to add for the file. + * @return Promise resolved when the file is downloaded. + */ + protected async addToQueue(siteId: string, fileId: string, url: string, priority: number, revision: number, + timemodified: number, filePath: string, onProgress?: (event: any) => any, options: any = {}, + link?: CoreFilepoolComponentLink): Promise { + + await this.dbReady; + + this.logger.debug(`Adding ${fileId} to the queue`); + + await this.appDB.insertRecord(this.QUEUE_TABLE, { + siteId, + fileId, + url, + priority, + revision, + timemodified, + path: filePath, + isexternalfile: options.isexternalfile ? 1 : 0, + repositorytype: options.repositorytype, + links: JSON.stringify(link ? [link] : []), + added: Date.now() + }); + + // Check if the queue is running. + this.checkQueueProcessing(); + this.notifyFileDownloading(siteId, fileId, link ? [link] : []); + + return this.getQueuePromise(siteId, fileId, true, onProgress); + } + + /** + * Add an entry to queue using a URL. + * + * @param siteId The site ID. + * @param fileUrl The absolute URL to the file. + * @param component The component to link the file to. + * @param componentId An ID to use in conjunction with the component (optional). + * @param timemodified The time this file was modified. Can be used to check file state. + * @param filePath Filepath to download the file to. If not defined, download to the filepool folder. + * @param onProgress Function to call on progress. + * @param priority The priority this file should get in the queue (range 0-999). + * @param options Extra options (isexternalfile, repositorytype). + * @param revision File revision. If not defined, it will be calculated using the URL. + * @param alreadyFixed Whether the URL has already been fixed. + * @return Resolved on success. + */ + async addToQueueByUrl(siteId: string, fileUrl: string, component?: string, componentId?: string | number, + timemodified: number = 0, filePath?: string, onProgress?: (event: any) => any, priority: number = 0, options: any = {}, + revision?: number, alreadyFixed?: boolean): Promise { + await this.dbReady; + + let fileId; + let queueDeferred; + + if (!CoreFile.instance.isAvailable()) { + return Promise.reject(null); + } + + return CoreSites.instance.getSite(siteId).then((site) => { + if (!site.canDownloadFiles()) { + return Promise.reject(null); + } + + if (alreadyFixed) { + // Already fixed, if we reached here it means it can be downloaded. + return {fileurl: fileUrl}; + } else { + return this.fixPluginfileURL(siteId, fileUrl); + } + }).then((file) => { + + fileUrl = file.fileurl; + timemodified = file.timemodified || timemodified; + revision = revision || this.getRevisionFromUrl(fileUrl); + fileId = this.getFileIdByUrl(fileUrl); + + const primaryKey = { siteId, fileId }; + + // Set up the component. + const link = this.createComponentLink(component, componentId); + + // Retrieve the queue deferred now if it exists. + // This is to prevent errors if file is removed from queue while we're checking if the file is in queue. + queueDeferred = this.getQueueDeferred(siteId, fileId, false, onProgress); + + return this.hasFileInQueue(siteId, fileId).then((entry: CoreFilepoolQueueEntry) => { + const newData: any = {}; + let foundLink = false; + + if (entry) { + // We already have the file in queue, we update the priority and links. + if (entry.priority < priority) { + newData.priority = priority; + } + if (revision && entry.revision !== revision) { + newData.revision = revision; + } + if (timemodified && entry.timemodified !== timemodified) { + newData.timemodified = timemodified; + } + if (filePath && entry.path !== filePath) { + newData.path = filePath; + } + if (entry.isexternalfile !== options.isexternalfile && (entry.isexternalfile || options.isexternalfile)) { + newData.isexternalfile = options.isexternalfile; + } + if (entry.repositorytype !== options.repositorytype && (entry.repositorytype || options.repositorytype)) { + newData.repositorytype = options.repositorytype; + } + + if (link) { + // We need to add the new link if it does not exist yet. + if (entry.links && entry.links.length) { + for (const i in entry.links) { + const fileLink = entry.links[i]; + if (fileLink.component == link.component && fileLink.componentId == link.componentId) { + foundLink = true; + break; + } + } + } + + if (!foundLink) { + newData.links = entry.links || []; + newData.links.push(link); + newData.links = JSON.stringify(entry.links); + } + } + + if (Object.keys(newData).length) { + // Update only when required. + this.logger.debug(`Updating file ${fileId} which is already in queue`); + + return this.appDB.updateRecords(this.QUEUE_TABLE, newData, primaryKey).then(() => { + return this.getQueuePromise(siteId, fileId, true, onProgress); + }); + } + + this.logger.debug(`File ${fileId} already in queue and does not require update`); + if (queueDeferred) { + // If we were able to retrieve the queue deferred before, we use that one. + return queueDeferred.promise; + } else { + // Create a new deferred and return its promise. + return this.getQueuePromise(siteId, fileId, true, onProgress); + } + } else { + return this.addToQueue( + siteId, fileId, fileUrl, priority, revision, timemodified, filePath, onProgress, options, link); + } + }, () => { + // Unsure why we could not get the record, let's add to the queue anyway. + return this.addToQueue( + siteId, fileId, fileUrl, priority, revision, timemodified, filePath, onProgress, options, link); + }); + }); + } + + /** + * Adds a file to the queue if the size is allowed to be downloaded. + * + * @param siteId The site ID. + * @param fileUrl The absolute URL to the file, already fixed. + * @param component The component to link the file to. + * @param componentId An ID to use in conjunction with the component. + * @param timemodified The time this file was modified. + * @param checkSize True if we shouldn't download files if their size is big, false otherwise. + * @param downloadUnknown True to download file in WiFi if their size is unknown, false otherwise. + * Ignored if checkSize=false. + * @param options Extra options (isexternalfile, repositorytype). + * @param revision File revision. If not defined, it will be calculated using the URL. + * @return Promise resolved when the file is downloaded. + */ + protected addToQueueIfNeeded(siteId: string, fileUrl: string, component: string, componentId?: string | number, + timemodified: number = 0, checkSize: boolean = true, downloadUnknown?: boolean, options: any = {}, revision?: number) + : Promise { + let promise; + + if (checkSize) { + if (typeof this.sizeCache[fileUrl] != 'undefined') { + promise = Promise.resolve(this.sizeCache[fileUrl]); + } else { + if (!CoreApp.instance.isOnline()) { + // Cannot check size in offline, stop. + return Promise.reject(null); + } + + promise = CoreWS.instance.getRemoteFileSize(fileUrl); + } + + // Calculate the size of the file. + return promise.then((size) => { + const isWifi = CoreApp.instance.isWifi(); + const sizeUnknown = size <= 0; + + if (!sizeUnknown) { + // Store the size in the cache. + this.sizeCache[fileUrl] = size; + } + + // Check if the file should be downloaded. + if (sizeUnknown) { + if (downloadUnknown && isWifi) { + return this.addToQueueByUrl(siteId, fileUrl, component, componentId, timemodified, undefined, undefined, + 0, options, revision, true); + } + } else if (this.shouldDownload(size)) { + return this.addToQueueByUrl(siteId, fileUrl, component, componentId, timemodified, undefined, undefined, 0, + options, revision, true); + } + }); + } else { + // No need to check size, just add it to the queue. + return this.addToQueueByUrl(siteId, fileUrl, component, componentId, timemodified, undefined, undefined, 0, options, + revision, true); + } + } + + /** + * Check the queue processing. + * + * @description + * In mose cases, this will enable the queue processing if it was paused. + * Though, this will disable the queue if we are missing network or if the file system + * is not accessible. Also, this will have no effect if the queue is already running. + */ + protected checkQueueProcessing(): void { + if (!CoreFile.instance.isAvailable() || !CoreApp.instance.isOnline()) { + this.queueState = this.QUEUE_PAUSED; + + return; + } else if (this.queueState === this.QUEUE_RUNNING) { + return; + } + + this.queueState = this.QUEUE_RUNNING; + this.processQueue(); + } + + /** + * Clear all packages status in a site. + * + * @param siteId Site ID. + * @return Promise resolved when all status are cleared. + */ + clearAllPackagesStatus(siteId: string): Promise { + this.logger.debug('Clear all packages status for site ' + siteId); + + return CoreSites.instance.getSite(siteId).then((site) => { + // Get all the packages to be able to "notify" the change in the status. + return site.getDb().getAllRecords(this.PACKAGES_TABLE).then((entries) => { + // Delete all the entries. + return site.getDb().deleteRecords(this.PACKAGES_TABLE).then(() => { + entries.forEach((entry) => { + // Trigger module status changed, setting it as not downloaded. + this.triggerPackageStatusChanged(siteId, CoreConstants.NOT_DOWNLOADED, entry.component, entry.componentId); + }); + }); + }); + }); + } + + /** + * Clears the filepool. Use it only when all the files from a site are deleted. + * + * @param siteId ID of the site to clear. + * @return Promise resolved when the filepool is cleared. + */ + clearFilepool(siteId: string): Promise { + return CoreSites.instance.getSiteDb(siteId).then((db) => { + return Promise.all([ + db.deleteRecords(this.FILES_TABLE), + db.deleteRecords(this.LINKS_TABLE) + ]); + }); + } + + /** + * Returns whether a component has files in the pool. + * + * @param siteId The site ID. + * @param component The component to link the file to. + * @param componentId An ID to use in conjunction with the component. + * @return Resolved means yes, rejected means no. + */ + componentHasFiles(siteId: string, component: string, componentId?: string | number): Promise { + return CoreSites.instance.getSiteDb(siteId).then((db) => { + const conditions = { + component, + componentId: this.fixComponentId(componentId) + }; + + return db.countRecords(this.LINKS_TABLE, conditions).then((count) => { + if (count <= 0) { + return Promise.reject(null); + } + }); + }); + } + + /** + * Prepare a component link. + * + * @param component The component to link the file to. + * @param componentId An ID to use in conjunction with the component. + * @return Link, null if nothing to link. + */ + protected createComponentLink(component: string, componentId?: string | number): CoreFilepoolComponentLink { + if (typeof component != 'undefined' && component != null) { + return { component, componentId: this.fixComponentId(componentId) }; + } + + return null; + } + + /** + * Prepare list of links from component and componentId. + * + * @param component The component to link the file to. + * @param componentId An ID to use in conjunction with the component. + * @return Links. + */ + protected createComponentLinks(component: string, componentId?: string | number): CoreFilepoolComponentLink[] { + const link = this.createComponentLink(component, componentId); + + return link ? [link] : []; + } + + /** + * Given the current status of a list of packages and the status of one of the packages, + * determine the new status for the list of packages. The status of a list of packages is: + * - CoreConstants.NOT_DOWNLOADABLE if there are no downloadable packages. + * - CoreConstants.NOT_DOWNLOADED if at least 1 package has status CoreConstants.NOT_DOWNLOADED. + * - CoreConstants.DOWNLOADED if ALL the downloadable packages have status CoreConstants.DOWNLOADED. + * - CoreConstants.DOWNLOADING if ALL the downloadable packages have status CoreConstants.DOWNLOADING or + * CoreConstants.DOWNLOADED, with at least 1 package with CoreConstants.DOWNLOADING. + * - CoreConstants.OUTDATED if ALL the downloadable packages have status CoreConstants.OUTDATED or CoreConstants.DOWNLOADED + * or CoreConstants.DOWNLOADING, with at least 1 package with CoreConstants.OUTDATED. + * + * @param current Current status of the list of packages. + * @param packagestatus Status of one of the packages. + * @return New status for the list of packages; + */ + determinePackagesStatus(current: string, packageStatus: string): string { + if (!current) { + current = CoreConstants.NOT_DOWNLOADABLE; + } + + if (packageStatus === CoreConstants.NOT_DOWNLOADED) { + // If 1 package is not downloaded the status of the whole list will always be not downloaded. + return CoreConstants.NOT_DOWNLOADED; + } else if (packageStatus === CoreConstants.DOWNLOADED && current === CoreConstants.NOT_DOWNLOADABLE) { + // If all packages are downloaded or not downloadable with at least 1 downloaded, status will be downloaded. + return CoreConstants.DOWNLOADED; + } else if (packageStatus === CoreConstants.DOWNLOADING && + (current === CoreConstants.NOT_DOWNLOADABLE || current === CoreConstants.DOWNLOADED)) { + // If all packages are downloading/downloaded/notdownloadable with at least 1 downloading, status will be downloading. + return CoreConstants.DOWNLOADING; + } else if (packageStatus === CoreConstants.OUTDATED && current !== CoreConstants.NOT_DOWNLOADED) { + // If there are no packages notdownloaded and there is at least 1 outdated, status will be outdated. + return CoreConstants.OUTDATED; + } + + // Status remains the same. + return current; + } + + /** + * Downloads a URL and update or add it to the pool. + * + * This uses the file system, you should always make sure that it is accessible before calling this method. + * + * @param siteId The site ID. + * @param fileUrl The file URL. + * @param options Extra options (revision, timemodified, isexternalfile, repositorytype). + * @param filePath Filepath to download the file to. If defined, no extension will be added. + * @param onProgress Function to call on progress. + * @param poolFileObject When set, the object will be updated, a new entry will not be created. + * @return Resolved with internal URL on success, rejected otherwise. + */ + protected downloadForPoolByUrl(siteId: string, fileUrl: string, options: any = {}, filePath?: string, + onProgress?: (event: any) => any, poolFileObject?: CoreFilepoolFileEntry): Promise { + + const fileId = this.getFileIdByUrl(fileUrl); + const extension = CoreMimetypeUtils.instance.guessExtensionFromUrl(fileUrl); + const addExtension = typeof filePath == 'undefined'; + const pathPromise = filePath ? filePath : this.getFilePath(siteId, fileId, extension); + + return Promise.resolve(pathPromise).then((filePath) => { + if (poolFileObject && poolFileObject.fileId !== fileId) { + this.logger.error('Invalid object to update passed'); + + return Promise.reject(null); + } + + const downloadId = this.getFileDownloadId(fileUrl, filePath); + + if (this.filePromises[siteId] && this.filePromises[siteId][downloadId]) { + // There's already a download ongoing for this file in this location, return the promise. + return this.filePromises[siteId][downloadId]; + } else if (!this.filePromises[siteId]) { + this.filePromises[siteId] = {}; + } + + this.filePromises[siteId][downloadId] = CoreSites.instance.getSite(siteId).then((site) => { + if (!site.canDownloadFiles()) { + return Promise.reject(null); + } + + let fileEntry; + + return CoreWS.instance.downloadFile(fileUrl, filePath, addExtension, onProgress).then((entry) => { + fileEntry = entry; + + return CorePluginFile.instance.treatDownloadedFile(fileUrl, fileEntry, siteId, onProgress); + }).then(() => { + const data: CoreFilepoolFileEntry = poolFileObject || {}; + + data.downloadTime = Date.now(); + data.stale = 0; + data.url = fileUrl; + data.revision = options.revision; + data.timemodified = options.timemodified; + data.isexternalfile = options.isexternalfile ? 1 : 0; + data.repositorytype = options.repositorytype; + data.path = fileEntry.path; + data.extension = fileEntry.extension; + + return this.addFileToPool(siteId, fileId, data).then(() => { + return fileEntry.toURL(); + }); + }); + }).finally(() => { + // Download finished, delete the promise. + delete this.filePromises[siteId][downloadId]; + }); + + return this.filePromises[siteId][downloadId]; + }); + } + + /** + * Download or prefetch several files into the filepool folder. + * + * @param siteId The site ID. + * @param files Array of files to download. + * @param prefetch True if should prefetch the contents (queue), false if they should be downloaded right now. + * @param ignoreStale True if 'stale' should be ignored. Only if prefetch=false. + * @param component The component to link the file to. + * @param componentId An ID to use in conjunction with the component. + * @param dirPath Name of the directory where to store the files (inside filepool dir). If not defined, store + * the files directly inside the filepool folder. + * @return Resolved on success. + */ + downloadOrPrefetchFiles(siteId: string, files: any[], prefetch: boolean, ignoreStale?: boolean, component?: string, + componentId?: string | number, dirPath?: string): Promise { + const promises = []; + + // Download files. + files.forEach((file) => { + const url = file.url || file.fileurl; + const timemodified = file.timemodified; + const options = { + isexternalfile: file.isexternalfile, + repositorytype: file.repositorytype, + }; + let path; + + if (dirPath) { + // Calculate the path to the file. + path = file.filename; + if (file.filepath !== '/') { + path = file.filepath.substr(1) + path; + } + path = CoreTextUtils.instance.concatenatePaths(dirPath, path); + } + + if (prefetch) { + promises.push(this.addToQueueByUrl( + siteId, url, component, componentId, timemodified, path, undefined, 0, options)); + } else { + promises.push(this.downloadUrl( + siteId, url, ignoreStale, component, componentId, timemodified, path, undefined, options)); + } + }); + + return CoreUtils.instance.allPromises(promises); + } + + /** + * Downloads or prefetches a list of files as a "package". + * + * @param siteId The site ID. + * @param fileList List of files to download. + * @param prefetch True if should prefetch the contents (queue), false if they should be downloaded right now. + * @param component The component to link the file to. + * @param componentId An ID to use in conjunction with the component. + * @param extra Extra data to store for the package. + * @param dirPath Name of the directory where to store the files (inside filepool dir). If not defined, store + * the files directly inside the filepool folder. + * @param onProgress Function to call on progress. + * @return Promise resolved when the package is downloaded. + */ + protected downloadOrPrefetchPackage(siteId: string, fileList: any[], prefetch?: boolean, component?: string, + componentId?: string | number, extra?: string, dirPath?: string, onProgress?: (event: any) => any): Promise { + + const packageId = this.getPackageId(component, componentId); + let promise; + + if (this.packagesPromises[siteId] && this.packagesPromises[siteId][packageId]) { + // There's already a download ongoing for this package, return the promise. + return this.packagesPromises[siteId][packageId]; + } else if (!this.packagesPromises[siteId]) { + this.packagesPromises[siteId] = {}; + } + + // Set package as downloading. + promise = this.storePackageStatus(siteId, CoreConstants.DOWNLOADING, component, componentId).then(() => { + const promises = []; + let packageLoaded = 0; + + fileList.forEach((file) => { + const fileUrl = file.url || file.fileurl; + const options = { + isexternalfile: file.isexternalfile, + repositorytype: file.repositorytype, + }; + let path; + let promise; + let fileLoaded = 0; + let onFileProgress; + + if (onProgress) { + // There's a onProgress event, create a function to receive file download progress events. + onFileProgress = (progress: any): void => { + if (progress && progress.loaded) { + // Add the new size loaded to the package loaded. + packageLoaded = packageLoaded + (progress.loaded - fileLoaded); + fileLoaded = progress.loaded; + onProgress({ + packageDownload: true, + loaded: packageLoaded, + fileProgress: progress + }); + } + }; + } + + if (dirPath) { + // Calculate the path to the file. + path = file.filename; + if (file.filepath !== '/') { + path = file.filepath.substr(1) + path; + } + path = CoreTextUtils.instance.concatenatePaths(dirPath, path); + } + + if (prefetch) { + promise = this.addToQueueByUrl( + siteId, fileUrl, component, componentId, file.timemodified, path, undefined, 0, options); + } else { + promise = this.downloadUrl( + siteId, fileUrl, false, component, componentId, file.timemodified, onFileProgress, path, options); + } + + // Using undefined for success & fail will pass the success/failure to the parent promise. + promises.push(promise); + }); + + return Promise.all(promises).then(() => { + // Success prefetching, store package as downloaded. + return this.storePackageStatus(siteId, CoreConstants.DOWNLOADED, component, componentId, extra); + }).catch((error) => { + // Error downloading, go back to previous status and reject the promise. + return this.setPackagePreviousStatus(siteId, component, componentId).then(() => { + return Promise.reject(error); + }); + }); + + }).finally(() => { + // Download finished, delete the promise. + delete this.packagesPromises[siteId][packageId]; + }); + + this.packagesPromises[siteId][packageId] = promise; + + return promise; + } + + /** + * Downloads a list of files. + * + * @param siteId The site ID. + * @param fileList List of files to download. + * @param component The component to link the file to. + * @param componentId An ID to identify the download. + * @param extra Extra data to store for the package. + * @param dirPath Name of the directory where to store the files (inside filepool dir). If not defined, store + * the files directly inside the filepool folder. + * @param onProgress Function to call on progress. + * @return Promise resolved when all files are downloaded. + */ + downloadPackage(siteId: string, fileList: any[], component: string, componentId?: string | number, extra?: string, + dirPath?: string, onProgress?: (event: any) => any): Promise { + return this.downloadOrPrefetchPackage(siteId, fileList, false, component, componentId, extra, dirPath, onProgress); + } + + /** + * Downloads a file on the spot. + * + * @param siteId The site ID. + * @param fileUrl The file URL. + * @param ignoreStale Whether 'stale' should be ignored. + * @param component The component to link the file to. + * @param componentId An ID to use in conjunction with the component. + * @param timemodified The time this file was modified. Can be used to check file state. + * @param filePath Filepath to download the file to. If not defined, download to the filepool folder. + * @param options Extra options (isexternalfile, repositorytype). + * @param revision File revision. If not defined, it will be calculated using the URL. + * @return Resolved with internal URL on success, rejected otherwise. + * @description + * Downloads a file on the spot. + * + * This will also take care of adding the file to the pool if it's missing. However, please note that this will + * not force a file to be re-downloaded if it is already part of the pool. You should mark a file as stale using + * invalidateFileByUrl to trigger a download. + */ + downloadUrl(siteId: string, fileUrl: string, ignoreStale?: boolean, component?: string, componentId?: string | number, + timemodified: number = 0, onProgress?: (event: any) => any, filePath?: string, options: any = {}, revision?: number) + : Promise { + let fileId; + let promise; + let alreadyDownloaded = true; + + if (CoreFile.instance.isAvailable()) { + return this.fixPluginfileURL(siteId, fileUrl).then((file) => { + + fileUrl = file.fileurl; + timemodified = file.timemodified || timemodified; + + options = Object.assign({}, options); // Create a copy to prevent modifying the original object. + options.timemodified = timemodified || 0; + options.revision = revision || this.getRevisionFromUrl(fileUrl); + fileId = this.getFileIdByUrl(fileUrl); + + const links = this.createComponentLinks(component, componentId); + + return this.hasFileInPool(siteId, fileId).then((fileObject) => { + + if (typeof fileObject === 'undefined') { + // We do not have the file, download and add to pool. + this.notifyFileDownloading(siteId, fileId, links); + alreadyDownloaded = false; + + return this.downloadForPoolByUrl(siteId, fileUrl, options, filePath, onProgress); + + } else if (this.isFileOutdated(fileObject, options.revision, options.timemodified) && + CoreApp.instance.isOnline() && !ignoreStale) { + // The file is outdated, force the download and update it. + this.notifyFileDownloading(siteId, fileId, links); + alreadyDownloaded = false; + + return this.downloadForPoolByUrl(siteId, fileUrl, options, filePath, onProgress, fileObject); + } + + // Everything is fine, return the file on disk. + if (filePath) { + promise = this.getInternalUrlByPath(filePath); + } else { + promise = this.getInternalUrlById(siteId, fileId); + } + + return promise.then((response) => { + return response; + }, () => { + // The file was not found in the pool, weird. + this.notifyFileDownloading(siteId, fileId, links); + alreadyDownloaded = false; + + return this.downloadForPoolByUrl(siteId, fileUrl, options, filePath, onProgress, fileObject); + }); + + }, () => { + // The file is not in the pool just yet. + this.notifyFileDownloading(siteId, fileId, links); + alreadyDownloaded = false; + + return this.downloadForPoolByUrl(siteId, fileUrl, options, filePath, onProgress); + }).then((response) => { + if (typeof component != 'undefined') { + this.addFileLink(siteId, fileId, component, componentId).catch(() => { + // Ignore errors. + }); + } + + if (!alreadyDownloaded) { + this.notifyFileDownloaded(siteId, fileId, links); + } + + return response; + }, (err) => { + this.notifyFileDownloadError(siteId, fileId, links); + + return Promise.reject(err); + }); + }); + } else { + return Promise.reject(null); + } + } + + /** + * Extract the downloadable URLs from an HTML code. + * + * @param html HTML code. + * @return List of file urls. + */ + extractDownloadableFilesFromHtml(html: string): string[] { + let urls = []; + let elements; + + const element = CoreDomUtils.instance.convertToElement(html); + elements = element.querySelectorAll('a, img, audio, video, source, track'); + + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + let url = element.tagName === 'A' ? element.href : element.src; + + if (url && CoreUrlUtils.instance.isDownloadableUrl(url) && urls.indexOf(url) == -1) { + urls.push(url); + } + + // Treat video poster. + if (element.tagName == 'VIDEO' && element.getAttribute('poster')) { + url = element.getAttribute('poster'); + if (url && CoreUrlUtils.instance.isDownloadableUrl(url) && urls.indexOf(url) == -1) { + urls.push(url); + } + } + } + + // Now get other files from plugin file handlers. + urls = urls.concat(CorePluginFile.instance.getDownloadableFilesFromHTML(element)); + + return urls; + } + + /** + * Extract the downloadable URLs from an HTML code and returns them in fake file objects. + * + * @param html HTML code. + * @return List of fake file objects with file URLs. + */ + extractDownloadableFilesFromHtmlAsFakeFileObjects(html: string): CoreWSExternalFile[] { + const urls = this.extractDownloadableFilesFromHtml(html); + + // Convert them to fake file objects. + return urls.map((url) => { + return { + fileurl: url + }; + }); + } + + /** + * Fill Missing Extension In the File Object if needed. + * This is to migrate from old versions. + * + * @param fileObject File object to be migrated. + * @param siteId SiteID to get migrated. + * @return Promise resolved when done. + */ + protected fillExtensionInFile(entry: CoreFilepoolFileEntry, siteId: string): Promise { + if (typeof entry.extension != 'undefined') { + // Already filled. + return Promise.resolve(); + } + + return CoreSites.instance.getSiteDb(siteId).then((db) => { + const extension = CoreMimetypeUtils.instance.getFileExtension(entry.path); + if (!extension) { + // Files does not have extension. Invalidate file (stale = true). + // Minor problem: file will remain in the filesystem once downloaded again. + this.logger.debug('Staled file with no extension ' + entry.fileId); + + return db.updateRecords(this.FILES_TABLE, { stale: 1 }, { fileId: entry.fileId }); + } + + // File has extension. Save extension, and add extension to path. + const fileId = entry.fileId; + entry.fileId = CoreMimetypeUtils.instance.removeExtension(fileId); + entry.extension = extension; + + return db.updateRecords(this.FILES_TABLE, entry, { fileId }).then(() => { + if (entry.fileId == fileId) { + // File ID hasn't changed, we're done. + this.logger.debug('Removed extesion ' + extension + ' from file ' + entry.fileId); + + return; + } + + // Now update the links. + return db.updateRecords(this.LINKS_TABLE, { fileId: entry.fileId }, { fileId }); + }); + }); + } + + /** + * Fix a component ID to always be a Number if possible. + * + * @param componentId The component ID. + * @return The normalised component ID. -1 when undefined was passed. + */ + protected fixComponentId(componentId: string | number): string | number { + if (typeof componentId == 'number') { + return componentId; + } + + // Try to convert it to a number. + const id = parseInt(componentId, 10); + if (isNaN(id)) { + // Not a number. + if (typeof componentId == 'undefined' || componentId === null) { + return -1; + } else { + return componentId; + } + } + + return id; + } + + /** + * Check whether the file can be downloaded, add the wstoken url and points to the correct script. + * + * @param siteId The site ID. + * @param fileUrl The file URL. + * @param timemodified The timemodified of the file. + * @return Promise resolved with the file data to use. + */ + protected fixPluginfileURL(siteId: string, fileUrl: string, timemodified: number = 0): Promise { + + return CorePluginFile.instance.getDownloadableFile({fileurl: fileUrl, timemodified}).then((file) => { + + return CoreSites.instance.getSite(siteId).then((site) => { + return site.checkAndFixPluginfileURL(file.fileurl); + }).then((fixedUrl) => { + file.fileurl = fixedUrl; + + return file; + }); + }); + } + + /** + * Convenience function to get component files. + * + * @param db Site's DB. + * @param component The component to get. + * @param componentId An ID to use in conjunction with the component. + * @return Promise resolved with the files. + */ + protected getComponentFiles(db: SQLiteDB, component: string, componentId?: string | number): Promise { + const conditions = { + component, + componentId: this.fixComponentId(componentId), + }; + + return db.getRecords(this.LINKS_TABLE, conditions).then((items) => { + items.forEach((item) => { + item.componentId = this.fixComponentId(item.componentId); + }); + + return items; + }); + } + + /** + * Returns the local URL of a directory. + * + * @param siteId The site ID. + * @param fileUrl The file URL. + * @return Resolved with the URL. Rejected otherwise. + */ + getDirectoryUrlByUrl(siteId: string, fileUrl: string): Promise { + if (CoreFile.instance.isAvailable()) { + return this.fixPluginfileURL(siteId, fileUrl).then((file) => { + const fileId = this.getFileIdByUrl(file.fileurl); + const filePath = this.getFilePath(siteId, fileId, ''); // No extension, the function will return a string. + + return CoreFile.instance.getDir(filePath).then((dirEntry) => { + return dirEntry.toURL(); + }); + }); + } + + return Promise.reject(null); + } + + /** + * Get the ID of a file download. Used to keep track of filePromises. + * + * @param fileUrl The file URL. + * @param filePath The file destination path. + * @return File download ID. + */ + protected getFileDownloadId(fileUrl: string, filePath: string): string { + return Md5.hashAsciiStr(fileUrl + '###' + filePath); + } + + /** + * Get the name of the event used to notify download events (CoreEventsProvider). + * + * @param siteId The site ID. + * @param fileId The file ID. + * @return Event name. + */ + protected getFileEventName(siteId: string, fileId: string): string { + return 'CoreFilepoolFile:' + siteId + ':' + fileId; + } + + /** + * Get the name of the event used to notify download events (CoreEventsProvider). + * + * @param siteId The site ID. + * @param fileUrl The absolute URL to the file. + * @return Promise resolved with event name. + */ + getFileEventNameByUrl(siteId: string, fileUrl: string): Promise { + return this.fixPluginfileURL(siteId, fileUrl).then((file) => { + const fileId = this.getFileIdByUrl(file.fileurl); + + return this.getFileEventName(siteId, fileId); + }); + } + + /** + * Creates a unique ID based on a URL. + * + * This has a minimal handling of pluginfiles in order to generate a clean file ID which will not change if + * pointing to the same pluginfile URL even if the token or extra attributes have changed. + * + * @param fileUrl The absolute URL to the file. + * @return The file ID. + */ + protected getFileIdByUrl(fileUrl: string): string { + let url = fileUrl; + + // If site supports it, since 3.8 we use tokenpluginfile instead of pluginfile. + // For compatibility with files already downloaded, we need to use pluginfile to calculate the file ID. + url = url.replace(/\/tokenpluginfile\.php\/[^\/]+\//, '/webservice/pluginfile.php/'); + + // Remove the revision number from the URL so updates on the file aren't detected as a different file. + url = this.removeRevisionFromUrl(url); + + // Decode URL. + url = CoreTextUtils.instance.decodeHTML(CoreTextUtils.instance.decodeURIComponent(url)); + + if (url.indexOf('/webservice/pluginfile') !== -1) { + // Remove attributes that do not matter. + this.urlAttributes.forEach((regex) => { + url = url.replace(regex, ''); + }); + } + + // Try to guess the filename the target file should have. + // We want to keep the original file name so people can easily identify the files after the download. + const filename = this.guessFilenameFromUrl(url); + + return this.addHashToFilename(url, filename); + } + + /** + * Get the links of a file. + * + * @param siteId The site ID. + * @param fileId The file ID. + * @return Promise resolved with the links. + */ + protected getFileLinks(siteId: string, fileId: string): Promise { + return CoreSites.instance.getSiteDb(siteId).then((db) => { + return db.getRecords(this.LINKS_TABLE, { fileId }); + }).then((items) => { + items.forEach((item) => { + item.componentId = this.fixComponentId(item.componentId); + }); + + return items; + }); + } + + /** + * Get the path to a file. This does not check if the file exists or not. + * + * @param siteId The site ID. + * @param fileId The file ID. + * @param extension Previously calculated extension. Empty to not add any. Undefined to calculate it. + * @return The path to the file relative to storage root. + */ + protected getFilePath(siteId: string, fileId: string, extension?: string): string | Promise { + let path = this.getFilepoolFolderPath(siteId) + '/' + fileId; + if (typeof extension == 'undefined') { + // We need the extension to be able to open files properly. + return this.hasFileInPool(siteId, fileId).then((entry) => { + if (entry.extension) { + path += '.' + entry.extension; + } + + return path; + }).catch(() => { + // If file not found, use the path without extension. + return path; + }); + } else { + if (extension) { + path += '.' + extension; + } + + return path; + } + } + + /** + * Get the path to a file from its URL. This does not check if the file exists or not. + * + * @param siteId The site ID. + * @param fileUrl The file URL. + * @return Promise resolved with the path to the file relative to storage root. + */ + getFilePathByUrl(siteId: string, fileUrl: string): Promise { + return this.fixPluginfileURL(siteId, fileUrl).then((file) => { + const fileId = this.getFileIdByUrl(file.fileurl); + + return this.getFilePath(siteId, fileId); + }); + } + + /** + * Get site Filepool Folder Path + * + * @param siteId The site ID. + * @return The root path to the filepool of the site. + */ + getFilepoolFolderPath(siteId: string): string { + return CoreFile.instance.getSiteFolder(siteId) + '/' + this.FOLDER; + } + + /** + * Get all the matching files from a component. Returns objects containing properties like path, extension and url. + * + * @param siteId The site ID. + * @param component The component to get. + * @param componentId An ID to use in conjunction with the component. + * @return Promise resolved with the files on success. + */ + getFilesByComponent(siteId: string, component: string, componentId?: string | number): Promise { + return CoreSites.instance.getSiteDb(siteId).then((db) => { + return this.getComponentFiles(db, component, componentId).then((items) => { + const promises = []; + const files = []; + + items.forEach((item) => { + promises.push(db.getRecord(this.FILES_TABLE, { fileId: item.fileId }).then((fileEntry) => { + if (!fileEntry) { + return; + } + + files.push({ + url: fileEntry.url, + path: fileEntry.path, + extension: fileEntry.extension, + revision: fileEntry.revision, + timemodified: fileEntry.timemodified + }); + }).catch(() => { + // File not found, ignore error. + })); + }); + + return Promise.all(promises).then(() => { + return files; + }); + }); + }); + } + + /** + * Get the size of all the files from a component. + * + * @param siteId The site ID. + * @param component The component to get. + * @param componentId An ID to use in conjunction with the component. + * @return Promise resolved with the size on success. + */ + getFilesSizeByComponent(siteId: string, component: string, componentId?: string | number): Promise { + return this.getFilesByComponent(siteId, component, componentId).then((files) => { + const promises = []; + let size = 0; + + files.forEach((file) => { + promises.push(CoreFile.instance.getFileSize(file.path).then((fs) => { + size += fs; + }).catch(() => { + // Ignore failures, maybe some file was deleted. + })); + }); + + return Promise.all(promises).then(() => { + return size; + }); + }); + } + + /** + * Returns the file state: mmCoreDownloaded, mmCoreDownloading, mmCoreNotDownloaded or mmCoreOutdated. + * + * @param siteId The site ID. + * @param fileUrl File URL. + * @param timemodified The time this file was modified. + * @param filePath Filepath to download the file to. If defined, no extension will be added. + * @param revision File revision. If not defined, it will be calculated using the URL. + * @return Promise resolved with the file state. + */ + async getFileStateByUrl(siteId: string, fileUrl: string, timemodified: number = 0, filePath?: string, revision?: number) + : Promise { + let file; + + try { + file = await this.fixPluginfileURL(siteId, fileUrl, timemodified); + } catch (e) { + return CoreConstants.NOT_DOWNLOADABLE; + } + + fileUrl = file.fileurl; + timemodified = file.timemodified || timemodified; + revision = revision || this.getRevisionFromUrl(fileUrl); + const fileId = this.getFileIdByUrl(fileUrl); + + try { + // Check if the file is in queue (waiting to be downloaded). + await this.hasFileInQueue(siteId, fileId); + + return CoreConstants.DOWNLOADING; + } catch (e) { + // Check if the file is being downloaded right now. + const extension = CoreMimetypeUtils.instance.guessExtensionFromUrl(fileUrl); + filePath = filePath || (await this.getFilePath(siteId, fileId, extension)); + + const downloadId = this.getFileDownloadId(fileUrl, filePath); + + if (this.filePromises[siteId] && this.filePromises[siteId][downloadId]) { + return CoreConstants.DOWNLOADING; + } + + try { + // File is not being downloaded. Check if it's downloaded and if it's outdated. + const entry = await this.hasFileInPool(siteId, fileId); + + if (this.isFileOutdated(entry, revision, timemodified)) { + return CoreConstants.OUTDATED; + } + + return CoreConstants.DOWNLOADED; + } catch (e) { + return CoreConstants.NOT_DOWNLOADED; + } + } + } + + /** + * Returns an absolute URL to access the file URL. + * + * @param siteId The site ID. + * @param fileUrl The absolute URL to the file. + * @param mode The type of URL to return. Accepts 'url' or 'src'. + * @param component The component to link the file to. + * @param componentId An ID to use in conjunction with the component. + * @param timemodified The time this file was modified. + * @param checkSize True if we shouldn't download files if their size is big, false otherwise. + * @param downloadUnknown True to download file in WiFi if their size is unknown, false otherwise. + * Ignored if checkSize=false. + * @param options Extra options (isexternalfile, repositorytype). + * @param revision File revision. If not defined, it will be calculated using the URL. + * @return Resolved with the URL to use. + * @description + * This will return a URL pointing to the content of the requested URL. + * + * This handles the queue and validity of the file. If there is a local file and it's valid, return the local URL. + * If the file isn't downloaded or it's outdated, return the online URL and add it to the queue to be downloaded later. + */ + protected getFileUrlByUrl(siteId: string, fileUrl: string, component: string, componentId?: string | number, + mode: string = 'url', timemodified: number = 0, checkSize: boolean = true, downloadUnknown?: boolean, + options: any = {}, revision?: number): Promise { + + let fileId; + const addToQueue = (fileUrl): void => { + // Add the file to queue if needed and ignore errors. + this.addToQueueIfNeeded(siteId, fileUrl, component, componentId, timemodified, checkSize, + downloadUnknown, options, revision).catch(() => { + // Ignore errors. + }); + }; + + return this.fixPluginfileURL(siteId, fileUrl, timemodified).then((file) => { + + fileUrl = file.fileurl; + timemodified = file.timemodified || timemodified; + revision = revision || this.getRevisionFromUrl(fileUrl); + fileId = this.getFileIdByUrl(fileUrl); + + return this.hasFileInPool(siteId, fileId).then((entry) => { + let response; + + if (typeof entry === 'undefined') { + // We do not have the file, add it to the queue, and return real URL. + addToQueue(fileUrl); + response = fileUrl; + + } else if (this.isFileOutdated(entry, revision, timemodified) && CoreApp.instance.isOnline()) { + // The file is outdated, we add to the queue and return real URL. + addToQueue(fileUrl); + response = fileUrl; + } else { + // We found the file entry, now look for the file on disk. + if (mode === 'src') { + response = this.getInternalSrcById(siteId, fileId); + } else { + response = this.getInternalUrlById(siteId, fileId); + } + + response = response.then((internalUrl) => { + // The file is on disk. + return internalUrl; + }).catch(() => { + // We could not retrieve the file, delete the entries associated with that ID. + this.logger.debug('File ' + fileId + ' not found on disk'); + this.removeFileById(siteId, fileId); + addToQueue(fileUrl); + + if (CoreApp.instance.isOnline()) { + // We still have a chance to serve the right content. + return fileUrl; + } + + return Promise.reject(null); + }); + } + + return response; + }, () => { + // We do not have the file in store yet. Add to queue and return the fixed URL. + addToQueue(fileUrl); + + return fileUrl; + }); + }); + } + + /** + * Returns the internal SRC of a file. + * + * The returned URL from this method is typically used with IMG tags. + * + * @param siteId The site ID. + * @param fileId The file ID. + * @return Resolved with the internal URL. Rejected otherwise. + */ + protected getInternalSrcById(siteId: string, fileId: string): Promise { + if (CoreFile.instance.isAvailable()) { + return Promise.resolve(this.getFilePath(siteId, fileId)).then((path) => { + return CoreFile.instance.getFile(path).then((fileEntry) => { + return CoreFile.instance.convertFileSrc(fileEntry.toURL()); + }); + }); + } + + return Promise.reject(null); + } + + /** + * Returns the local URL of a file. + * + * @param siteId The site ID. + * @param fileId The file ID. + * @return Resolved with the URL. Rejected otherwise. + */ + protected getInternalUrlById(siteId: string, fileId: string): Promise { + if (CoreFile.instance.isAvailable()) { + return Promise.resolve(this.getFilePath(siteId, fileId)).then((path) => { + return CoreFile.instance.getFile(path).then((fileEntry) => { + // This URL is usually used to launch files or put them in HTML. In desktop we need the internal URL. + if (CoreApp.instance.isDesktop()) { + return fileEntry.toInternalURL(); + } else { + return fileEntry.toURL(); + } + }); + }); + } + + return Promise.reject(null); + } + + /** + * Returns the local URL of a file. + * + * @param filePath The file path. + * @return Resolved with the URL. + */ + protected getInternalUrlByPath(filePath: string): Promise { + if (CoreFile.instance.isAvailable()) { + return CoreFile.instance.getFile(filePath).then((fileEntry) => { + return fileEntry.toURL(); + }); + } + + return Promise.reject(null); + } + + /** + * Returns the local URL of a file. + * + * @param siteId The site ID. + * @param fileUrl The file URL. + * @return Resolved with the URL. Rejected otherwise. + */ + getInternalUrlByUrl(siteId: string, fileUrl: string): Promise { + if (CoreFile.instance.isAvailable()) { + return this.fixPluginfileURL(siteId, fileUrl).then((file) => { + const fileId = this.getFileIdByUrl(file.fileurl); + + return this.getInternalUrlById(siteId, fileId); + }); + } + + return Promise.reject(null); + } + + /** + * Get the data stored for a package. + * + * @param siteId Site ID. + * @param component Package's component. + * @param componentId An ID to use in conjunction with the component. + * @return Promise resolved with the data. + */ + getPackageData(siteId: string, component: string, componentId?: string | number): Promise { + componentId = this.fixComponentId(componentId); + + return CoreSites.instance.getSite(siteId).then((site) => { + const packageId = this.getPackageId(component, componentId); + + return site.getDb().getRecord(this.PACKAGES_TABLE, { id: packageId }); + }); + } + + /** + * Creates the name for a package directory (hash). + * + * @param url An URL to identify the package. + * @return The directory name. + */ + protected getPackageDirNameByUrl(url: string): string { + let extension = ''; + + url = this.removeRevisionFromUrl(url); + + if (url.indexOf('/webservice/pluginfile') !== -1) { + // Remove attributes that do not matter. + this.urlAttributes.forEach((regex) => { + url = url.replace(regex, ''); + }); + + // Guess the extension of the URL. This is for backwards compatibility. + const candidate = CoreMimetypeUtils.instance.guessExtensionFromUrl(url); + if (candidate && candidate !== 'php') { + extension = '.' + candidate; + } + } + + return Md5.hashAsciiStr('url:' + url) + extension; + } + + /** + * Get the path to a directory to store a package files. This does not check if the file exists or not. + * + * @param siteId The site ID. + * @param url An URL to identify the package. + * @return Promise resolved with the path of the package. + */ + getPackageDirPathByUrl(siteId: string, url: string): Promise { + return this.fixPluginfileURL(siteId, url).then((file) => { + const dirName = this.getPackageDirNameByUrl(file.fileurl); + + return this.getFilePath(siteId, dirName, ''); + }); + } + + /** + * Returns the local URL of a package directory. + * + * @param siteId The site ID. + * @param url An URL to identify the package. + * @return Resolved with the URL. + */ + getPackageDirUrlByUrl(siteId: string, url: string): Promise { + if (CoreFile.instance.isAvailable()) { + return this.fixPluginfileURL(siteId, url).then((file) => { + const dirName = this.getPackageDirNameByUrl(file.fileurl); + const dirPath = this.getFilePath(siteId, dirName, ''); // No extension, the function will return a string. + + return CoreFile.instance.getDir(dirPath).then((dirEntry) => { + return dirEntry.toURL(); + }); + }); + } + + return Promise.reject(null); + } + + /** + * Get a download promise. If the promise is not set, return undefined. + * + * @param siteId Site ID. + * @param component The component of the package. + * @param componentId An ID to use in conjunction with the component. + * @return Download promise or undefined. + */ + getPackageDownloadPromise(siteId: string, component: string, componentId?: string | number): Promise { + const packageId = this.getPackageId(component, componentId); + if (this.packagesPromises[siteId] && this.packagesPromises[siteId][packageId]) { + return this.packagesPromises[siteId][packageId]; + } + } + /** + * Get a package extra data. + * + * @param siteId Site ID. + * @param component Package's component. + * @param componentId An ID to use in conjunction with the component. + * @return Promise resolved with the extra data. + */ + getPackageExtra(siteId: string, component: string, componentId?: string | number): Promise { + return this.getPackageData(siteId, component, componentId).then((entry) => { + return entry.extra; + }); + } + + /** + * Get the ID of a package. + * + * @param component Package's component. + * @param componentId An ID to use in conjunction with the component. + * @return Package ID. + */ + getPackageId(component: string, componentId?: string | number): string { + return Md5.hashAsciiStr(component + '#' + this.fixComponentId(componentId)); + } + + /** + * Get a package previous status. + * + * @param siteId Site ID. + * @param component Package's component. + * @param componentId An ID to use in conjunction with the component. + * @return Promise resolved with the status. + */ + getPackagePreviousStatus(siteId: string, component: string, componentId?: string | number): Promise { + return this.getPackageData(siteId, component, componentId).then((entry) => { + return entry.previous || CoreConstants.NOT_DOWNLOADED; + }).catch(() => { + return CoreConstants.NOT_DOWNLOADED; + }); + } + + /** + * Get a package status. + * + * @param siteId Site ID. + * @param component Package's component. + * @param componentId An ID to use in conjunction with the component. + * @return Promise resolved with the status. + */ + getPackageStatus(siteId: string, component: string, componentId?: string | number): Promise { + return this.getPackageData(siteId, component, componentId).then((entry) => { + return entry.status || CoreConstants.NOT_DOWNLOADED; + }).catch(() => { + return CoreConstants.NOT_DOWNLOADED; + }); + } + + /** + * Return the array of arguments of the pluginfile url. + * + * @param url URL to get the args. + * @return The args found, undefined if not a pluginfile. + */ + protected getPluginFileArgs(url: string): string[] { + if (!CoreUrlUtils.instance.isPluginFileUrl(url)) { + // Not pluginfile, return. + return; + } + + const relativePath = url.substr(url.indexOf('/pluginfile.php') + 16); + const args = relativePath.split('/'); + + if (args.length < 3) { + // To be a plugin file it should have at least contextId, Component and Filearea. + return; + } + + return args; + } + + /** + * Get the deferred object for a file in the queue. + * + * @param siteId The site ID. + * @param fileId The file ID. + * @param create True if it should create a new deferred if it doesn't exist. + * @param onProgress Function to call on progress. + * @return Deferred. + */ + protected getQueueDeferred(siteId: string, fileId: string, create: boolean = true, onProgress?: (event: any) => any): any { + if (!this.queueDeferreds[siteId]) { + if (!create) { + return; + } + this.queueDeferreds[siteId] = {}; + } + if (!this.queueDeferreds[siteId][fileId]) { + if (!create) { + return; + } + this.queueDeferreds[siteId][fileId] = CoreUtils.instance.promiseDefer(); + } + + if (onProgress) { + this.queueDeferreds[siteId][fileId].onProgress = onProgress; + } + + return this.queueDeferreds[siteId][fileId]; + } + + /** + * Get the on progress for a file in the queue. + * + * @param siteId The site ID. + * @param fileId The file ID. + * @return On progress function, undefined if not found. + */ + protected getQueueOnProgress(siteId: string, fileId: string): (event: any) => any { + const deferred = this.getQueueDeferred(siteId, fileId, false); + if (deferred) { + return deferred.onProgress; + } + } + + /** + * Get the promise for a file in the queue. + * + * @param siteId The site ID. + * @param fileId The file ID. + * @param create True if it should create a new promise if it doesn't exist. + * @param onProgress Function to call on progress. + * @return Promise. + */ + protected getQueuePromise(siteId: string, fileId: string, create: boolean = true, onProgress?: (event: any) => any) + : Promise { + return this.getQueueDeferred(siteId, fileId, create, onProgress).promise; + } + + /** + * Get a revision number from a list of files (highest revision). + * + * @param files Package files. + * @return Highest revision. + */ + getRevisionFromFileList(files: any[]): number { + let revision = 0; + + files.forEach((file) => { + if (file.url || file.fileurl) { + const r = this.getRevisionFromUrl(file.url || file.fileurl); + if (r > revision) { + revision = r; + } + } + }); + + return revision; + } + + /** + * Get the revision number from a file URL. + * + * @param url URL to get the revision number. + * @return Revision number. + */ + protected getRevisionFromUrl(url: string): number { + const args = this.getPluginFileArgs(url); + if (!args) { + // Not a pluginfile, no revision will be found. + return 0; + } + + const revisionRegex = CorePluginFile.instance.getComponentRevisionRegExp(args); + if (!revisionRegex) { + return 0; + } + + const matches = url.match(revisionRegex); + if (matches && typeof matches[1] != 'undefined') { + return parseInt(matches[1], 10); + } + + return 0; + } + + /** + * Returns an absolute URL to use in IMG tags. + * + * @param siteId The site ID. + * @param fileUrl The absolute URL to the file. + * @param mode The type of URL to return. Accepts 'url' or 'src'. + * @param component The component to link the file to. + * @param componentId An ID to use in conjunction with the component. + * @param timemodified The time this file was modified. + * @param checkSize True if we shouldn't download files if their size is big, false otherwise. + * @param downloadUnknown True to download file in WiFi if their size is unknown, false otherwise. + * Ignored if checkSize=false. + * @param options Extra options (isexternalfile, repositorytype). + * @param revision File revision. If not defined, it will be calculated using the URL. + * @return Resolved with the URL to use. + * @description + * This will return a URL pointing to the content of the requested URL. + * The URL returned is compatible to use with IMG tags. + */ + getSrcByUrl(siteId: string, fileUrl: string, component: string, componentId?: string | number, timemodified: number = 0, + checkSize: boolean = true, downloadUnknown?: boolean, options: any = {}, revision?: number): Promise { + return this.getFileUrlByUrl(siteId, fileUrl, component, componentId, 'src', + timemodified, checkSize, downloadUnknown, options, revision); + } + + /** + * Get time modified from a list of files. + * + * @param files List of files. + * @return Time modified. + */ + getTimemodifiedFromFileList(files: any[]): number { + let timemodified = 0; + + files.forEach((file) => { + if (file.timemodified > timemodified) { + timemodified = file.timemodified; + } + }); + + return timemodified; + } + + /** + * Returns an absolute URL to access the file. + * + * @param siteId The site ID. + * @param fileUrl The absolute URL to the file. + * @param mode The type of URL to return. Accepts 'url' or 'src'. + * @param component The component to link the file to. + * @param componentId An ID to use in conjunction with the component. + * @param timemodified The time this file was modified. + * @param checkSize True if we shouldn't download files if their size is big, false otherwise. + * @param downloadUnknown True to download file in WiFi if their size is unknown, false otherwise. + * Ignored if checkSize=false. + * @param options Extra options (isexternalfile, repositorytype). + * @param revision File revision. If not defined, it will be calculated using the URL. + * @return Resolved with the URL to use. + * @description + * This will return a URL pointing to the content of the requested URL. + * The URL returned is compatible to use with a local browser. + */ + getUrlByUrl(siteId: string, fileUrl: string, component: string, componentId?: string | number, timemodified: number = 0, + checkSize: boolean = true, downloadUnknown?: boolean, options: any = {}, revision?: number): Promise { + return this.getFileUrlByUrl(siteId, fileUrl, component, componentId, 'url', + timemodified, checkSize, downloadUnknown, options, revision); + } + + /** + * Guess the filename of a file from its URL. This is very weak and unreliable. + * + * @param fileUrl The file URL. + * @return The filename treated so it doesn't have any special character. + */ + protected guessFilenameFromUrl(fileUrl: string): string { + let filename = ''; + + if (fileUrl.indexOf('/webservice/pluginfile') !== -1) { + // It's a pluginfile URL. Search for the 'file' param to extract the name. + const params = CoreUrlUtils.instance.extractUrlParams(fileUrl); + if (params.file) { + filename = params.file.substr(params.file.lastIndexOf('/') + 1); + } else { + // 'file' param not found. Extract what's after the last '/' without params. + filename = CoreUrlUtils.instance.getLastFileWithoutParams(fileUrl); + } + + } else if (CoreUrlUtils.instance.isGravatarUrl(fileUrl)) { + // Extract gravatar ID. + filename = 'gravatar_' + CoreUrlUtils.instance.getLastFileWithoutParams(fileUrl); + } else if (CoreUrlUtils.instance.isThemeImageUrl(fileUrl)) { + // Extract user ID. + const matches = fileUrl.match(/\/core\/([^\/]*)\//); + if (matches && matches[1]) { + filename = matches[1]; + } + // Attach a constant and the image type. + filename = 'default_' + filename + '_' + CoreUrlUtils.instance.getLastFileWithoutParams(fileUrl); + } else { + // Another URL. Just get what's after the last /. + filename = CoreUrlUtils.instance.getLastFileWithoutParams(fileUrl); + } + + // If there are hashes in the URL, extract them. + const index = filename.indexOf('#'); + let hashes; + + if (index != -1) { + hashes = filename.split('#'); + + // Remove the URL from the array. + hashes.shift(); + + filename = filename.substr(0, index); + } + + // Remove the extension from the filename. + filename = CoreMimetypeUtils.instance.removeExtension(filename); + + if (hashes) { + // Add hashes to the name. + filename += '_' + hashes.join('_'); + } + + return CoreTextUtils.instance.removeSpecialCharactersForFiles(filename); + } + + /** + * Check if the file is already in the pool. This does not check if the file is on the disk. + * + * @param siteId The site ID. + * @param fileUrl The file URL. + * @return Resolved with file object from DB on success, rejected otherwise. + */ + protected hasFileInPool(siteId: string, fileId: string): Promise { + return CoreSites.instance.getSiteDb(siteId).then((db) => { + return db.getRecord(this.FILES_TABLE, { fileId }).then((entry) => { + if (typeof entry === 'undefined') { + return Promise.reject(null); + } + + return entry; + }); + }); + } + + /** + * Check if the file is in the queue. + * + * @param siteId The site ID. + * @param fileUrl The file URL. + * @return Resolved with file object from DB on success, rejected otherwise. + */ + protected async hasFileInQueue(siteId: string, fileId: string): Promise { + await this.dbReady; + + const entry = await this.appDB.getRecord(this.QUEUE_TABLE, { siteId, fileId }); + if (typeof entry === 'undefined') { + throw null; + } + // Convert the links to an object. + entry.links = CoreTextUtils.instance.parseJSON(entry.links, []); + + return entry; + } + + /** + * Invalidate all the files in a site. + * + * @param siteId The site ID. + * @param onlyUnknown True to only invalidate files from external repos or without revision/timemodified. + * It is advised to set it to true to reduce the performance and data usage of the app. + * @return Resolved on success. + */ + async invalidateAllFiles(siteId: string, onlyUnknown: boolean = true): Promise { + const db = await CoreSites.instance.getSiteDb(siteId); + + const where = onlyUnknown ? this.FILE_UPDATE_UNKNOWN_WHERE_CLAUSE : null; + + await db.updateRecordsWhere(this.FILES_TABLE, { stale: 1 }, where); + } + + /** + * Invalidate a file by URL. + * + * @param siteId The site ID. + * @param fileUrl The file URL. + * @return Resolved on success. + * @description + * Invalidates a file by marking it stale. It will not be added to the queue automatically, but the next time this file + * is requested it will be added to the queue. + * You can manully call addToQueueByUrl to add this file to the queue immediately. + * Please note that, if a file is stale, the user will be presented the stale file if there is no network access. + */ + invalidateFileByUrl(siteId: string, fileUrl: string): Promise { + return this.fixPluginfileURL(siteId, fileUrl).then((file) => { + const fileId = this.getFileIdByUrl(file.fileurl); + + return CoreSites.instance.getSiteDb(siteId).then((db) => { + return db.updateRecords(this.FILES_TABLE, { stale: 1 }, { fileId }); + }); + }); + } + + /** + * Invalidate all the matching files from a component. + * + * @param siteId The site ID. + * @param component The component to invalidate. + * @param componentId An ID to use in conjunction with the component. + * @param onlyUnknown True to only invalidate files from external repos or without revision/timemodified. + * It is advised to set it to true to reduce the performance and data usage of the app. + * @return Resolved when done. + */ + async invalidateFilesByComponent(siteId: string, component: string, componentId?: string | number, onlyUnknown: boolean = true) + : Promise { + + const db = await CoreSites.instance.getSiteDb(siteId); + + const items = await this.getComponentFiles(db, component, componentId); + + if (!items.length) { + // Nothing to invalidate. + return; + } + + const fileIds = items.map((item) => item.fileId); + const whereAndParams = db.getInOrEqual(fileIds); + + whereAndParams[0] = 'fileId ' + whereAndParams[0]; + + if (onlyUnknown) { + whereAndParams[0] += ' AND (' + this.FILE_UPDATE_UNKNOWN_WHERE_CLAUSE + ')'; + } + + await db.updateRecordsWhere(this.FILES_TABLE, { stale: 1 }, whereAndParams[0], whereAndParams[1]); + } + + /** + * Whether a file action indicates a file was downloaded or deleted. + * + * @param data Event data. + * @return Whether downloaded or deleted. + */ + isFileEventDownloadedOrDeleted(data: CoreFilepoolFileEventData): boolean { + return (data.action == CoreFilepoolFileActions.DOWNLOAD && data.success == true) || + data.action == CoreFilepoolFileActions.DELETED; + } + + /** + * Check whether a file is downloadable. + * + * @param siteId The site ID. + * @param fileUrl File URL. + * @param timemodified The time this file was modified. + * @param filePath Filepath to download the file to. If defined, no extension will be added. + * @param revision File revision. If not defined, it will be calculated using the URL. + * @return Promise resolved with a boolean: whether a file is downloadable. + */ + async isFileDownloadable(siteId: string, fileUrl: string, timemodified: number = 0, filePath?: string, revision?: number) + : Promise { + const state = await this.getFileStateByUrl(siteId, fileUrl, timemodified, filePath, revision); + + return state != CoreConstants.NOT_DOWNLOADABLE; + } + + /** + * Check if a file is downloading. + * + * @param siteId The site ID. + * @param fileUrl File URL. + * @param Promise resolved if file is downloading, rejected otherwise. + */ + isFileDownloadingByUrl(siteId: string, fileUrl: string): Promise { + return this.fixPluginfileURL(siteId, fileUrl).then((file) => { + const fileId = this.getFileIdByUrl(file.fileurl); + + return this.hasFileInQueue(siteId, fileId); + }); + } + + /** + * Check if a file is outdated. + * + * @param entry Filepool entry. + * @param revision File revision number. + * @param timemodified The time this file was modified. + * @param Whether the file is outdated. + */ + protected isFileOutdated(entry: CoreFilepoolFileEntry, revision?: number, timemodified?: number): boolean { + return !!entry.stale || revision > entry.revision || timemodified > entry.timemodified; + } + + /** + * Check if cannot determine if a file has been updated. + * + * @param entry Filepool entry. + * @return Whether it cannot determine updates. + */ + protected isFileUpdateUnknown(entry: CoreFilepoolFileEntry): boolean { + return !!entry.isexternalfile || (!entry.revision && !entry.timemodified); + } + + /** + * Notify an action performed on a file to a list of components. + * + * @param siteId The site ID. + * @param eventData The file event data. + * @param links The links to the components. + */ + protected notifyFileActionToComponents(siteId: string, eventData: CoreFilepoolFileEventData, + links: CoreFilepoolComponentLink[]): void { + + links.forEach((link) => { + const data: CoreFilepoolComponentFileEventData = Object.assign({ + component: link.component, + componentId: link.componentId, + }, eventData); + + CoreEvents.instance.trigger(CoreEventsProvider.COMPONENT_FILE_ACTION, data, siteId); + }); + } + + /** + * Notify a file has been deleted. + * + * @param siteId The site ID. + * @param fileId The file ID. + * @param links The links to components. + */ + protected notifyFileDeleted(siteId: string, fileId: string, links: CoreFilepoolComponentLink[]): void { + const data: CoreFilepoolFileEventData = { + fileId, + action: CoreFilepoolFileActions.DELETED, + }; + + CoreEvents.instance.trigger(this.getFileEventName(siteId, fileId), data); + this.notifyFileActionToComponents(siteId, data, links); + } + + /** + * Notify a file has been downloaded. + * + * @param siteId The site ID. + * @param fileId The file ID. + * @param links The links to components. + */ + protected notifyFileDownloaded(siteId: string, fileId: string, links: CoreFilepoolComponentLink[]): void { + const data: CoreFilepoolFileEventData = { + fileId, + action: CoreFilepoolFileActions.DOWNLOAD, + success: true, + }; + + CoreEvents.instance.trigger(this.getFileEventName(siteId, fileId), data); + this.notifyFileActionToComponents(siteId, data, links); + } + + /** + * Notify error occurred while downloading a file. + * + * @param siteId The site ID. + * @param fileId The file ID. + * @param links The links to components. + */ + protected notifyFileDownloadError(siteId: string, fileId: string, links: CoreFilepoolComponentLink[]): void { + const data: CoreFilepoolFileEventData = { + fileId, + action: CoreFilepoolFileActions.DOWNLOAD, + success: false, + }; + + CoreEvents.instance.trigger(this.getFileEventName(siteId, fileId), data); + this.notifyFileActionToComponents(siteId, data, links); + } + + /** + * Notify a file starts being downloaded or added to queue. + * + * @param siteId The site ID. + * @param fileId The file ID. + * @param links The links to components. + */ + protected notifyFileDownloading(siteId: string, fileId: string, links: CoreFilepoolComponentLink[]): void { + const data: CoreFilepoolFileEventData = { + fileId, + action: CoreFilepoolFileActions.DOWNLOADING, + }; + + CoreEvents.instance.trigger(this.getFileEventName(siteId, fileId), data); + this.notifyFileActionToComponents(siteId, data, links); + + } + + /** + * Notify a file has been outdated. + * + * @param siteId The site ID. + * @param fileId The file ID. + * @param links The links to components. + */ + protected notifyFileOutdated(siteId: string, fileId: string, links: CoreFilepoolComponentLink[]): void { + const data: CoreFilepoolFileEventData = { + fileId, + action: CoreFilepoolFileActions.OUTDATED, + }; + + CoreEvents.instance.trigger(this.getFileEventName(siteId, fileId), data); + this.notifyFileActionToComponents(siteId, data, links); + } + + /** + * Prefetches a list of files. + * + * @param siteId The site ID. + * @param fileList List of files to download. + * @param component The component to link the file to. + * @param componentId An ID to identify the download. + * @param extra Extra data to store for the package. + * @param dirPath Name of the directory where to store the files (inside filepool dir). If not defined, store + * the files directly inside the filepool folder. + * @param onProgress Function to call on progress. + * @return Promise resolved when all files are downloaded. + */ + prefetchPackage(siteId: string, fileList: any[], component: string, componentId?: string | number, extra?: string, + dirPath?: string, onProgress?: (event: any) => any): Promise { + return this.downloadOrPrefetchPackage(siteId, fileList, true, component, componentId, extra, dirPath, onProgress); + } + + /** + * Process the queue. + * + * @description + * This loops over itself to keep on processing the queue in the background. + * The queue process is site agnostic. + */ + protected processQueue(): void { + let promise; + + if (this.queueState !== this.QUEUE_RUNNING) { + // Silently ignore, the queue is on pause. + promise = Promise.reject(this.ERR_QUEUE_ON_PAUSE); + + } else if (!CoreFile.instance.isAvailable() || !CoreApp.instance.isOnline()) { + promise = Promise.reject(this.ERR_FS_OR_NETWORK_UNAVAILABLE); + + } else { + promise = this.processImportantQueueItem(); + } + + promise.then(() => { + // All good, we schedule next execution. + setTimeout(() => { + this.processQueue(); + }, this.QUEUE_PROCESS_INTERVAL); + + }, (error) => { + + // We had an error, in which case we pause the processing. + if (error === this.ERR_FS_OR_NETWORK_UNAVAILABLE) { + this.logger.debug('Filesysem or network unavailable, pausing queue processing.'); + + } else if (error === this.ERR_QUEUE_IS_EMPTY) { + this.logger.debug('Queue is empty, pausing queue processing.'); + } + + this.queueState = this.QUEUE_PAUSED; + }); + } + + /** + * Process the most important queue item. + * + * @return Resolved on success. Rejected on failure. + */ + protected async processImportantQueueItem(): Promise { + await this.dbReady; + + let items; + + try { + items = await this.appDB.getRecords(this.QUEUE_TABLE, undefined, 'priority DESC, added ASC', undefined, 0, 1); + } catch (err) { + throw this.ERR_QUEUE_IS_EMPTY; + } + + const item = items.pop(); + if (!item) { + throw this.ERR_QUEUE_IS_EMPTY; + } + // Convert the links to an object. + item.links = CoreTextUtils.instance.parseJSON(item.links, []); + + return this.processQueueItem(item); + } + + /** + * Process a queue item. + * + * @param item The object from the queue store. + * @return Resolved on success. Rejected on failure. + */ + protected processQueueItem(item: CoreFilepoolQueueEntry): Promise { + // Cast optional fields to undefined instead of null. + const siteId = item.siteId; + const fileId = item.fileId; + const fileUrl = item.url; + const options = { + revision: item.revision || undefined, + timemodified: item.timemodified || undefined, + isexternalfile: item.isexternalfile || undefined, + repositorytype: item.repositorytype || undefined, + }; + const filePath = item.path || undefined; + const links = item.links || []; + + this.logger.debug('Processing queue item: ' + siteId + ', ' + fileId); + + // Check if the file is already in pool. + return this.hasFileInPool(siteId, fileId).catch(() => { + // File not in pool. + }).then((entry: CoreFilepoolFileEntry) => { + + if (entry && !options.isexternalfile && !this.isFileOutdated(entry, options.revision, options.timemodified)) { + // We have the file, it is not stale, we can update links and remove from queue. + this.logger.debug('Queued file already in store, ignoring...'); + this.addFileLinks(siteId, fileId, links).catch(() => { + // Ignore errors. + }); + this.removeFromQueue(siteId, fileId).catch(() => { + // Ignore errors. + }).finally(() => { + this.treatQueueDeferred(siteId, fileId, true); + }); + + return; + } + + // The file does not exist, or is stale, ... download it. + const onProgress = this.getQueueOnProgress(siteId, fileId); + + return this.downloadForPoolByUrl(siteId, fileUrl, options, filePath, onProgress, entry).then(() => { + // Success, we add links and remove from queue. + this.addFileLinks(siteId, fileId, links).catch(() => { + // Ignore errors. + }); + + this.treatQueueDeferred(siteId, fileId, true); + this.notifyFileDownloaded(siteId, fileId, links); + + // Wait for the item to be removed from queue before resolving the promise. + // If the item could not be removed from queue we still resolve the promise. + return this.removeFromQueue(siteId, fileId).catch(() => { + // Ignore errors. + }); + }, (errorObject) => { + // Whoops, we have an error... + let dropFromQueue = false; + + if (errorObject && errorObject.source === fileUrl) { + // This is most likely a FileTransfer error. + if (errorObject.code === 1) { // FILE_NOT_FOUND_ERR. + // The file was not found, most likely a 404, we remove from queue. + dropFromQueue = true; + } else if (errorObject.code === 2) { // INVALID_URL_ERR. + // The URL is invalid, we drop the file from the queue. + dropFromQueue = true; + } else if (errorObject.code === 3) { // CONNECTION_ERR. + // If there was an HTTP status, then let's remove from the queue. + dropFromQueue = true; + } else if (errorObject.code === 4) { // ABORTED_ERR. + // The transfer was aborted, we will keep the file in queue. + } else if (errorObject.code === 5) { // NOT_MODIFIED_ERR. + // We have the latest version of the file, HTTP 304 status. + dropFromQueue = true; + } else { + // Unknown error, let's remove the file from the queue to avoi locking down the queue. + dropFromQueue = true; + } + } else { + dropFromQueue = true; + } + + let errorMessage = null; + // Some Android devices restrict the amount of usable storage using quotas. + // If this quota would be exceeded by the download, it throws an exception. + // We catch this exception here, and report a meaningful error message to the user. + if (errorObject instanceof FileTransferError && errorObject.exception && errorObject.exception.includes('EDQUOT')) { + errorMessage = 'core.course.insufficientavailablequota'; + } + + if (dropFromQueue) { + this.logger.debug('Item dropped from queue due to error: ' + fileUrl, errorObject); + + return this.removeFromQueue(siteId, fileId).catch(() => { + // Consider this as a silent error, never reject the promise here. + }).then(() => { + this.treatQueueDeferred(siteId, fileId, false, errorMessage); + this.notifyFileDownloadError(siteId, fileId, links); + }); + } else { + // We considered the file as legit but did not get it, failure. + this.treatQueueDeferred(siteId, fileId, false, errorMessage); + this.notifyFileDownloadError(siteId, fileId, links); + + return Promise.reject(errorObject); + } + + }); + }); + } + + /** + * Remove a file from the queue. + * + * @param siteId The site ID. + * @param fileId The file ID. + * @return Resolved on success. Rejected on failure. It is advised to silently ignore failures. + */ + protected async removeFromQueue(siteId: string, fileId: string): Promise { + await this.dbReady; + + return this.appDB.deleteRecords(this.QUEUE_TABLE, { siteId, fileId }); + } + + /** + * Remove a file from the pool. + * + * @param siteId The site ID. + * @param fileId The file ID. + * @return Resolved on success. + */ + protected removeFileById(siteId: string, fileId: string): Promise { + return CoreSites.instance.getSiteDb(siteId).then((db) => { + // Get the path to the file first since it relies on the file object stored in the pool. + // Don't use getFilePath to prevent performing 2 DB requests. + let path = this.getFilepoolFolderPath(siteId) + '/' + fileId; + let fileUrl; + + return this.hasFileInPool(siteId, fileId).then((entry) => { + fileUrl = entry.url; + + if (entry.extension) { + path += '.' + entry.extension; + } + + return path; + }).catch(() => { + // If file not found, use the path without extension. + return path; + }).then((path) => { + const conditions = { + fileId, + }; + + // Get links to components to notify them after remove. + return this.getFileLinks(siteId, fileId).then((links) => { + const promises = []; + + // Remove entry from filepool store. + promises.push(db.deleteRecords(this.FILES_TABLE, conditions)); + + // Remove links. + promises.push(db.deleteRecords(this.LINKS_TABLE, conditions)); + + // Remove the file. + if (CoreFile.instance.isAvailable()) { + promises.push(CoreFile.instance.removeFile(path).catch((error) => { + if (error && error.code == 1) { + // Not found, ignore error since maybe it was deleted already. + } else { + return Promise.reject(error); + } + })); + } + + return Promise.all(promises).then(() => { + this.notifyFileDeleted(siteId, fileId, links); + + return CorePluginFile.instance.fileDeleted(fileUrl, path, siteId).catch((error) => { + // Ignore errors. + }); + }); + }); + }); + }); + } + + /** + * Delete all the matching files from a component. + * + * @param siteId The site ID. + * @param component The component to link the file to. + * @param componentId An ID to use in conjunction with the component. + * @return Resolved on success. + */ + removeFilesByComponent(siteId: string, component: string, componentId?: string | number): Promise { + return CoreSites.instance.getSiteDb(siteId).then((db) => { + return this.getComponentFiles(db, component, componentId); + }).then((items) => { + return Promise.all(items.map((item) => { + return this.removeFileById(siteId, item.fileId); + })); + }); + } + + /** + * Remove a file from the pool. + * + * @param siteId The site ID. + * @param fileUrl The file URL. + * @return Resolved on success, rejected on failure. + */ + removeFileByUrl(siteId: string, fileUrl: string): Promise { + return this.fixPluginfileURL(siteId, fileUrl).then((file) => { + const fileId = this.getFileIdByUrl(file.fileurl); + + return this.removeFileById(siteId, fileId); + }); + } + + /** + * Removes the revision number from a file URL. + * + * @param url URL to remove the revision number. + * @return URL without revision number. + * @description + * The revision is used to know if a file has changed. We remove it from the URL to prevent storing a file per revision. + */ + protected removeRevisionFromUrl(url: string): string { + const args = this.getPluginFileArgs(url); + if (!args) { + // Not a pluginfile, no revision will be found. + return url; + } + + return CorePluginFile.instance.removeRevisionFromUrl(url, args); + } + + /** + * Change the package status, setting it to the previous status. + * + * @param siteId Site ID. + * @param component Package's component. + * @param componentId An ID to use in conjunction with the component. + * @return Promise resolved when the status is changed. Resolve param: new status. + */ + setPackagePreviousStatus(siteId: string, component: string, componentId?: string | number): Promise { + componentId = this.fixComponentId(componentId); + this.logger.debug(`Set previous status for package ${component} ${componentId}`); + + return CoreSites.instance.getSite(siteId).then((site) => { + const packageId = this.getPackageId(component, componentId); + + // Get current stored data, we'll only update 'status' and 'updated' fields. + return site.getDb().getRecord(this.PACKAGES_TABLE, { id: packageId }).then((entry: CoreFilepoolPackageEntry) => { + const newData: CoreFilepoolPackageEntry = {}; + if (entry.status == CoreConstants.DOWNLOADING) { + // Going back from downloading to previous status, restore previous download time. + newData.downloadTime = entry.previousDownloadTime; + } + newData.status = entry.previous || CoreConstants.NOT_DOWNLOADED; + newData.updated = Date.now(); + this.logger.debug(`Set previous status '${entry.status}' for package ${component} ${componentId}`); + + return site.getDb().updateRecords(this.PACKAGES_TABLE, newData, { id: packageId }).then(() => { + // Success updating, trigger event. + this.triggerPackageStatusChanged(site.id, newData.status, component, componentId); + + return newData.status; + }); + }); + }); + } + + /** + * Check if a file should be downloaded based on its size. + * + * @param size File size. + * @return Whether file should be downloaded. + */ + shouldDownload(size: number): boolean { + return size <= this.DOWNLOAD_THRESHOLD || (CoreApp.instance.isWifi() && size <= this.WIFI_DOWNLOAD_THRESHOLD); + } + + /** + * Convenience function to check if a file should be downloaded before opening it. + * + * @param url File online URL. + * @param size File size. + * @return Promise resolved if should download before open, rejected otherwise. + * @description + * Convenience function to check if a file should be downloaded before opening it. + * + * The default behaviour in the app is to download first and then open the local file in the following cases: + * - The file is small (less than DOWNLOAD_THRESHOLD). + * - The file cannot be streamed. + * If the file is big and can be streamed, the promise returned by this function will be rejected. + */ + shouldDownloadBeforeOpen(url: string, size: number): Promise { + if (size >= 0 && size <= this.DOWNLOAD_THRESHOLD) { + // The file is small, download it. + return Promise.resolve(); + } + + if (CoreApp.instance.isDesktop()) { + // In desktop always download first. + return Promise.resolve(); + } + + return CoreUtils.instance.getMimeTypeFromUrl(url).then((mimetype) => { + // If the file is streaming (audio or video) we reject. + if (mimetype.indexOf('video') != -1 || mimetype.indexOf('audio') != -1) { + return Promise.reject(null); + } + }); + } + + /** + * Store package status. + * + * @param siteId Site ID. + * @param status New package status. + * @param component Package's component. + * @param componentId An ID to use in conjunction with the component. + * @param extra Extra data to store for the package. If you want to store more than 1 value, use JSON.stringify. + * @return Promise resolved when status is stored. + */ + storePackageStatus(siteId: string, status: string, component: string, componentId?: string | number, extra?: string) + : Promise { + this.logger.debug(`Set status '${status}' for package ${component} ${componentId}`); + componentId = this.fixComponentId(componentId); + + return CoreSites.instance.getSite(siteId).then((site) => { + const packageId = this.getPackageId(component, componentId); + let downloadTime; + let previousDownloadTime; + + if (status == CoreConstants.DOWNLOADING) { + // Set download time if package is now downloading. + downloadTime = CoreTimeUtils.instance.timestamp(); + } + + // Search current status to set it as previous status. + return site.getDb().getRecord(this.PACKAGES_TABLE, { id: packageId }).then((entry: CoreFilepoolPackageEntry) => { + if (typeof extra == 'undefined' || extra === null) { + extra = entry.extra; + } + if (typeof downloadTime == 'undefined') { + // Keep previous download time. + downloadTime = entry.downloadTime; + previousDownloadTime = entry.previousDownloadTime; + } else { + // The downloadTime will be updated, store current time as previous. + previousDownloadTime = entry.downloadTime; + } + + return entry.status; + }).catch(() => { + // No previous status. + }).then((previousStatus: string) => { + const packageEntry: CoreFilepoolPackageEntry = { + id: packageId, + component, + componentId, + status, + previous: previousStatus, + updated: Date.now(), + downloadTime, + previousDownloadTime, + extra, + }; + let promise; + + if (previousStatus === status) { + // The package already has this status, no need to change it. + promise = Promise.resolve(); + } else { + promise = site.getDb().insertRecord(this.PACKAGES_TABLE, packageEntry); + } + + return promise.then(() => { + // Success inserting, trigger event. + this.triggerPackageStatusChanged(siteId, status, component, componentId); + }); + }); + }); + } + + /** + * Search for files in a CSS code and try to download them. Once downloaded, replace their URLs + * and store the result in the CSS file. + * + * @param siteId Site ID. + * @param fileUrl CSS file URL. + * @param cssCode CSS code. + * @param component The component to link the file to. + * @param componentId An ID to use in conjunction with the component. + * @param revision Revision to use in all files. If not defined, it will be calculated using the URL of each file. + * @return Promise resolved with the CSS code. + */ + treatCSSCode(siteId: string, fileUrl: string, cssCode: string, component?: string, componentId?: string | number, + revision?: number): Promise { + + const urls = CoreDomUtils.instance.extractUrlsFromCSS(cssCode); + const promises = []; + let filePath; + let updated = false; + + // Get the path of the CSS file. + promises.push(this.getFilePathByUrl(siteId, fileUrl).then((path) => { + filePath = path; + })); + + urls.forEach((url) => { + // Download the file only if it's an online URL. + if (!CoreUrlUtils.instance.isLocalFileUrl(url)) { + promises.push(this.downloadUrl(siteId, url, false, component, componentId, 0, undefined, undefined, undefined, + revision).then((fileUrl) => { + + if (fileUrl != url) { + cssCode = cssCode.replace(new RegExp(CoreTextUtils.instance.escapeForRegex(url), 'g'), fileUrl); + updated = true; + } + }).catch((error) => { + // It shouldn't happen. Ignore errors. + this.logger.warn('Error treating file ', url, error); + })); + } + }); + + return Promise.all(promises).then(() => { + // All files downloaded. Store the result if it has changed. + if (updated) { + return CoreFile.instance.writeFile(filePath, cssCode); + } + }).then(() => { + return cssCode; + }); + } + + /** + * Resolves or rejects a queue deferred and removes it from the list. + * + * @param siteId The site ID. + * @param fileId The file ID. + * @param resolve True if promise should be resolved, false if it should be rejected. + * @param error String identifier for error message, if rejected. + */ + protected treatQueueDeferred(siteId: string, fileId: string, resolve: boolean, error?: string): void { + if (this.queueDeferreds[siteId] && this.queueDeferreds[siteId][fileId]) { + if (resolve) { + this.queueDeferreds[siteId][fileId].resolve(); + } else { + this.queueDeferreds[siteId][fileId].reject(error); + } + delete this.queueDeferreds[siteId][fileId]; + } + } + + /** + * Trigger mmCoreEventPackageStatusChanged with the right data. + * + * @param siteId Site ID. + * @param status New package status. + * @param component Package's component. + * @param componentId An ID to use in conjunction with the component. + */ + protected triggerPackageStatusChanged(siteId: string, status: string, component: string, componentId?: string | number): void { + const data = { + component, + componentId: this.fixComponentId(componentId), + status, + }; + CoreEvents.instance.trigger(CoreEventsProvider.PACKAGE_STATUS_CHANGED, data, siteId); + } + + /** + * Update the download time of a package. This doesn't modify the previous download time. + * This function should be used if a package generates some new data during a download. Calling this function + * right after generating the data in the download will prevent detecting this data as an update. + * + * @param siteId Site ID. + * @param component Package's component. + * @param componentId An ID to use in conjunction with the component. + * @return Promise resolved when status is stored. + */ + updatePackageDownloadTime(siteId: string, component: string, componentId?: string | number): Promise { + componentId = this.fixComponentId(componentId); + + return CoreSites.instance.getSite(siteId).then((site) => { + const packageId = this.getPackageId(component, componentId); + + return site.getDb().updateRecords(this.PACKAGES_TABLE, { downloadTime: CoreTimeUtils.instance.timestamp() }, { id: packageId }); + }); + } +} + +export class CoreFilepool extends makeSingleton(CoreFilepoolProvider) {} + +/** + * Entry from filepool. + */ +export type CoreFilepoolFileEntry = { + /** + * The fileId to identify the file. + */ + fileId?: string; + + /** + * File's URL. + */ + url?: string; + + /** + * File's revision. + */ + revision?: number; + + /** + * File's timemodified. + */ + timemodified?: number; + + /** + * 1 if file is stale (needs to be updated), 0 otherwise. + */ + stale?: number; + + /** + * Timestamp when this file was downloaded. + */ + downloadTime?: number; + + /** + * 1 if it's a external file (from an external repository), 0 otherwise. + */ + isexternalfile?: number; + + /** + * Type of the repository this file belongs to. + */ + repositorytype?: string; + + /** + * File's path. + */ + path?: string; + + /** + * File's extension. + */ + extension?: string; +}; + +/** + * Entry from the file's queue. + */ +export type CoreFilepoolQueueEntry = { + /** + * The site the file belongs to. + */ + siteId?: string; + + /** + * The fileId to identify the file. + */ + fileId?: string; + + /** + * Timestamp when the file was added to the queue. + */ + added?: number; + + /** + * The priority of the file. + */ + priority?: number; + + /** + * File's URL. + */ + url?: string; + + /** + * File's revision. + */ + revision?: number; + + /** + * File's timemodified. + */ + timemodified?: number; + + /** + * 1 if it's a external file (from an external repository), 0 otherwise. + */ + isexternalfile?: number; + + /** + * Type of the repository this file belongs to. + */ + repositorytype?: string; + + /** + * File's path. + */ + path?: string; + + /** + * File links (to link the file to components and componentIds). + */ + links?: CoreFilepoolComponentLink[]; +}; + +/** + * Entry from packages table. + */ +export type CoreFilepoolPackageEntry = { + /** + * Package id. + */ + id?: string; + + /** + * The component to link the files to. + */ + component?: string; + + /** + * An ID to use in conjunction with the component. + */ + componentId?: string | number; + + /** + * Package status. + */ + status?: string; + + /** + * Package previous status. + */ + previous?: string; + + /** + * Timestamp when this package was updated. + */ + updated?: number; + + /** + * Timestamp when this package was downloaded. + */ + downloadTime?: number; + + /** + * Previous download time. + */ + previousDownloadTime?: number; + + /** + * Extra data stored by the package. + */ + extra?: string; +}; + +/** + * A component link. + */ +export type CoreFilepoolComponentLink = { + /** + * Link's component. + */ + component: string; + + /** + * Link's componentId. + */ + componentId?: string | number; +}; + +/** + * File actions. + */ +export const enum CoreFilepoolFileActions { + DOWNLOAD = 'download', + DOWNLOADING = 'downloading', + DELETED = 'deleted', + OUTDATED = 'outdated', +} + +/** + * Data sent to file events. + */ +export type CoreFilepoolFileEventData = { + /** + * The file ID. + */ + fileId: string; + + /** + * The file ID. + */ + action: CoreFilepoolFileActions; + + /** + * Whether the action was a success. Only for DOWNLOAD action. + */ + success?: boolean; +}; + +/** + * Data sent to component file events. + */ +export type CoreFilepoolComponentFileEventData = CoreFilepoolFileEventData & { + /** + * The component. + */ + component: string; + + /** + * The component ID. + */ + componentId: string | number; +}; diff --git a/src/app/services/geolocation.ts b/src/app/services/geolocation.ts new file mode 100644 index 000000000..cded645ae --- /dev/null +++ b/src/app/services/geolocation.ts @@ -0,0 +1,144 @@ +// (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 { Coordinates } from '@ionic-native/geolocation'; + +import { CoreApp } from '@services/app'; +import { CoreError } from '@classes/error'; +import { Geolocation, Diagnostic, makeSingleton } from '@singletons/core.singletons'; + +@Injectable() +export class CoreGeolocationProvider { + + /** + * Get current user coordinates. + * + * @throws {CoreGeolocationError} + */ + async getCoordinates(): Promise { + try { + await this.authorizeLocation(); + await this.enableLocation(); + + const result = await Geolocation.instance.getCurrentPosition({ + enableHighAccuracy: true, + timeout: 30000, + }); + + return result.coords; + } catch (error) { + if (this.isCordovaPermissionDeniedError(error)) { + throw new CoreGeolocationError(CoreGeolocationErrorReason.PermissionDenied); + } + + throw error; + } + } + + /** + * Make sure that using device location has been authorized and ask for permission if it hasn't. + * + * @throws {CoreGeolocationError} + */ + async authorizeLocation(): Promise { + await this.doAuthorizeLocation(); + } + + /** + * Make sure that location is enabled and open settings to enable it if necessary. + * + * @throws {CoreGeolocationError} + */ + async enableLocation(): Promise { + let locationEnabled = await Diagnostic.instance.isLocationEnabled(); + + if (locationEnabled) { + // Location is enabled. + return; + } + + if (!CoreApp.instance.isIOS()) { + await Diagnostic.instance.switchToLocationSettings(); + await CoreApp.instance.waitForResume(30000); + + locationEnabled = await Diagnostic.instance.isLocationEnabled(); + } + + if (!locationEnabled) { + throw new CoreGeolocationError(CoreGeolocationErrorReason.LocationNotEnabled); + } + } + + /** + * Recursive implementation of authorizeLocation method, protected to avoid exposing the failOnDeniedOnce parameter. + * + * @param failOnDeniedOnce Throw an exception if the permission has been denied once. + * @throws {CoreGeolocationError} + */ + protected async doAuthorizeLocation(failOnDeniedOnce: boolean = false): Promise { + const authorizationStatus = await Diagnostic.instance.getLocationAuthorizationStatus(); + + switch (authorizationStatus) { + // This constant is hard-coded because it is not declared in @ionic-native/diagnostic v4. + case 'DENIED_ONCE': + if (failOnDeniedOnce) { + throw new CoreGeolocationError(CoreGeolocationErrorReason.PermissionDenied); + } + // Fall through. + case Diagnostic.instance.permissionStatus.NOT_REQUESTED: + await Diagnostic.instance.requestLocationAuthorization(); + await CoreApp.instance.waitForResume(500); + await this.doAuthorizeLocation(true); + + return; + case Diagnostic.instance.permissionStatus.GRANTED: + case Diagnostic.instance.permissionStatus.GRANTED_WHEN_IN_USE: + // Location is authorized. + return; + case Diagnostic.instance.permissionStatus.DENIED: + default: + throw new CoreGeolocationError(CoreGeolocationErrorReason.PermissionDenied); + } + } + + /** + * Check whether an error was caused by a PERMISSION_DENIED from the cordova plugin. + * + * @param error Error. + */ + protected isCordovaPermissionDeniedError(error?: any): boolean { + return error && 'code' in error && 'PERMISSION_DENIED' in error && error.code === error.PERMISSION_DENIED; + } + +} + +export class CoreGeolocation extends makeSingleton(CoreGeolocationProvider) {} + +export enum CoreGeolocationErrorReason { + PermissionDenied = 'permission-denied', + LocationNotEnabled = 'location-not-enabled', +} + +export class CoreGeolocationError extends CoreError { + + readonly reason: CoreGeolocationErrorReason; + + constructor(reason: CoreGeolocationErrorReason) { + super(`GeolocationError: ${reason}`); + + this.reason = reason; + } + +} diff --git a/src/app/services/groups.ts b/src/app/services/groups.ts new file mode 100644 index 000000000..c1e4090bf --- /dev/null +++ b/src/app/services/groups.ts @@ -0,0 +1,448 @@ +// (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 { CoreSites } from '@services/sites'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; +import { makeSingleton, Translate } from '@singletons/core.singletons'; + +/* + * Service to handle groups. +*/ +@Injectable() +export class CoreGroupsProvider { + // Group mode constants. + static NOGROUPS = 0; + static SEPARATEGROUPS = 1; + static VISIBLEGROUPS = 2; + protected ROOT_CACHE_KEY = 'mmGroups:'; + + constructor() { } + + /** + * Check if group mode of an activity is enabled. + * + * @param cmId Course module ID. + * @param siteId Site ID. If not defined, current site. + * @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down). + * @return Promise resolved with true if the activity has groups, resolved with false otherwise. + */ + activityHasGroups(cmId: number, siteId?: string, ignoreCache?: boolean): Promise { + return this.getActivityGroupMode(cmId, siteId, ignoreCache).then((groupmode) => { + return groupmode === CoreGroupsProvider.SEPARATEGROUPS || groupmode === CoreGroupsProvider.VISIBLEGROUPS; + }).catch(() => { + return false; + }); + } + + /** + * Get the groups allowed in an activity. + * + * @param cmId Course module ID. + * @param userId User ID. If not defined, use current user. + * @param siteId Site ID. If not defined, current site. + * @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down). + * @return Promise resolved when the groups are retrieved. + */ + getActivityAllowedGroups(cmId: number, userId?: number, siteId?: string, ignoreCache?: boolean): Promise { + return CoreSites.instance.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + const params = { + cmid: cmId, + userid: userId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getActivityAllowedGroupsCacheKey(cmId, userId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + + if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('core_group_get_activity_allowed_groups', params, preSets).then((response) => { + if (!response || !response.groups) { + return Promise.reject(null); + } + + return response; + }); + }); + } + + /** + * Get cache key for group mode WS calls. + * + * @param cmId Course module ID. + * @param userId User ID. + * @return Cache key. + */ + protected getActivityAllowedGroupsCacheKey(cmId: number, userId: number): string { + return this.ROOT_CACHE_KEY + 'allowedgroups:' + cmId + ':' + userId; + } + + /** + * Get the groups allowed in an activity if they are allowed. + * + * @param cmId Course module ID. + * @param userId User ID. If not defined, use current user. + * @param siteId Site ID. If not defined, current site. + * @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down). + * @return Promise resolved when the groups are retrieved. If not allowed, empty array will be returned. + */ + getActivityAllowedGroupsIfEnabled(cmId: number, userId?: number, siteId?: string, ignoreCache?: boolean): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + // Get real groupmode, in case it's forced by the course. + return this.activityHasGroups(cmId, siteId, ignoreCache).then((hasGroups) => { + if (hasGroups) { + // Get the groups available for the user. + return this.getActivityAllowedGroups(cmId, userId, siteId, ignoreCache); + } + + return { + groups: [] + }; + }); + } + + /** + * Helper function to get activity group info (group mode and list of groups). + * + * @param cmId Course module ID. + * @param addAllParts Deprecated. + * @param userId User ID. If not defined, use current user. + * @param siteId Site ID. If not defined, current site. + * @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down). + * @return Promise resolved with the group info. + */ + getActivityGroupInfo(cmId: number, addAllParts?: boolean, userId?: number, siteId?: string, ignoreCache?: boolean) + : Promise { + + const groupInfo: CoreGroupInfo = { + groups: [] + }; + + return this.getActivityGroupMode(cmId, siteId, ignoreCache).then((groupMode) => { + groupInfo.separateGroups = groupMode === CoreGroupsProvider.SEPARATEGROUPS; + groupInfo.visibleGroups = groupMode === CoreGroupsProvider.VISIBLEGROUPS; + + if (groupInfo.separateGroups || groupInfo.visibleGroups) { + return this.getActivityAllowedGroups(cmId, userId, siteId, ignoreCache); + } + + return { + groups: [], + canaccessallgroups: false + }; + }).then((result) => { + if (result.groups.length <= 0) { + groupInfo.separateGroups = false; + groupInfo.visibleGroups = false; + groupInfo.defaultGroupId = 0; + } else { + // The "canaccessallgroups" field was added in 3.4. Add all participants for visible groups in previous versions. + if (result.canaccessallgroups || (typeof result.canaccessallgroups == 'undefined' && groupInfo.visibleGroups)) { + groupInfo.groups.push({ id: 0, name: Translate.instance.instant('core.allparticipants') }); + groupInfo.defaultGroupId = 0; + } else { + groupInfo.defaultGroupId = result.groups[0].id; + } + + groupInfo.groups = groupInfo.groups.concat(result.groups); + } + + return groupInfo; + }); + } + + /** + * Get the group mode of an activity. + * + * @param cmId Course module ID. + * @param siteId Site ID. If not defined, current site. + * @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down). + * @return Promise resolved when the group mode is retrieved. + */ + getActivityGroupMode(cmId: number, siteId?: string, ignoreCache?: boolean): Promise { + return CoreSites.instance.getSite(siteId).then((site) => { + const params = { + cmid: cmId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getActivityGroupModeCacheKey(cmId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + + if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('core_group_get_activity_groupmode', params, preSets).then((response) => { + if (!response || typeof response.groupmode == 'undefined') { + return Promise.reject(null); + } + + return response.groupmode; + }); + }); + } + + /** + * Get cache key for group mode WS calls. + * + * @param cmId Course module ID. + * @return Cache key. + */ + protected getActivityGroupModeCacheKey(cmId: number): string { + return this.ROOT_CACHE_KEY + 'groupmode:' + cmId; + } + + /** + * Get user groups in all the user enrolled courses. + * + * @param siteId Site to get the groups from. If not defined, use current site. + * @return Promise resolved when the groups are retrieved. + */ + getAllUserGroups(siteId?: string): Promise { + return CoreSites.instance.getSite(siteId).then((site) => { + siteId = siteId || site.getId(); + + if (site.isVersionGreaterEqualThan('3.6')) { + return this.getUserGroupsInCourse(0, siteId); + } + + // @todo Get courses. + }); + } + + /** + * Get user groups in all the supplied courses. + * + * @param courses List of courses or course ids to get the groups from. + * @param siteId Site to get the groups from. If not defined, use current site. + * @param userId ID of the user. If not defined, use the userId related to siteId. + * @return Promise resolved when the groups are retrieved. + */ + getUserGroups(courses: any[], siteId?: string, userId?: number): Promise { + // Get all courses one by one. + const promises = courses.map((course) => { + const courseId = typeof course == 'object' ? course.id : course; + + return this.getUserGroupsInCourse(courseId, siteId, userId); + }); + + return Promise.all(promises).then((courseGroups) => { + return [].concat(...courseGroups); + }); + } + + /** + * Get user groups in a course. + * + * @param courseId ID of the course. 0 to get all enrolled courses groups (Moodle version > 3.6). + * @param siteId Site to get the groups from. If not defined, use current site. + * @param userId ID of the user. If not defined, use ID related to siteid. + * @return Promise resolved when the groups are retrieved. + */ + getUserGroupsInCourse(courseId: number, siteId?: string, userId?: number): Promise { + return CoreSites.instance.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + const data = { + userid: userId, + courseid: courseId, + }; + const preSets = { + cacheKey: this.getUserGroupsInCourseCacheKey(courseId, userId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + + return site.read('core_group_get_course_user_groups', data, preSets).then((response) => { + if (response && response.groups) { + return response.groups; + } else { + return Promise.reject(null); + } + }); + }); + } + + /** + * Get prefix cache key for user groups in course WS calls. + * + * @return Prefix Cache key. + */ + protected getUserGroupsInCoursePrefixCacheKey(): string { + return this.ROOT_CACHE_KEY + 'courseGroups:'; + } + + /** + * Get cache key for user groups in course WS calls. + * + * @param courseId Course ID. + * @param userId User ID. + * @return Cache key. + */ + protected getUserGroupsInCourseCacheKey(courseId: number, userId: number): string { + return this.getUserGroupsInCoursePrefixCacheKey() + courseId + ':' + userId; + } + + /** + * Invalidates activity allowed groups. + * + * @param cmId Course module ID. + * @param userId User ID. If not defined, use current user. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + invalidateActivityAllowedGroups(cmId: number, userId?: number, siteId?: string): Promise { + return CoreSites.instance.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + return site.invalidateWsCacheForKey(this.getActivityAllowedGroupsCacheKey(cmId, userId)); + }); + } + + /** + * Invalidates activity group mode. + * + * @param cmId Course module ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + invalidateActivityGroupMode(cmId: number, siteId?: string): Promise { + return CoreSites.instance.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getActivityGroupModeCacheKey(cmId)); + }); + } + + /** + * Invalidates all activity group info: mode and allowed groups. + * + * @param cmId Course module ID. + * @param userId User ID. If not defined, use current user. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + invalidateActivityGroupInfo(cmId: number, userId?: number, siteId?: string): Promise { + const promises = []; + promises.push(this.invalidateActivityAllowedGroups(cmId, userId, siteId)); + promises.push(this.invalidateActivityGroupMode(cmId, siteId)); + + return Promise.all(promises); + } + + /** + * Invalidates user groups in all user enrolled courses. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + invalidateAllUserGroups(siteId?: string): Promise { + return CoreSites.instance.getSite(siteId).then((site) => { + if (site.isVersionGreaterEqualThan('3.6')) { + return this.invalidateUserGroupsInCourse(0, siteId); + } + + return site.invalidateWsCacheForKeyStartingWith(this.getUserGroupsInCoursePrefixCacheKey()); + }); + } + + /** + * Invalidates user groups in courses. + * + * @param courses List of courses or course ids. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined, use current user. + * @return Promise resolved when the data is invalidated. + */ + invalidateUserGroups(courses: any[], siteId?: string, userId?: number): Promise { + return CoreSites.instance.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + const promises = courses.map((course) => { + const courseId = typeof course == 'object' ? course.id : course; + + return this.invalidateUserGroupsInCourse(courseId, site.id, userId); + }); + + return Promise.all(promises); + }); + } + + /** + * Invalidates user groups in course. + * + * @param courseId ID of the course. 0 to get all enrolled courses groups (Moodle version > 3.6). + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined, use current user. + * @return Promise resolved when the data is invalidated. + */ + invalidateUserGroupsInCourse(courseId: number, siteId?: string, userId?: number): Promise { + return CoreSites.instance.getSite(siteId).then((site) => { + userId = userId || site.getUserId(); + + return site.invalidateWsCacheForKey(this.getUserGroupsInCourseCacheKey(courseId, userId)); + }); + } + + /** + * Validate a group ID. If the group is not visible by the user, it will return the first group ID. + * + * @param groupId Group ID to validate. + * @param groupInfo Group info. + * @return Group ID to use. + */ + validateGroupId(groupId: number, groupInfo: CoreGroupInfo): number { + if (groupId > 0 && groupInfo && groupInfo.groups && groupInfo.groups.length > 0) { + // Check if the group is in the list of groups. + if (groupInfo.groups.some((group) => groupId == group.id)) { + return groupId; + } + } + + return groupInfo.defaultGroupId; + } +} + +export class CoreGroups extends makeSingleton(CoreGroupsProvider) {} + +/** + * Group info for an activity. + */ +export type CoreGroupInfo = { + /** + * List of groups. + */ + groups?: any[]; + + /** + * Whether it's separate groups. + */ + separateGroups?: boolean; + + /** + * Whether it's visible groups. + */ + visibleGroups?: boolean; + + /** + * The group ID to use by default. If all participants is visible, 0 will be used. First group ID otherwise. + */ + defaultGroupId?: number; +}; diff --git a/src/app/services/lang.ts b/src/app/services/lang.ts new file mode 100644 index 000000000..c49e3c0a6 --- /dev/null +++ b/src/app/services/lang.ts @@ -0,0 +1,451 @@ +// (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 CoreConfigConstants from '@app/config.json'; +import { CoreApp, CoreAppProvider } from '@services/app'; +import { CoreConfig } from '@services/config'; +import { makeSingleton, Translate, Platform, Globalization } from '@singletons/core.singletons'; + +import * as moment from 'moment'; + +/* + * Service to handle language features, like changing the current language. +*/ +@Injectable() +export class CoreLangProvider { + protected fallbackLanguage = 'en'; // Always use English as fallback language since it contains all strings. + protected defaultLanguage = CoreConfigConstants.default_lang || 'en'; // Lang to use if device lang not valid or is forced. + protected currentLanguage: string; // Save current language in a variable to speed up the get function. + protected customStrings = {}; // Strings defined using the admin tool. + protected customStringsRaw: string; + protected sitePluginsStrings = {}; // Strings defined by site plugins. + + constructor() { + // Set fallback language and language to use until the app determines the right language to use. + Translate.instance.setDefaultLang(this.fallbackLanguage); + Translate.instance.use(this.defaultLanguage); + + Platform.instance.ready().then(() => { + if (CoreAppProvider.isAutomated()) { + // Force current language to English when Behat is running. + this.changeCurrentLanguage('en'); + + return; + } + + this.getCurrentLanguage().then((language) => { + this.changeCurrentLanguage(language); + }); + }); + + Translate.instance.onLangChange.subscribe((event: any) => { + // @todo: Set platform lang and dir. + }); + } + + /** + * Add a set of site plugins strings for a certain language. + * + * @param lang The language where to add the strings. + * @param strings Object with the strings to add. + * @param prefix A prefix to add to all keys. + */ + addSitePluginsStrings(lang: string, strings: any, prefix?: string): void { + lang = lang.replace(/_/g, '-'); // Use the app format instead of Moodle format. + + // Initialize structure if it doesn't exist. + if (!this.sitePluginsStrings[lang]) { + this.sitePluginsStrings[lang] = {}; + } + + for (const key in strings) { + const prefixedKey = prefix + key; + let value = strings[key]; + + if (this.customStrings[lang] && this.customStrings[lang][prefixedKey]) { + // This string is overridden by a custom string, ignore it. + continue; + } + + // Replace the way to access subproperties. + value = value.replace(/\$a->/gm, '$a.'); + // Add another curly bracket to string params ({$a} -> {{$a}}). + value = value.replace(/{([^ ]+)}/gm, '{{$1}}'); + // Make sure we didn't add to many brackets in some case. + value = value.replace(/{{{([^ ]+)}}}/gm, '{{$1}}'); + + // Load the string. + this.loadString(this.sitePluginsStrings, lang, prefixedKey, value); + } + } + + /** + * Capitalize a string (make the first letter uppercase). + * We cannot use a function from text utils because it would cause a circular dependency. + * + * @param value String to capitalize. + * @return Capitalized string. + */ + protected capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); + } + + /** + * Change current language. + * + * @param language New language to use. + * @return Promise resolved when the change is finished. + */ + changeCurrentLanguage(language: string): Promise { + const promises = []; + + // Change the language, resolving the promise when we receive the first value. + promises.push(new Promise((resolve, reject): void => { + const subscription = Translate.instance.use(language).subscribe((data) => { + // It's a language override, load the original one first. + const fallbackLang = Translate.instance.instant('core.parentlanguage'); + + if (fallbackLang != '' && fallbackLang != 'core.parentlanguage' && fallbackLang != language) { + const fallbackSubs = Translate.instance.use(fallbackLang).subscribe((fallbackData) => { + data = Object.assign(fallbackData, data); + resolve(data); + + // Data received, unsubscribe. Use a timeout because we can receive a value immediately. + setTimeout(() => { + fallbackSubs.unsubscribe(); + }); + }, (error) => { + // Resolve with the original language. + resolve(data); + + // Error received, unsubscribe. Use a timeout because we can receive a value immediately. + setTimeout(() => { + fallbackSubs.unsubscribe(); + }); + }); + } else { + resolve(data); + } + + // Data received, unsubscribe. Use a timeout because we can receive a value immediately. + setTimeout(() => { + subscription.unsubscribe(); + }); + }, (error) => { + reject(error); + + // Error received, unsubscribe. Use a timeout because we can receive a value immediately. + setTimeout(() => { + subscription.unsubscribe(); + }); + }); + })); + + // Change the config. + promises.push(CoreConfig.instance.set('current_language', language)); + + // Use british english when parent english is loaded. + moment.locale(language == 'en' ? 'en-gb' : language); + + // @todo: Set data for ion-datetime. + + this.currentLanguage = language; + + return Promise.all(promises).finally(() => { + // Load the custom and site plugins strings for the language. + if (this.loadLangStrings(this.customStrings, language) || this.loadLangStrings(this.sitePluginsStrings, language)) { + // Some lang strings have changed, emit an event to update the pipes. + Translate.instance.onLangChange.emit({lang: language, translations: Translate.instance.translations[language]}); + } + }); + } + + /** + * Clear current custom strings. + */ + clearCustomStrings(): void { + this.unloadStrings(this.customStrings); + this.customStrings = {}; + this.customStringsRaw = ''; + } + + /** + * Clear current site plugins strings. + */ + clearSitePluginsStrings(): void { + this.unloadStrings(this.sitePluginsStrings); + this.sitePluginsStrings = {}; + } + + /** + * Get all current custom strings. + * + * @return Custom strings. + */ + getAllCustomStrings(): any { + return this.customStrings; + } + + /** + * Get all current site plugins strings. + * + * @return Site plugins strings. + */ + getAllSitePluginsStrings(): any { + return this.sitePluginsStrings; + } + + /** + * Get current language. + * + * @return Promise resolved with the current language. + */ + getCurrentLanguage(): Promise { + + if (typeof this.currentLanguage != 'undefined') { + return Promise.resolve(this.currentLanguage); + } + + // Get current language from config (user might have changed it). + return CoreConfig.instance.get('current_language').then((language) => { + return language; + }).catch(() => { + // User hasn't defined a language. If default language is forced, use it. + if (CoreConfigConstants.default_lang && CoreConfigConstants.forcedefaultlanguage) { + return CoreConfigConstants.default_lang; + } + + try { + // No forced language, try to get current language from cordova globalization. + return Globalization.instance.getPreferredLanguage().then((result) => { + let language = result.value.toLowerCase(); + if (language.indexOf('-') > -1) { + // Language code defined by locale has a dash, like en-US or es-ES. Check if it's supported. + if (CoreConfigConstants.languages && typeof CoreConfigConstants.languages[language] == 'undefined') { + // Code is NOT supported. Fallback to language without dash. E.g. 'en-US' would fallback to 'en'. + language = language.substr(0, language.indexOf('-')); + + } + } + + if (typeof CoreConfigConstants.languages[language] == 'undefined') { + // Language not supported, use default language. + return this.defaultLanguage; + } + + return language; + }).catch(() => { + // Error getting locale. Use default language. + return this.defaultLanguage; + }); + } catch (err) { + // Error getting locale. Use default language. + return Promise.resolve(this.defaultLanguage); + } + }).then((language) => { + this.currentLanguage = language; // Save it for later. + + return language; + }); + } + + /** + * Get the default language. + * + * @return Default language. + */ + getDefaultLanguage(): string { + return this.defaultLanguage; + } + + /** + * Get the fallback language. + * + * @return Fallback language. + */ + getFallbackLanguage(): string { + return this.fallbackLanguage; + } + + /** + * Get the full list of translations for a certain language. + * + * @param lang The language to check. + * @return Promise resolved when done. + */ + getTranslationTable(lang: string): Promise { + // Create a promise to convert the observable into a promise. + return new Promise((resolve, reject): void => { + const observer = Translate.instance.getTranslation(lang).subscribe((table) => { + resolve(table); + observer.unsubscribe(); + }, (err) => { + reject(err); + observer.unsubscribe(); + }); + }); + } + + /** + * Load certain custom strings. + * + * @param strings Custom strings to load (tool_mobile_customlangstrings). + */ + loadCustomStrings(strings: string): void { + if (strings == this.customStringsRaw) { + // Strings haven't changed, stop. + return; + } + + // Reset current values. + this.clearCustomStrings(); + + if (!strings) { + return; + } + + let currentLangChanged = false; + + const list: string[] = strings.split(/(?:\r\n|\r|\n)/); + list.forEach((entry: string) => { + const values: string[] = entry.split('|'); + let lang: string; + + if (values.length < 3) { + // Not enough data, ignore the entry. + return; + } + + lang = values[2].replace(/_/g, '-'); // Use the app format instead of Moodle format. + + if (lang == this.currentLanguage) { + currentLangChanged = true; + } + + if (!this.customStrings[lang]) { + this.customStrings[lang] = {}; + } + + // Convert old keys format to new one. + const key = values[0].replace(/^mm\.core/, 'core').replace(/^mm\./, 'core.').replace(/^mma\./, 'addon.') + .replace(/^core\.sidemenu/, 'core.mainmenu').replace(/^addon\.grades/, 'core.grades') + .replace(/^addon\.participants/, 'core.user'); + + this.loadString(this.customStrings, lang, key, values[1]); + }); + + this.customStringsRaw = strings; + + if (currentLangChanged) { + // Some lang strings have changed, emit an event to update the pipes. + Translate.instance.onLangChange.emit({ + lang: this.currentLanguage, + translations: Translate.instance.translations[this.currentLanguage] + }); + } + } + + /** + * Load custom strings for a certain language that weren't loaded because the language wasn't active. + * + * @param langObject The object with the strings to load. + * @param lang Language to load. + * @return Whether the translation table was modified. + */ + loadLangStrings(langObject: any, lang: string): boolean { + let langApplied = false; + + if (langObject[lang]) { + for (const key in langObject[lang]) { + const entry = langObject[lang][key]; + + if (!entry.applied) { + // Store the original value of the string. + entry.original = Translate.instance.translations[lang][key]; + + // Store the string in the translations table. + Translate.instance.translations[lang][key] = entry.value; + + entry.applied = true; + langApplied = true; + } + } + } + + return langApplied; + } + + /** + * Load a string in a certain lang object and in the translate table if the lang is loaded. + * + * @param langObject The object where to store the lang. + * @param lang Language code. + * @param key String key. + * @param value String value. + */ + loadString(langObject: any, lang: string, key: string, value: string): void { + lang = lang.replace(/_/g, '-'); // Use the app format instead of Moodle format. + + if (Translate.instance.translations[lang]) { + // The language is loaded. + // Store the original value of the string. + langObject[lang][key] = { + original: Translate.instance.translations[lang][key], + value, + applied: true, + }; + + // Store the string in the translations table. + Translate.instance.translations[lang][key] = value; + } else { + // The language isn't loaded. + // Save it in our object but not in the translations table, it will be loaded when the lang is loaded. + langObject[lang][key] = { + value, + applied: false, + }; + } + } + + /** + * Unload custom or site plugin strings, removing them from the translations table. + * + * @param strings Strings to unload. + */ + protected unloadStrings(strings: any): void { + // Iterate over all languages and strings. + for (const lang in strings) { + if (!Translate.instance.translations[lang]) { + // Language isn't loaded, nothing to unload. + continue; + } + + const langStrings = strings[lang]; + for (const key in langStrings) { + const entry = langStrings[key]; + if (entry.original) { + // The string had a value, restore it. + Translate.instance.translations[lang][key] = entry.original; + } else { + // The string didn't exist, delete it. + delete Translate.instance.translations[lang][key]; + } + } + } + } +} + +export class CoreLang extends makeSingleton(CoreLangProvider) {} diff --git a/src/app/services/local-notifications.ts b/src/app/services/local-notifications.ts new file mode 100644 index 000000000..18a11f940 --- /dev/null +++ b/src/app/services/local-notifications.ts @@ -0,0 +1,697 @@ +// (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 { Subject, Subscription } from 'rxjs'; +import { ILocalNotification } from '@ionic-native/local-notifications'; + +import { CoreApp, CoreAppSchema } from '@services/app'; +import { CoreConfig } from '@services/config'; +import { CoreEvents, CoreEventsProvider } from '@services/events'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { SQLiteDB } from '@classes/sqlitedb'; +import { CoreQueueRunner } from '@classes/queue-runner'; +import { CoreConstants } from '@core/constants'; +import CoreConfigConstants from '@app/config.json'; +import { makeSingleton, NgZone, Platform, Translate, LocalNotifications, Push, Device } from '@singletons/core.singletons'; +import { CoreLogger } from '@singletons/logger'; + +/** + * Service to handle local notifications. + */ +@Injectable() +export class CoreLocalNotificationsProvider { + // Variables for the database. + protected SITES_TABLE = 'notification_sites'; // Store to asigne unique codes to each site. + protected COMPONENTS_TABLE = 'notification_components'; // Store to asigne unique codes to each component. + protected TRIGGERED_TABLE = 'notifications_triggered'; // Store to prevent re-triggering notifications. + protected tablesSchema: CoreAppSchema = { + name: 'CoreLocalNotificationsProvider', + version: 1, + tables: [ + { + name: this.SITES_TABLE, + columns: [ + { + name: 'id', + type: 'TEXT', + primaryKey: true + }, + { + name: 'code', + type: 'INTEGER', + notNull: true + }, + ], + }, + { + name: this.COMPONENTS_TABLE, + columns: [ + { + name: 'id', + type: 'TEXT', + primaryKey: true + }, + { + name: 'code', + type: 'INTEGER', + notNull: true + }, + ], + }, + { + name: this.TRIGGERED_TABLE, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true + }, + { + name: 'at', + type: 'INTEGER', + notNull: true + }, + ], + }, + ], + }; + + protected logger: CoreLogger; + protected appDB: SQLiteDB; + protected dbReady: Promise; // Promise resolved when the app DB is initialized. + protected codes: { [s: string]: number } = {}; + protected codeRequestsQueue = {}; + protected observables = {}; + protected currentNotification = { + title: '', + texts: [], + ids: [], + timeouts: [] + }; + protected triggerSubscription: Subscription; + protected clickSubscription: Subscription; + protected clearSubscription: Subscription; + protected cancelSubscription: Subscription; + protected addSubscription: Subscription; + protected updateSubscription: Subscription; + protected queueRunner: CoreQueueRunner; // Queue to decrease the number of concurrent calls to the plugin (see MOBILE-3477). + + constructor() { + + this.logger = CoreLogger.getInstance('CoreLocalNotificationsProvider'); + this.queueRunner = new CoreQueueRunner(10); + this.appDB = CoreApp.instance.getDB(); + this.dbReady = CoreApp.instance.createTablesFromSchema(this.tablesSchema).catch(() => { + // Ignore errors. + }); + + Platform.instance.ready().then(() => { + // Listen to events. + this.triggerSubscription = LocalNotifications.instance.on('trigger').subscribe((notification: ILocalNotification) => { + this.trigger(notification); + + this.handleEvent('trigger', notification); + }); + + this.clickSubscription = LocalNotifications.instance.on('click').subscribe((notification: ILocalNotification) => { + this.handleEvent('click', notification); + }); + + this.clearSubscription = LocalNotifications.instance.on('clear').subscribe((notification: ILocalNotification) => { + this.handleEvent('clear', notification); + }); + + this.cancelSubscription = LocalNotifications.instance.on('cancel').subscribe((notification: ILocalNotification) => { + this.handleEvent('cancel', notification); + }); + + this.addSubscription = LocalNotifications.instance.on('schedule').subscribe((notification: ILocalNotification) => { + this.handleEvent('schedule', notification); + }); + + this.updateSubscription = LocalNotifications.instance.on('update').subscribe((notification: ILocalNotification) => { + this.handleEvent('update', notification); + }); + + // Create the default channel for local notifications. + this.createDefaultChannel(); + + Translate.instance.onLangChange.subscribe((event: any) => { + // Update the channel name. + this.createDefaultChannel(); + }); + }); + + CoreEvents.instance.on(CoreEventsProvider.SITE_DELETED, (site) => { + if (site) { + this.cancelSiteNotifications(site.id); + } + }); + } + + /** + * Cancel a local notification. + * + * @param id Notification id. + * @param component Component of the notification. + * @param siteId Site ID. + * @return Promise resolved when the notification is cancelled. + */ + async cancel(id: number, component: string, siteId: string): Promise { + const uniqueId = await this.getUniqueNotificationId(id, component, siteId); + + const queueId = 'cancel-' + uniqueId; + + await this.queueRunner.run(queueId, () => LocalNotifications.instance.cancel(uniqueId), { + allowRepeated: true, + }); + } + + /** + * Cancel all the scheduled notifications belonging to a certain site. + * + * @param siteId Site ID. + * @return Promise resolved when the notifications are cancelled. + */ + async cancelSiteNotifications(siteId: string): Promise { + + if (!this.isAvailable()) { + return; + } else if (!siteId) { + throw new Error('No site ID supplied.'); + } + + const scheduled = await this.getAllScheduled(); + + const ids = []; + const queueId = 'cancelSiteNotifications-' + siteId; + + scheduled.forEach((notif) => { + notif.data = this.parseNotificationData(notif.data); + + if (typeof notif.data == 'object' && notif.data.siteId === siteId) { + ids.push(notif.id); + } + }); + + await this.queueRunner.run(queueId, () => LocalNotifications.instance.cancel(ids), { + allowRepeated: true, + }); + } + + /** + * Check whether sound can be disabled for notifications. + * + * @return Whether sound can be disabled for notifications. + */ + canDisableSound(): boolean { + // Only allow disabling sound in Android 7 or lower. In iOS and Android 8+ it can easily be done with system settings. + return this.isAvailable() && !CoreApp.instance.isDesktop() && CoreApp.instance.isAndroid() && + Device.instance.version && Number(Device.instance.version.split('.')[0]) < 8; + } + + /** + * Create the default channel. It is used to change the name. + * + * @return Promise resolved when done. + */ + protected createDefaultChannel(): Promise { + if (!CoreApp.instance.isAndroid()) { + return Promise.resolve(); + } + + return Push.instance.createChannel({ + id: 'default-channel-id', + description: Translate.instance.instant('addon.calendar.calendarreminders'), + importance: 4 + }).catch((error) => { + this.logger.error('Error changing channel name', error); + }); + } + + /** + * Get all scheduled notifications. + * + * @return Promise resolved with the notifications. + */ + protected getAllScheduled(): Promise { + return this.queueRunner.run('allScheduled', () => LocalNotifications.instance.getAllScheduled()); + } + + /** + * Get a code to create unique notifications. If there's no code assigned, create a new one. + * + * @param table Table to search in local DB. + * @param id ID of the element to get its code. + * @return Promise resolved when the code is retrieved. + */ + protected async getCode(table: string, id: string): Promise { + await this.dbReady; + + const key = table + '#' + id; + + // Check if the code is already in memory. + if (typeof this.codes[key] != 'undefined') { + return this.codes[key]; + } + + try { + // Check if we already have a code stored for that ID. + const entry = await this.appDB.getRecord(table, { id: id }); + this.codes[key] = entry.code; + + return entry.code; + } catch (err) { + // No code stored for that ID. Create a new code for it. + const entries = await this.appDB.getRecords(table, undefined, 'code DESC'); + let newCode = 0; + if (entries.length > 0) { + newCode = entries[0].code + 1; + } + + await this.appDB.insertRecord(table, { id: id, code: newCode }); + this.codes[key] = newCode; + + return newCode; + } + } + + /** + * Get a notification component code to be used. + * If it's the first time this component is used to send notifications, create a new code for it. + * + * @param component Component name. + * @return Promise resolved when the component code is retrieved. + */ + protected getComponentCode(component: string): Promise { + return this.requestCode(this.COMPONENTS_TABLE, component); + } + + /** + * Get a site code to be used. + * If it's the first time this site is used to send notifications, create a new code for it. + * + * @param siteId Site ID. + * @return Promise resolved when the site code is retrieved. + */ + protected getSiteCode(siteId: string): Promise { + return this.requestCode(this.SITES_TABLE, siteId); + } + + /** + * Create a unique notification ID, trying to prevent collisions. Generated ID must be a Number (Android). + * The generated ID shouldn't be higher than 2147483647 or it's going to cause problems in Android. + * This function will prevent collisions and keep the number under Android limit if: + * -User has used less than 21 sites. + * -There are less than 11 components. + * -The notificationId passed as parameter is lower than 10000000. + * + * @param notificationId Notification ID. + * @param component Component triggering the notification. + * @param siteId Site ID. + * @return Promise resolved when the notification ID is generated. + */ + protected getUniqueNotificationId(notificationId: number, component: string, siteId: string): Promise { + if (!siteId || !component) { + return Promise.reject(null); + } + + return this.getSiteCode(siteId).then((siteCode) => { + return this.getComponentCode(component).then((componentCode) => { + // We use the % operation to keep the number under Android's limit. + return (siteCode * 100000000 + componentCode * 10000000 + notificationId) % 2147483647; + }); + }); + } + + /** + * Handle an event triggered by the local notifications plugin. + * + * @param eventName Name of the event. + * @param notification Notification. + */ + protected handleEvent(eventName: string, notification: any): void { + if (notification && notification.data) { + this.logger.debug('Notification event: ' + eventName + '. Data:', notification.data); + + this.notifyEvent(eventName, notification.data); + } + } + + /** + * Returns whether local notifications plugin is installed. + * + * @return Whether local notifications plugin is installed. + */ + isAvailable(): boolean { + const win = window; + + return CoreApp.instance.isDesktop() || !!(win.cordova && win.cordova.plugins && win.cordova.plugins.notification && + win.cordova.plugins.notification.local); + } + + /** + * Check if a notification has been triggered with the same trigger time. + * + * @param notification Notification to check. + * @param useQueue Whether to add the call to the queue. + * @return Promise resolved with a boolean indicating if promise is triggered (true) or not. + */ + async isTriggered(notification: ILocalNotification, useQueue: boolean = true): Promise { + await this.dbReady; + + try { + const stored = await this.appDB.getRecord(this.TRIGGERED_TABLE, { id: notification.id }); + let triggered = (notification.trigger && notification.trigger.at) || 0; + + if (typeof triggered != 'number') { + triggered = triggered.getTime(); + } + + return stored.at === triggered; + } catch (err) { + if (useQueue) { + const queueId = 'isTriggered-' + notification.id; + + return this.queueRunner.run(queueId, () => LocalNotifications.instance.isTriggered(notification.id), { + allowRepeated: true, + }); + } else { + return LocalNotifications.instance.isTriggered(notification.id); + } + } + } + + /** + * Notify notification click to observers. Only the observers with the same component as the notification will be notified. + * + * @param data Data received by the notification. + */ + notifyClick(data: any): void { + this.notifyEvent('click', data); + } + + /** + * Notify a certain event to observers. Only the observers with the same component as the notification will be notified. + * + * @param eventName Name of the event to notify. + * @param data Data received by the notification. + */ + notifyEvent(eventName: string, data: any): void { + // Execute the code in the Angular zone, so change detection doesn't stop working. + NgZone.instance.run(() => { + const component = data.component; + if (component) { + if (this.observables[eventName] && this.observables[eventName][component]) { + this.observables[eventName][component].next(data); + } + } + }); + } + + /** + * Parse some notification data. + * + * @param data Notification data. + * @return Parsed data. + */ + protected parseNotificationData(data: any): any { + if (!data) { + return {}; + } else if (typeof data == 'string') { + return CoreTextUtils.instance.parseJSON(data, {}); + } else { + return data; + } + } + + /** + * Process the next request in queue. + */ + protected processNextRequest(): void { + const nextKey = Object.keys(this.codeRequestsQueue)[0]; + let request, + promise; + + if (typeof nextKey == 'undefined') { + // No more requests in queue, stop. + return; + } + + request = this.codeRequestsQueue[nextKey]; + + // Check if request is valid. + if (typeof request == 'object' && typeof request.table != 'undefined' && typeof request.id != 'undefined') { + // Get the code and resolve/reject all the promises of this request. + promise = this.getCode(request.table, request.id).then((code) => { + request.promises.forEach((p) => { + p.resolve(code); + }); + }).catch((error) => { + request.promises.forEach((p) => { + p.reject(error); + }); + }); + } else { + promise = Promise.resolve(); + } + + // Once this item is treated, remove it and process next. + promise.finally(() => { + delete this.codeRequestsQueue[nextKey]; + this.processNextRequest(); + }); + } + + /** + * Register an observer to be notified when a notification belonging to a certain component is clicked. + * + * @param component Component to listen notifications for. + * @param callback Function to call with the data received by the notification. + * @return Object with an "off" property to stop listening for clicks. + */ + registerClick(component: string, callback: Function): any { + return this.registerObserver('click', component, callback); + } + + /** + * Register an observer to be notified when a certain event is fired for a notification belonging to a certain component. + * + * @param eventName Name of the event to listen to. + * @param component Component to listen notifications for. + * @param callback Function to call with the data received by the notification. + * @return Object with an "off" property to stop listening for events. + */ + registerObserver(eventName: string, component: string, callback: Function): any { + this.logger.debug(`Register observer '${component}' for event '${eventName}'.`); + + if (typeof this.observables[eventName] == 'undefined') { + this.observables[eventName] = {}; + } + + if (typeof this.observables[eventName][component] == 'undefined') { + // No observable for this component, create a new one. + this.observables[eventName][component] = new Subject(); + } + + this.observables[eventName][component].subscribe(callback); + + return { + off: (): void => { + this.observables[eventName][component].unsubscribe(callback); + } + }; + } + + /** + * Remove a notification from triggered store. + * + * @param id Notification ID. + * @return Promise resolved when it is removed. + */ + async removeTriggered(id: number): Promise { + await this.dbReady; + + return this.appDB.deleteRecords(this.TRIGGERED_TABLE, { id: id }); + } + + /** + * Request a unique code. The request will be added to the queue and the queue is going to be started if it's paused. + * + * @param table Table to search in local DB. + * @param id ID of the element to get its code. + * @return Promise resolved when the code is retrieved. + */ + protected requestCode(table: string, id: string): Promise { + const deferred = CoreUtils.instance.promiseDefer(), + key = table + '#' + id, + isQueueEmpty = Object.keys(this.codeRequestsQueue).length == 0; + + if (typeof this.codeRequestsQueue[key] != 'undefined') { + // There's already a pending request for this store and ID, add the promise to it. + this.codeRequestsQueue[key].promises.push(deferred); + } else { + // Add a pending request to the queue. + this.codeRequestsQueue[key] = { + table: table, + id: id, + promises: [deferred] + }; + } + + if (isQueueEmpty) { + this.processNextRequest(); + } + + return deferred.promise; + } + + /** + * Reschedule all notifications that are already scheduled. + * + * @return Promise resolved when all notifications have been rescheduled. + */ + async rescheduleAll(): Promise { + // Get all the scheduled notifications. + const notifications = await this.getAllScheduled(); + + await Promise.all(notifications.map(async (notification) => { + // Convert some properties to the needed types. + notification.data = this.parseNotificationData(notification.data); + + const queueId = 'schedule-' + notification.id; + + await this.queueRunner.run(queueId, () => this.scheduleNotification(notification), { + allowRepeated: true, + }); + })); + } + + /** + * Schedule a local notification. + * + * @param notification Notification to schedule. Its ID should be lower than 10000000 and it should + * be unique inside its component and site. + * @param component Component triggering the notification. It is used to generate unique IDs. + * @param siteId Site ID. + * @param alreadyUnique Whether the ID is already unique. + * @return Promise resolved when the notification is scheduled. + */ + async schedule(notification: ILocalNotification, component: string, siteId: string, alreadyUnique?: boolean): Promise { + + if (!alreadyUnique) { + notification.id = await this.getUniqueNotificationId(notification.id, component, siteId); + } + + notification.data = notification.data || {}; + notification.data.component = component; + notification.data.siteId = siteId; + + if (CoreApp.instance.isAndroid()) { + notification.icon = notification.icon || 'res://icon'; + notification.smallIcon = notification.smallIcon || 'res://smallicon'; + notification.color = notification.color || CoreConfigConstants.notificoncolor; + + const led: any = notification.led || {}; + notification.led = { + color: led.color || 'FF9900', + on: led.on || 1000, + off: led.off || 1000 + }; + } + + const queueId = 'schedule-' + notification.id; + + await this.queueRunner.run(queueId, () => this.scheduleNotification(notification), { + allowRepeated: true, + }); + } + + /** + * Helper function to schedule a notification object if it hasn't been triggered already. + * + * @param notification Notification to schedule. + * @return Promise resolved when scheduled. + */ + protected scheduleNotification(notification: ILocalNotification): Promise { + // Check if the notification has been triggered already. + return this.isTriggered(notification, false).then((triggered) => { + // Cancel the current notification in case it gets scheduled twice. + return LocalNotifications.instance.cancel(notification.id).finally(() => { + if (!triggered) { + // Check if sound is enabled for notifications. + let promise; + + if (this.canDisableSound()) { + promise = CoreConfig.instance.get(CoreConstants.SETTINGS_NOTIFICATION_SOUND, true); + } else { + promise = Promise.resolve(true); + } + + return promise.then((soundEnabled) => { + if (!soundEnabled) { + notification.sound = null; + } else { + delete notification.sound; // Use default value. + } + + notification.foreground = true; + + // Remove from triggered, since the notification could be in there with a different time. + this.removeTriggered(notification.id); + LocalNotifications.instance.schedule(notification); + }); + } + }); + }); + } + + /** + * Function to call when a notification is triggered. Stores the notification so it's not scheduled again unless the + * time is changed. + * + * @param notification Triggered notification. + * @return Promise resolved when stored, rejected otherwise. + */ + async trigger(notification: ILocalNotification): Promise { + await this.dbReady; + + const entry = { + id: notification.id, + at: notification.trigger && notification.trigger.at ? notification.trigger.at : Date.now() + }; + + return this.appDB.insertRecord(this.TRIGGERED_TABLE, entry); + } + + /** + * Update a component name. + * + * @param oldName The old name. + * @param newName The new name. + * @return Promise resolved when done. + */ + async updateComponentName(oldName: string, newName: string): Promise { + await this.dbReady; + + const oldId = this.COMPONENTS_TABLE + '#' + oldName, + newId = this.COMPONENTS_TABLE + '#' + newName; + + return this.appDB.updateRecords(this.COMPONENTS_TABLE, {id: newId}, {id: oldId}); + } +} + +export class CoreLocalNotifications extends makeSingleton(CoreLocalNotificationsProvider) {} diff --git a/src/app/services/plugin-file-delegate.ts b/src/app/services/plugin-file-delegate.ts new file mode 100644 index 000000000..584deb83f --- /dev/null +++ b/src/app/services/plugin-file-delegate.ts @@ -0,0 +1,394 @@ +// (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 { FileEntry } from '@ionic-native/file'; + +import { CoreFilepool } from '@services/filepool'; +import { CoreWSExternalFile } from '@services/ws'; +import { CoreConstants } from '@core/constants'; +import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { makeSingleton } from '@singletons/core.singletons'; + +/** + * Delegate to register pluginfile information handlers. + */ +@Injectable() +export class CorePluginFileDelegate extends CoreDelegate { + protected handlerNameProperty = 'component'; + + constructor() { + super('CorePluginFileDelegate', true); + } + + /** + * React to a file being deleted. + * + * @param fileUrl The file URL used to download the file. + * @param path The path of the deleted file. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + fileDeleted(fileUrl: string, path: string, siteId?: string): Promise { + const handler = this.getHandlerForFile({fileurl: fileUrl}); + + if (handler && handler.fileDeleted) { + return handler.fileDeleted(fileUrl, path, siteId); + } + + return Promise.resolve(); + } + + /** + * Check whether a file can be downloaded. If so, return the file to download. + * + * @param file The file data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the file to use. Rejected if cannot download. + */ + getDownloadableFile(file: CoreWSExternalFile, siteId?: string): Promise { + const handler = this.getHandlerForFile(file); + + return this.getHandlerDownloadableFile(file, handler, siteId); + } + + /** + * Check whether a file can be downloaded. If so, return the file to download. + * + * @param file The file data. + * @param handler The handler to use. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the file to use. Rejected if cannot download. + */ + protected async getHandlerDownloadableFile(file: CoreWSExternalFile, handler: CorePluginFileHandler, siteId?: string) + : Promise { + + const isDownloadable = await this.isFileDownloadable(file, siteId); + + if (!isDownloadable.downloadable) { + throw isDownloadable.reason; + } + + if (handler && handler.getDownloadableFile) { + const newFile = await handler.getDownloadableFile(file, siteId); + + return newFile || file; + } + + return file; + } + + /** + * Get the RegExp of the component and filearea described in the URL. + * + * @param args Arguments of the pluginfile URL defining component and filearea at least. + * @return RegExp to match the revision or undefined if not found. + */ + getComponentRevisionRegExp(args: string[]): RegExp { + // Get handler based on component (args[1]). + const handler = this.getHandler(args[1], true); + + if (handler && handler.getComponentRevisionRegExp) { + return handler.getComponentRevisionRegExp(args); + } + } + + /** + * Given an HTML element, get the URLs of the files that should be downloaded and weren't treated by + * CoreFilepoolProvider.extractDownloadableFilesFromHtml. + * + * @param container Container where to get the URLs from. + * @return List of URLs. + */ + getDownloadableFilesFromHTML(container: HTMLElement): string[] { + let files = []; + + for (const component in this.enabledHandlers) { + const handler = this.enabledHandlers[component]; + + if (handler && handler.getDownloadableFilesFromHTML) { + files = files.concat(handler.getDownloadableFilesFromHTML(container)); + } + } + + return files; + } + + /** + * Sum the filesizes from a list if they are not downloaded. + * + * @param files List of files to sum its filesize. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with file size and a boolean to indicate if it is the total size or only partial. + */ + async getFilesDownloadSize(files: CoreWSExternalFile[], siteId?: string): Promise<{ size: number, total: boolean }> { + const filteredFiles = []; + + await Promise.all(files.map(async (file) => { + const state = await CoreFilepool.instance.getFileStateByUrl(siteId, file.fileurl, file.timemodified); + + if (state != CoreConstants.DOWNLOADED && state != CoreConstants.NOT_DOWNLOADABLE) { + filteredFiles.push(file); + } + })); + + return this.getFilesSize(filteredFiles, siteId); + } + + /** + * Sum the filesizes from a list of files checking if the size will be partial or totally calculated. + * + * @param files List of files to sum its filesize. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with file size and a boolean to indicate if it is the total size or only partial. + */ + async getFilesSize(files: CoreWSExternalFile[], siteId?: string): Promise<{ size: number, total: boolean }> { + const result = { + size: 0, + total: true + }; + + await Promise.all(files.map(async (file) => { + const size = await this.getFileSize(file, siteId); + + if (typeof size == 'undefined') { + // We don't have the file size, cannot calculate its total size. + result.total = false; + } else { + result.size += size; + } + })); + + return result; + } + + /** + * Get a file size. + * + * @param file The file data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the size. + */ + async getFileSize(file: CoreWSExternalFile, siteId?: string): Promise { + const isDownloadable = await this.isFileDownloadable(file, siteId); + + if (!isDownloadable.downloadable) { + return 0; + } + + const handler = this.getHandlerForFile(file); + + // First of all check if file can be downloaded. + const downloadableFile = await this.getHandlerDownloadableFile(file, handler, siteId); + if (!downloadableFile) { + return 0; + } + + if (handler && handler.getFileSize) { + try { + const size = handler.getFileSize(downloadableFile, siteId); + + return size; + } catch (error) { + // Ignore errors. + } + } + + return downloadableFile.filesize; + } + + /** + * Get a handler to treat a certain file. + * + * @param file File data. + * @return Handler. + */ + protected getHandlerForFile(file: CoreWSExternalFile): CorePluginFileHandler { + for (const component in this.enabledHandlers) { + const handler = this.enabledHandlers[component]; + + if (handler && handler.shouldHandleFile && handler.shouldHandleFile(file)) { + return handler; + } + } + } + + /** + * Check if a file is downloadable. + * + * @param file The file data. + * @param siteId Site ID. If not defined, current site. + * @return Promise with the data. + */ + isFileDownloadable(file: CoreWSExternalFile, siteId?: string): Promise { + const handler = this.getHandlerForFile(file); + + if (handler && handler.isFileDownloadable) { + return handler.isFileDownloadable(file, siteId); + } + + // Default to true. + return Promise.resolve({downloadable: true}); + } + + /** + * Removes the revision number from a file URL. + * + * @param url URL to be replaced. + * @param args Arguments of the pluginfile URL defining component and filearea at least. + * @return Replaced URL without revision. + */ + removeRevisionFromUrl(url: string, args: string[]): string { + // Get handler based on component (args[1]). + const handler = this.getHandler(args[1], true); + + if (handler && handler.getComponentRevisionRegExp && handler.getComponentRevisionReplace) { + const revisionRegex = handler.getComponentRevisionRegExp(args); + if (revisionRegex) { + return url.replace(revisionRegex, handler.getComponentRevisionReplace(args)); + } + } + + return url; + } + + /** + * Treat a downloaded file. + * + * @param fileUrl The file URL used to download the file. + * @param file The file entry of the downloaded file. + * @param siteId Site ID. If not defined, current site. + * @param onProgress Function to call on progress. + * @return Promise resolved when done. + */ + treatDownloadedFile(fileUrl: string, file: FileEntry, siteId?: string, onProgress?: (event: any) => any): Promise { + const handler = this.getHandlerForFile({fileurl: fileUrl}); + + if (handler && handler.treatDownloadedFile) { + return handler.treatDownloadedFile(fileUrl, file, siteId, onProgress); + } + + return Promise.resolve(); + } +} + +export class CorePluginFile extends makeSingleton(CorePluginFileDelegate) {} + +/** + * Interface that all plugin file handlers must implement. + */ +export interface CorePluginFileHandler extends CoreDelegateHandler { + + /** + * The "component" of the handler. It should match the "component" of pluginfile URLs. + * It is used to treat revision from URLs. + */ + component?: string; + + /** + * Return the RegExp to match the revision on pluginfile URLs. + * + * @param args Arguments of the pluginfile URL defining component and filearea at least. + * @return RegExp to match the revision on pluginfile URLs. + */ + getComponentRevisionRegExp?(args: string[]): RegExp; + + /** + * Should return the string to remove the revision on pluginfile url. + * + * @param args Arguments of the pluginfile URL defining component and filearea at least. + * @return String to remove the revision on pluginfile url. + */ + getComponentRevisionReplace?(args: string[]): string; + + /** + * React to a file being deleted. + * + * @param fileUrl The file URL used to download the file. + * @param path The path of the deleted file. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + fileDeleted?(fileUrl: string, path: string, siteId?: string): Promise; + + /** + * Check whether a file can be downloaded. If so, return the file to download. + * + * @param file The file data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the file to use. Rejected if cannot download. + */ + getDownloadableFile?(file: CoreWSExternalFile, siteId?: string): Promise; + + /** + * Given an HTML element, get the URLs of the files that should be downloaded and weren't treated by + * CoreFilepoolProvider.extractDownloadableFilesFromHtml. + * + * @param container Container where to get the URLs from. + * @return List of URLs. + */ + getDownloadableFilesFromHTML?(container: HTMLElement): string[]; + + /** + * Get a file size. + * + * @param file The file data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the size. + */ + getFileSize?(file: CoreWSExternalFile, siteId?: string): Promise; + + /** + * Check if a file is downloadable. + * + * @param file The file data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with a boolean and a reason why it isn't downloadable if needed. + */ + isFileDownloadable?(file: CoreWSExternalFile, siteId?: string): Promise; + + /** + * Check whether the file should be treated by this handler. It is used in functions where the component isn't used. + * + * @param file The file data. + * @return Whether the file should be treated by this handler. + */ + shouldHandleFile?(file: CoreWSExternalFile): boolean; + + /** + * Treat a downloaded file. + * + * @param fileUrl The file URL used to download the file. + * @param file The file entry of the downloaded file. + * @param siteId Site ID. If not defined, current site. + * @param onProgress Function to call on progress. + * @return Promise resolved when done. + */ + treatDownloadedFile?(fileUrl: string, file: FileEntry, siteId?: string, onProgress?: (event: any) => any): Promise; +} + +/** + * Data about if a file is downloadable. + */ +export type CorePluginFileDownloadableResult = { + /** + * Whether it's downloadable. + */ + downloadable: boolean; + + /** + * If not downloadable, the reason why it isn't. + */ + reason?: string; +}; diff --git a/src/app/services/sites.ts b/src/app/services/sites.ts new file mode 100644 index 000000000..8c4dc2543 --- /dev/null +++ b/src/app/services/sites.ts @@ -0,0 +1,2034 @@ +// (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 { Md5 } from 'ts-md5/dist/md5'; +import { timeout } from 'rxjs/operators'; + +import { CoreApp, CoreAppSchema, CoreStoreConfig } from '@services/app'; +import { CoreEvents, CoreEventsProvider } from '@services/events'; +import { CoreWS } from '@services/ws'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreConstants } from '@core/constants'; +import CoreConfigConstants from '@app/config.json'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; +import { SQLiteDB, SQLiteDBTableSchema } from '@classes/sqlitedb'; +import { makeSingleton, Translate, Http } from '@singletons/core.singletons'; +import { CoreLogger } from '@singletons/logger'; + +/* + * Service to manage and interact with sites. + * It allows creating tables in the databases of all sites. Each service or component should be responsible of creating + * their own database tables. Example: + * + * constructor(sitesProvider: CoreSitesProvider) { + * this.sitesProvider.registerSiteSchema(this.tableSchema); + * + * This provider will automatically create the tables in the databases of all the instantiated sites, and also to the + * databases of sites instantiated from now on. +*/ +@Injectable() +export class CoreSitesProvider { + // Variables for the database. + static SITES_TABLE = 'sites_2'; + static CURRENT_SITE_TABLE = 'current_site'; + static SCHEMA_VERSIONS_TABLE = 'schema_versions'; + + protected appTablesSchema: CoreAppSchema = { + name: 'CoreSitesProvider', + version: 2, + tables: [ + { + name: CoreSitesProvider.SITES_TABLE, + columns: [ + { + name: 'id', + type: 'TEXT', + primaryKey: true + }, + { + name: 'siteUrl', + type: 'TEXT', + notNull: true + }, + { + name: 'token', + type: 'TEXT' + }, + { + name: 'info', + type: 'TEXT' + }, + { + name: 'privateToken', + type: 'TEXT' + }, + { + name: 'config', + type: 'TEXT' + }, + { + name: 'loggedOut', + type: 'INTEGER' + }, + { + name: 'oauthId', + type: 'INTEGER' + }, + ], + }, + { + name: CoreSitesProvider.CURRENT_SITE_TABLE, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true + }, + { + name: 'siteId', + type: 'TEXT', + notNull: true, + unique: true + }, + ], + }, + ], + async migrate(db: SQLiteDB, oldVersion: number): Promise { + if (oldVersion < 2) { + const newTable = CoreSitesProvider.SITES_TABLE; + const oldTable = 'sites'; + + try { + // Check if V1 table exists. + await db.tableExists(oldTable); + + // Move the records from the old table. + const sites = await db.getAllRecords(oldTable); + const promises = []; + + sites.forEach((site) => { + promises.push(db.insertRecord(newTable, site)); + }); + + await Promise.all(promises); + + // Data moved, drop the old table. + await db.dropTable(oldTable); + } catch (error) { + // Old table does not exist, ignore. + } + } + }, + }; + + // Constants to validate a site version. + protected WORKPLACE_APP = 3; + protected MOODLE_APP = 2; + protected VALID_VERSION = 1; + protected INVALID_VERSION = -1; + + protected isWPApp: boolean; + + protected logger: CoreLogger; + protected services = {}; + protected sessionRestored = false; + protected currentSite: CoreSite; + protected sites: { [s: string]: CoreSite } = {}; + protected appDB: SQLiteDB; + protected dbReady: Promise; // Promise resolved when the app DB is initialized. + protected siteSchemasMigration: { [siteId: string]: Promise } = {}; + + // Schemas for site tables. Other providers can add schemas in here. + protected siteSchemas: { [name: string]: CoreRegisteredSiteSchema } = {}; + protected siteTablesSchemas: SQLiteDBTableSchema[] = [ + { + name: CoreSitesProvider.SCHEMA_VERSIONS_TABLE, + columns: [ + { + name: 'name', + type: 'TEXT', + primaryKey: true, + }, + { + name: 'version', + type: 'INTEGER' + } + ] + } + ]; + + // Site schema for this provider. + protected siteSchema: CoreSiteSchema = { + name: 'CoreSitesProvider', + version: 2, + canBeCleared: [ CoreSite.WS_CACHE_TABLE ], + tables: [ + { + name: CoreSite.WS_CACHE_TABLE, + columns: [ + { + name: 'id', + type: 'TEXT', + primaryKey: true + }, + { + name: 'data', + type: 'TEXT' + }, + { + name: 'key', + type: 'TEXT' + }, + { + name: 'expirationTime', + type: 'INTEGER' + }, + { + name: 'component', + type: 'TEXT' + }, + { + name: 'componentId', + type: 'INTEGER' + } + ] + }, + { + name: CoreSite.CONFIG_TABLE, + columns: [ + { + name: 'name', + type: 'TEXT', + unique: true, + notNull: true + }, + { + name: 'value' + } + ] + } + ], + async migrate(db: SQLiteDB, oldVersion: number, siteId: string): Promise { + if (oldVersion && oldVersion < 2) { + const newTable = CoreSite.WS_CACHE_TABLE; + const oldTable = 'wscache'; + + try { + await db.tableExists(oldTable); + } catch (error) { + // Old table does not exist, ignore. + return; + } + // Cannot use insertRecordsFrom because there are extra fields, so manually code INSERT INTO. + await db.execute( + 'INSERT INTO ' + newTable + ' ' + + 'SELECT id, data, key, expirationTime, NULL as component, NULL as componentId ' + + 'FROM ' + oldTable); + + try { + await db.dropTable(oldTable); + } catch (error) { + // Error deleting old table, ignore. + } + } + } + }; + + constructor() { + this.logger = CoreLogger.getInstance('CoreSitesProvider'); + + this.appDB = CoreApp.instance.getDB(); + this.dbReady = CoreApp.instance.createTablesFromSchema(this.appTablesSchema).catch(() => { + // Ignore errors. + }); + this.registerSiteSchema(this.siteSchema); + } + + /** + * Get the demo data for a certain "name" if it is a demo site. + * + * @param name Name of the site to check. + * @return Site data if it's a demo site, undefined otherwise. + */ + getDemoSiteData(name: string): any { + const demoSites = CoreConfigConstants.demo_sites; + name = name.toLowerCase(); + + if (typeof demoSites != 'undefined' && typeof demoSites[name] != 'undefined') { + return demoSites[name]; + } + } + + /** + * Check if a site is valid and if it has specifics settings for authentication (like force to log in using the browser). + * It will test both protocols if the first one fails: http and https. + * + * @param siteUrl URL of the site to check. + * @param protocol Protocol to use first. + * @return A promise resolved when the site is checked. + */ + checkSite(siteUrl: string, protocol: string = 'https://'): Promise { + // The formatURL function adds the protocol if is missing. + siteUrl = CoreUrlUtils.instance.formatURL(siteUrl); + + if (!CoreUrlUtils.instance.isHttpURL(siteUrl)) { + return Promise.reject(Translate.instance.instant('core.login.invalidsite')); + } else if (!CoreApp.instance.isOnline()) { + return Promise.reject(Translate.instance.instant('core.networkerrormsg')); + } else { + return this.checkSiteWithProtocol(siteUrl, protocol).catch((error) => { + // Do not continue checking if a critical error happened. + if (error.critical) { + return Promise.reject(error); + } + + // Retry with the other protocol. + protocol = protocol == 'https://' ? 'http://' : 'https://'; + + return this.checkSiteWithProtocol(siteUrl, protocol).catch((secondError) => { + if (secondError.critical) { + return Promise.reject(secondError); + } + + // Site doesn't exist. Return the error message. + if (CoreTextUtils.instance.getErrorMessageFromError(error)) { + return Promise.reject(error); + } else if (CoreTextUtils.instance.getErrorMessageFromError(secondError)) { + return Promise.reject(secondError); + } else { + return Translate.instance.instant('core.cannotconnecttrouble'); + } + }); + }); + } + } + + /** + * Helper function to check if a site is valid and if it has specifics settings for authentication. + * + * @param siteUrl URL of the site to check. + * @param protocol Protocol to use. + * @return A promise resolved when the site is checked. + */ + checkSiteWithProtocol(siteUrl: string, protocol: string): Promise { + let publicConfig; + + // Now, replace the siteUrl with the protocol. + siteUrl = siteUrl.replace(/^http(s)?\:\/\//i, protocol); + + return this.siteExists(siteUrl).catch((error) => { + // Do not continue checking if WS are not enabled. + if (error.errorcode == 'enablewsdescription') { + return rejectWithCriticalError(error.error, error.errorcode); + } + + // Site doesn't exist. Try to add or remove 'www'. + const treatedUrl = CoreUrlUtils.instance.addOrRemoveWWW(siteUrl); + + return this.siteExists(treatedUrl).then(() => { + // Success, use this new URL as site url. + siteUrl = treatedUrl; + }).catch((secondError) => { + // Do not continue checking if WS are not enabled. + if (secondError.errorcode == 'enablewsdescription') { + return rejectWithCriticalError(secondError.error, secondError.errorcode); + } + + // Return the error message. + if (CoreTextUtils.instance.getErrorMessageFromError(error)) { + return Promise.reject(error); + } else { + return Promise.reject(secondError); + } + }); + }).then(() => { + // Create a temporary site to check if local_mobile is installed. + const temporarySite = new CoreSite(undefined, siteUrl); + + return temporarySite.checkLocalMobilePlugin().then((data) => { + data.service = data.service || CoreConfigConstants.wsservice; + this.services[siteUrl] = data.service; // No need to store it in DB. + + if (data.coreSupported || + (data.code != CoreConstants.LOGIN_SSO_CODE && data.code != CoreConstants.LOGIN_SSO_INAPP_CODE)) { + // SSO using local_mobile not needed, try to get the site public config. + return temporarySite.getPublicConfig().then((config): any => { + publicConfig = config; + + // Check that the user can authenticate. + if (!config.enablewebservices) { + return rejectWithCriticalError(Translate.instance.instant('core.login.webservicesnotenabled')); + } else if (!config.enablemobilewebservice) { + return rejectWithCriticalError(Translate.instance.instant('core.login.mobileservicesnotenabled')); + } else if (config.maintenanceenabled) { + let message = Translate.instance.instant('core.sitemaintenance'); + if (config.maintenancemessage) { + message += config.maintenancemessage; + } + + return rejectWithCriticalError(message); + } + + // Everything ok. + if (data.code === 0) { + data.code = config.typeoflogin; + } + + return data; + }, (error): any => { + // Error, check if not supported. + if (error.available === 1) { + // Service supported but an error happened. Return error. + error.critical = true; + + if (error.errorcode == 'codingerror') { + // This could be caused by a redirect. Check if it's the case. + return CoreUtils.instance.checkRedirect(siteUrl).then((redirect) => { + if (redirect) { + error.error = Translate.instance.instant('core.login.sitehasredirect'); + } else { + // We can't be sure if there is a redirect or not. Display cannot connect error. + error.error = Translate.instance.instant('core.cannotconnecttrouble'); + } + + return Promise.reject(error); + }); + } + + return Promise.reject(error); + } + + return data; + }); + } + + return data; + }, (error) => { + // Local mobile check returned an error. This only happens if the plugin is installed and it returns an error. + return rejectWithCriticalError(error); + }).then((data) => { + siteUrl = temporarySite.getURL(); + + return { siteUrl, code: data.code, warning: data.warning, service: data.service, config: publicConfig }; + }); + }); + + // Return a rejected promise with a "critical" error. + function rejectWithCriticalError(message: string, errorCode?: string): Promise { + return Promise.reject({ + error: message, + errorcode: errorCode, + critical: true + }); + } + } + + /** + * Check if a site exists. + * + * @param siteUrl URL of the site to check. + * @return A promise to be resolved if the site exists. + */ + siteExists(siteUrl: string): Promise { + return Http.instance.post(siteUrl + '/login/token.php', {}).pipe(timeout(CoreWS.instance.getRequestTimeout())).toPromise() + .catch(() => { + // Default error messages are kinda bad, return our own message. + return Promise.reject({error: Translate.instance.instant('core.cannotconnecttrouble')}); + }).then((data: any) => { + + if (data === null) { + // Cannot connect. + return Promise.reject({error: Translate.instance.instant('core.cannotconnect', {$a: CoreSite.MINIMUM_MOODLE_VERSION})}); + } + + if (data.errorcode && (data.errorcode == 'enablewsdescription' || data.errorcode == 'requirecorrectaccess')) { + return Promise.reject({ errorcode: data.errorcode, error: data.error }); + } else if (data.error && data.error == 'Web services must be enabled in Advanced features.') { + return Promise.reject({ errorcode: 'enablewsdescription', error: data.error }); + } + // Other errors are not being checked because invalid login will be always raised and we cannot differ them. + }); + } + + /** + * Gets a user token from the server. + * + * @param siteUrl The site url. + * @param username User name. + * @param password Password. + * @param service Service to use. If not defined, it will be searched in memory. + * @param retry Whether we are retrying with a prefixed URL. + * @return A promise resolved when the token is retrieved. + */ + getUserToken(siteUrl: string, username: string, password: string, service?: string, retry?: boolean) + : Promise { + if (!CoreApp.instance.isOnline()) { + return Promise.reject(Translate.instance.instant('core.networkerrormsg')); + } + + if (!service) { + service = this.determineService(siteUrl); + } + + const params = { + username, + password, + service, + }; + const loginUrl = siteUrl + '/login/token.php'; + const promise = Http.instance.post(loginUrl, params).pipe(timeout(CoreWS.instance.getRequestTimeout())).toPromise(); + + return promise.then((data: any): any => { + if (typeof data == 'undefined') { + return Promise.reject(Translate.instance.instant('core.cannotconnecttrouble')); + } else { + if (typeof data.token != 'undefined') { + return { token: data.token, siteUrl, privateToken: data.privatetoken }; + } else { + + if (typeof data.error != 'undefined') { + // We only allow one retry (to avoid loops). + if (!retry && data.errorcode == 'requirecorrectaccess') { + siteUrl = CoreUrlUtils.instance.addOrRemoveWWW(siteUrl); + + return this.getUserToken(siteUrl, username, password, service, true); + } else if (data.errorcode == 'missingparam') { + // It seems the server didn't receive all required params, it could be due to a redirect. + return CoreUtils.instance.checkRedirect(loginUrl).then((redirect) => { + if (redirect) { + return Promise.reject({ error: Translate.instance.instant('core.login.sitehasredirect') }); + } else { + return Promise.reject({ error: data.error, errorcode: data.errorcode }); + } + }); + } else if (typeof data.errorcode != 'undefined') { + return Promise.reject({ error: data.error, errorcode: data.errorcode }); + } else { + return Promise.reject(data.error); + } + } else { + return Promise.reject(Translate.instance.instant('core.login.invalidaccount')); + } + } + } + }, () => { + return Promise.reject(Translate.instance.instant('core.cannotconnecttrouble')); + }); + } + + /** + * Add a new site to the site list and authenticate the user in this site. + * + * @param siteUrl The site url. + * @param token User's token. + * @param privateToken User's private token. + * @param login Whether to login the user in the site. Defaults to true. + * @param oauthId OAuth ID. Only if the authentication was using an OAuth method. + * @return A promise resolved with siteId when the site is added and the user is authenticated. + */ + newSite(siteUrl: string, token: string, privateToken: string = '', login: boolean = true, oauthId?: number): Promise { + if (typeof login != 'boolean') { + login = true; + } + + // Create a "candidate" site to fetch the site info. + let candidateSite = new CoreSite(undefined, siteUrl, token, undefined, privateToken, undefined, undefined); + let isNewSite = true; + + return candidateSite.fetchSiteInfo().then((info) => { + const result = this.isValidMoodleVersion(info); + if (result == this.VALID_VERSION) { + const siteId = this.createSiteID(info.siteurl, info.username); + + // Check if the site already exists. + return this.getSite(siteId).catch(() => { + // Not exists. + }).then((site) => { + if (site) { + // Site already exists, update its data and use it. + isNewSite = false; + candidateSite = site; + candidateSite.setToken(token); + candidateSite.setPrivateToken(privateToken); + candidateSite.setInfo(info); + candidateSite.setOAuthId(oauthId); + candidateSite.setLoggedOut(false); + + } else { + // New site, set site ID and info. + isNewSite = true; + candidateSite.setId(siteId); + candidateSite.setInfo(info); + candidateSite.setOAuthId(oauthId); + + // Create database tables before login and before any WS call. + return this.migrateSiteSchemas(candidateSite); + } + + }).then(() => { + + // Try to get the site config. + return this.getSiteConfig(candidateSite).catch((error) => { + // Ignore errors if it's not a new site, we'll use the config already stored. + if (isNewSite) { + return Promise.reject(error); + } + }).then((config) => { + if (typeof config != 'undefined') { + candidateSite.setConfig(config); + } + + // Add site to sites list. + this.addSite(siteId, siteUrl, token, info, privateToken, config, oauthId); + this.sites[siteId] = candidateSite; + + if (login) { + // Turn candidate site into current site. + this.currentSite = candidateSite; + // Store session. + this.login(siteId); + } + + CoreEvents.instance.trigger(CoreEventsProvider.SITE_ADDED, info, siteId); + + return siteId; + }); + }); + } + + return this.treatInvalidAppVersion(result, siteUrl); + }).catch((error) => { + // Error invaliddevice is returned by Workplace server meaning the same as connecttoworkplaceapp. + if (error && error.errorcode == 'invaliddevice') { + return this.treatInvalidAppVersion(this.WORKPLACE_APP, siteUrl); + } + + return Promise.reject(error); + }); + } + + /** + * Having the result of isValidMoodleVersion, it treats the error message to be shown. + * + * @param result Result returned by isValidMoodleVersion function. + * @param siteUrl The site url. + * @param siteId If site is already added, it will invalidate the token. + * @return A promise rejected with the error info. + */ + protected treatInvalidAppVersion(result: number, siteUrl: string, siteId?: string): Promise { + let errorCode; + let errorKey; + let translateParams; + + switch (result) { + case this.MOODLE_APP: + errorKey = 'core.login.connecttomoodleapp'; + errorCode = 'connecttomoodleapp'; + break; + case this.WORKPLACE_APP: + errorKey = 'core.login.connecttoworkplaceapp'; + errorCode = 'connecttoworkplaceapp'; + break; + default: + errorCode = 'invalidmoodleversion'; + errorKey = 'core.login.invalidmoodleversion'; + translateParams = {$a: CoreSite.MINIMUM_MOODLE_VERSION}; + } + + let promise; + + if (siteId) { + promise = this.setSiteLoggedOut(siteId, true); + } else { + promise = Promise.resolve(); + } + + return promise.then(() => { + return Promise.reject({ + error: Translate.instance.instant(errorKey, translateParams), + errorcode: errorCode, + loggedout: true + }); + }); + } + + /** + * Create a site ID based on site URL and username. + * + * @param siteUrl The site url. + * @param username Username. + * @return Site ID. + */ + createSiteID(siteUrl: string, username: string): string { + return Md5.hashAsciiStr(siteUrl + username); + } + + /** + * Function for determine which service we should use (default or extended plugin). + * + * @param siteUrl The site URL. + * @return The service shortname. + */ + determineService(siteUrl: string): string { + // We need to try siteUrl in both https or http (due to loginhttps setting). + + // First http:// + siteUrl = siteUrl.replace('https://', 'http://'); + if (this.services[siteUrl]) { + return this.services[siteUrl]; + } + + // Now https:// + siteUrl = siteUrl.replace('http://', 'https://'); + if (this.services[siteUrl]) { + return this.services[siteUrl]; + } + + // Return default service. + return CoreConfigConstants.wsservice; + } + + /** + * Check for the minimum required version. + * + * @param info Site info. + * @return Either VALID_VERSION, WORKPLACE_APP, MOODLE_APP or INVALID_VERSION. + */ + protected isValidMoodleVersion(info: any): number { + if (!info) { + return this.INVALID_VERSION; + } + + const version31 = 2016052300; + const release31 = CoreSite.MINIMUM_MOODLE_VERSION; + + // Try to validate by version. + if (info.version) { + const version = parseInt(info.version, 10); + if (!isNaN(version)) { + if (version >= version31) { + return this.validateWorkplaceVersion(info); + } + } + } + + // We couldn't validate by version number. Let's try to validate by release number. + const release = this.getReleaseNumber(info.release || ''); + if (release) { + if (release >= release31) { + return this.validateWorkplaceVersion(info); + } + } + + // Couldn't validate it. + return this.INVALID_VERSION; + } + + /** + * Check if needs to be redirected to specific Workplace App or general Moodle App. + * + * @param info Site info. + * @return Either VALID_VERSION, WORKPLACE_APP or MOODLE_APP. + */ + protected validateWorkplaceVersion(info: any): number { + const isWorkplace = !!info.functions && info.functions.some((func) => { + return func.name == 'tool_program_get_user_programs'; + }); + + if (typeof this.isWPApp == 'undefined') { + this.isWPApp = false; // @todo + } + + if (!this.isWPApp && isWorkplace) { + return this.WORKPLACE_APP; + } + + if (this.isWPApp && !isWorkplace) { + return this.MOODLE_APP; + } + + return this.VALID_VERSION; + } + + /** + * Returns the release number from site release info. + * + * @param rawRelease Raw release info text. + * @return Release number or empty. + */ + getReleaseNumber(rawRelease: string): string { + const matches = rawRelease.match(/^\d(\.\d(\.\d+)?)?/); + if (matches) { + return matches[0]; + } + + return ''; + } + + /** + * Check if site info is valid. If it's not, return error message. + * + * @param info Site info. + * @return True if valid, object with error message to show and its params if not valid. + */ + protected validateSiteInfo(info: any): any { + if (!info.firstname || !info.lastname) { + const moodleLink = `${info.siteurl}`; + + return { error: 'core.requireduserdatamissing', params: { $a: moodleLink } }; + } + + return true; + } + + /** + * Saves a site in local DB. + * + * @param id Site ID. + * @param siteUrl Site URL. + * @param token User's token in the site. + * @param info Site's info. + * @param privateToken User's private token. + * @param config Site config (from tool_mobile_get_config). + * @param oauthId OAuth ID. Only if the authentication was using an OAuth method. + * @return Promise resolved when done. + */ + async addSite(id: string, siteUrl: string, token: string, info: any, privateToken: string = '', config?: any, + oauthId?: number): Promise { + await this.dbReady; + + const entry = { + id, + siteUrl, + token, + info: info ? JSON.stringify(info) : info, + privateToken, + config: config ? JSON.stringify(config) : config, + loggedOut: 0, + oauthId, + }; + + return this.appDB.insertRecord(CoreSitesProvider.SITES_TABLE, entry); + } + + /** + * Check the app for a site and show a download dialogs if necessary. + * + * @param response Data obtained during site check. + */ + async checkApplication(response: CoreSiteCheckResponse): Promise { + await this.checkRequiredMinimumVersion(response.config); + } + + /** + * Check the required minimum version of the app for a site and shows a download dialog. + * + * @param config Config object of the site. + * @param siteId ID of the site to check. Current site id will be used otherwise. + * @return Resolved with if meets the requirements, rejected otherwise. + */ + checkRequiredMinimumVersion(config: any, siteId?: string): Promise { + if (config && config.tool_mobile_minimumversion) { + const requiredVersion = this.convertVersionName(config.tool_mobile_minimumversion); + const appVersion = this.convertVersionName(CoreConfigConstants.versionname); + + if (requiredVersion > appVersion) { + const storesConfig: CoreStoreConfig = { + android: config.tool_mobile_androidappid || false, + ios: config.tool_mobile_iosappid || false, + desktop: config.tool_mobile_setuplink || 'https://download.moodle.org/desktop/', + mobile: config.tool_mobile_setuplink || 'https://download.moodle.org/mobile/', + default: config.tool_mobile_setuplink, + }; + + const downloadUrl = CoreApp.instance.getAppStoreUrl(storesConfig); + + siteId = siteId || this.getCurrentSiteId(); + + // Do not block interface. + CoreDomUtils.instance.showConfirm( + Translate.instance.instant('core.updaterequireddesc', { $a: config.tool_mobile_minimumversion }), + Translate.instance.instant('core.updaterequired'), + Translate.instance.instant('core.download'), + Translate.instance.instant(siteId ? 'core.mainmenu.logout' : 'core.cancel')).then(() => { + + CoreUtils.instance.openInBrowser(downloadUrl); + }).catch(() => { + // Do nothing. + }); + + if (siteId) { + // Logout if it's the currentSite. + const promise = siteId == this.getCurrentSiteId() ? this.logout() : Promise.resolve(); + + return promise.then(() => { + // Always expire the token. + return this.setSiteLoggedOut(siteId, true); + }).then(() => { + return Promise.reject(null); + }); + } + + return Promise.reject(null); + } + } + + return Promise.resolve(); + } + + /** + * Convert version name to numbers. + * + * @param name Version name (dot separated). + * @return Version translated to a comparable number. + */ + protected convertVersionName(name: string): number { + let version = 0; + + const parts = name.split('-')[0].split('.', 3); + parts.forEach((num) => { + version = (version * 100) + Number(num); + }); + + if (parts.length < 3) { + version = version * Math.pow(100, 3 - parts.length); + } + + return version; + } + + /** + * Login a user to a site from the list of sites. + * + * @param siteId ID of the site to load. + * @param pageName Name of the page to go once authenticated if logged out. If not defined, site initial page. + * @param params Params of the page to go once authenticated if logged out. + * @return Promise resolved with true if site is loaded, resolved with false if cannot login. + */ + loadSite(siteId: string, pageName?: string, params?: any): Promise { + this.logger.debug(`Load site ${siteId}`); + + return this.getSite(siteId).then((site) => { + this.currentSite = site; + + if (site.isLoggedOut()) { + // Logged out, trigger session expired event and stop. + CoreEvents.instance.trigger(CoreEventsProvider.SESSION_EXPIRED, { + pageName, + params, + }, site.getId()); + + return false; + } + + // Check if local_mobile was installed to Moodle. + return site.checkIfLocalMobileInstalledAndNotUsed().then(() => { + // Local mobile was added. Throw invalid session to force reconnect and create a new token. + CoreEvents.instance.trigger(CoreEventsProvider.SESSION_EXPIRED, { + pageName, + params, + }, siteId); + + return false; + }, () => { + return site.getPublicConfig().catch(() => { + return {}; + }).then((config) => { + return this.checkRequiredMinimumVersion(config).then(() => { + this.login(siteId); + + // Update site info. We don't block the UI. + this.updateSiteInfo(siteId); + + return true; + }).catch(() => { + return false; + }); + }); + }); + }); + } + + /** + * Get current site. + * + * @return Current site. + */ + getCurrentSite(): CoreSite { + return this.currentSite; + } + + /** + * Get the site home ID of the current site. + * + * @return Current site home ID. + */ + getCurrentSiteHomeId(): number { + if (this.currentSite) { + return this.currentSite.getSiteHomeId(); + } else { + return 1; + } + } + + /** + * Get current site ID. + * + * @return Current site ID. + */ + getCurrentSiteId(): string { + if (this.currentSite) { + return this.currentSite.getId(); + } else { + return ''; + } + } + + /** + * Get current site User ID. + * + * @return Current site User ID. + */ + getCurrentSiteUserId(): number { + if (this.currentSite) { + return this.currentSite.getUserId(); + } else { + return 0; + } + } + + /** + * Check if the user is logged in a site. + * + * @return Whether the user is logged in a site. + */ + isLoggedIn(): boolean { + return typeof this.currentSite != 'undefined' && typeof this.currentSite.token != 'undefined' && + this.currentSite.token != ''; + } + + /** + * Delete a site from the sites list. + * + * @param siteId ID of the site to delete. + * @return Promise to be resolved when the site is deleted. + */ + async deleteSite(siteId: string): Promise { + await this.dbReady; + + this.logger.debug(`Delete site ${siteId}`); + + if (typeof this.currentSite != 'undefined' && this.currentSite.id == siteId) { + this.logout(); + } + + const site = await this.getSite(siteId); + + await site.deleteDB(); + + // Site DB deleted, now delete the app from the list of sites. + delete this.sites[siteId]; + + try { + await this.appDB.deleteRecords(CoreSitesProvider.SITES_TABLE, { id: siteId }); + } catch (err) { + // DB remove shouldn't fail, but we'll go ahead even if it does. + } + + // Site deleted from sites list, now delete the folder. + await site.deleteFolder(); + + CoreEvents.instance.trigger(CoreEventsProvider.SITE_DELETED, site, siteId); + } + + /** + * Check if there are sites stored. + * + * @return Promise resolved with true if there are sites and false if there aren't. + */ + async hasSites(): Promise { + await this.dbReady; + + const count = await this.appDB.countRecords(CoreSitesProvider.SITES_TABLE); + + return count > 0; + } + + /** + * Returns a site object. + * + * @param siteId The site ID. If not defined, current site (if available). + * @return Promise resolved with the site. + */ + async getSite(siteId?: string): Promise { + await this.dbReady; + + if (!siteId) { + if (this.currentSite) { + return this.currentSite; + } + + throw null; + } else if (this.currentSite && this.currentSite.getId() == siteId) { + return this.currentSite; + } else if (typeof this.sites[siteId] != 'undefined') { + return this.sites[siteId]; + } else { + // Retrieve and create the site. + const data = await this.appDB.getRecord(CoreSitesProvider.SITES_TABLE, { id: siteId }); + + return this.makeSiteFromSiteListEntry(data); + } + } + + /** + * Create a site from an entry of the sites list DB. The new site is added to the list of "cached" sites: this.sites. + * + * @param entry Site list entry. + * @return Promised resolved with the created site. + */ + makeSiteFromSiteListEntry(entry: any): Promise { + let info = entry.info; + let config = entry.config; + + // Parse info and config. + info = info ? CoreTextUtils.instance.parseJSON(info) : info; + config = config ? CoreTextUtils.instance.parseJSON(config) : config; + + const site = new CoreSite(entry.id, entry.siteUrl, entry.token, + info, entry.privateToken, config, entry.loggedOut == 1); + site.setOAuthId(entry.oauthId); + + return this.migrateSiteSchemas(site).then(() => { + // Set site after migrating schemas, or a call to getSite could get the site while tables are being created. + this.sites[entry.id] = site; + + return site; + }); + } + + /** + * Returns if the site is the current one. + * + * @param site Site object or siteId to be compared. If not defined, use current site. + * @return Whether site or siteId is the current one. + */ + isCurrentSite(site: string | CoreSite): boolean { + if (!site || !this.currentSite) { + return !!this.currentSite; + } + + const siteId = typeof site == 'object' ? site.getId() : site; + + return this.currentSite.getId() === siteId; + } + + /** + * Returns the database object of a site. + * + * @param siteId The site ID. If not defined, current site (if available). + * @return Promise resolved with the database. + */ + getSiteDb(siteId: string): Promise { + return this.getSite(siteId).then((site) => { + return site.getDb(); + }); + } + + /** + * Returns the site home ID of a site. + * + * @param siteId The site ID. If not defined, current site (if available). + * @return Promise resolved with site home ID. + */ + getSiteHomeId(siteId?: string): Promise { + return this.getSite(siteId).then((site) => { + return site.getSiteHomeId(); + }); + } + + /** + * Get the list of sites stored. + * + * @param ids IDs of the sites to get. If not defined, return all sites. + * @return Promise resolved when the sites are retrieved. + */ + async getSites(ids?: string[]): Promise { + await this.dbReady; + + const sites = await this.appDB.getAllRecords(CoreSitesProvider.SITES_TABLE); + + const formattedSites = []; + sites.forEach((site) => { + if (!ids || ids.indexOf(site.id) > -1) { + // Parse info. + const siteInfo = site.info ? CoreTextUtils.instance.parseJSON(site.info) : site.info; + const basicInfo: CoreSiteBasicInfo = { + id: site.id, + siteUrl: site.siteUrl, + fullName: siteInfo && siteInfo.fullname, + siteName: CoreConfigConstants.sitename ? CoreConfigConstants.sitename : siteInfo && siteInfo.sitename, + avatar: siteInfo && siteInfo.userpictureurl, + siteHomeId: siteInfo && siteInfo.siteid || 1, + }; + formattedSites.push(basicInfo); + } + }); + + return formattedSites; + } + + /** + * Get the list of sites stored, sorted by URL and full name. + * + * @param ids IDs of the sites to get. If not defined, return all sites. + * @return Promise resolved when the sites are retrieved. + */ + getSortedSites(ids?: string[]): Promise { + return this.getSites(ids).then((sites) => { + // Sort sites by url and ful lname. + sites.sort((a, b) => { + // First compare by site url without the protocol. + let compareA = a.siteUrl.replace(/^https?:\/\//, '').toLowerCase(); + let compareB = b.siteUrl.replace(/^https?:\/\//, '').toLowerCase(); + const compare = compareA.localeCompare(compareB); + + if (compare !== 0) { + return compare; + } + + // If site url is the same, use fullname instead. + compareA = a.fullName.toLowerCase().trim(); + compareB = b.fullName.toLowerCase().trim(); + + return compareA.localeCompare(compareB); + }); + + return sites; + }); + } + + /** + * Get the list of IDs of sites stored and not logged out. + * + * @return Promise resolved when the sites IDs are retrieved. + */ + async getLoggedInSitesIds(): Promise { + await this.dbReady; + + const sites = await this.appDB.getRecords(CoreSitesProvider.SITES_TABLE, {loggedOut : 0}); + + return sites.map((site) => { + return site.id; + }); + } + + /** + * Get the list of IDs of sites stored. + * + * @return Promise resolved when the sites IDs are retrieved. + */ + async getSitesIds(): Promise { + await this.dbReady; + + const sites = await this.appDB.getAllRecords(CoreSitesProvider.SITES_TABLE); + + return sites.map((site) => { + return site.id; + }); + } + + /** + * Login the user in a site. + * + * @param siteid ID of the site the user is accessing. + * @return Promise resolved when current site is stored. + */ + async login(siteId: string): Promise { + await this.dbReady; + + const entry = { + id: 1, + siteId, + }; + + await this.appDB.insertRecord(CoreSitesProvider.CURRENT_SITE_TABLE, entry); + + CoreEvents.instance.trigger(CoreEventsProvider.LOGIN, {}, siteId); + } + + /** + * Logout the user. + * + * @return Promise resolved when the user is logged out. + */ + async logout(): Promise { + await this.dbReady; + + let siteId; + const promises = []; + + if (this.currentSite) { + const siteConfig = this.currentSite.getStoredConfig(); + siteId = this.currentSite.getId(); + + this.currentSite = undefined; + + if (siteConfig && siteConfig.tool_mobile_forcelogout == '1') { + promises.push(this.setSiteLoggedOut(siteId, true)); + } + + promises.push(this.appDB.deleteRecords(CoreSitesProvider.CURRENT_SITE_TABLE, { id: 1 })); + } + + try { + await Promise.all(promises); + } finally { + CoreEvents.instance.trigger(CoreEventsProvider.LOGOUT, {}, siteId); + } + } + + /** + * Restores the session to the previous one so the user doesn't has to login everytime the app is started. + * + * @return Promise resolved if a session is restored. + */ + async restoreSession(): Promise { + if (this.sessionRestored) { + return Promise.reject(null); + } + + await this.dbReady; + + this.sessionRestored = true; + + try { + const currentSite = await this.appDB.getRecord(CoreSitesProvider.CURRENT_SITE_TABLE, { id: 1 }); + const siteId = currentSite.siteId; + this.logger.debug(`Restore session in site ${siteId}`); + + return this.loadSite(siteId); + } catch (err) { + // No current session. + } + } + + /** + * Mark or unmark a site as logged out so the user needs to authenticate again. + * + * @param siteId ID of the site. + * @param loggedOut True to set the site as logged out, false otherwise. + * @return Promise resolved when done. + */ + async setSiteLoggedOut(siteId: string, loggedOut: boolean): Promise { + await this.dbReady; + + const site = await this.getSite(siteId); + const newValues = { + token: '', // Erase the token for security. + loggedOut: loggedOut ? 1 : 0 + }; + + site.setLoggedOut(loggedOut); + + return this.appDB.updateRecords(CoreSitesProvider.SITES_TABLE, newValues, { id: siteId }); + } + + /** + * Unset current site. + */ + unsetCurrentSite(): void { + this.currentSite = undefined; + } + + /** + * Updates a site's token. + * + * @param siteUrl Site's URL. + * @param username Username. + * @param token User's new token. + * @param privateToken User's private token. + * @return A promise resolved when the site is updated. + */ + updateSiteToken(siteUrl: string, username: string, token: string, privateToken: string = ''): Promise { + const siteId = this.createSiteID(siteUrl, username); + + return this.updateSiteTokenBySiteId(siteId, token, privateToken).then(() => { + return this.login(siteId); + }); + } + + /** + * Updates a site's token using siteId. + * + * @param siteId Site Id. + * @param token User's new token. + * @param privateToken User's private token. + * @return A promise resolved when the site is updated. + */ + async updateSiteTokenBySiteId(siteId: string, token: string, privateToken: string = ''): Promise { + await this.dbReady; + + const site = await this.getSite(siteId); + const newValues = { + token, + privateToken, + loggedOut: 0, + }; + + site.token = token; + site.privateToken = privateToken; + site.setLoggedOut(false); // Token updated means the user authenticated again, not logged out anymore. + + return this.appDB.updateRecords(CoreSitesProvider.SITES_TABLE, newValues, { id: siteId }); + } + + /** + * Updates a site's info. + * + * @param siteid Site's ID. + * @return A promise resolved when the site is updated. + */ + async updateSiteInfo(siteId: string): Promise { + await this.dbReady; + + const site = await this.getSite(siteId); + + try { + + const info = await site.fetchSiteInfo(); + site.setInfo(info); + + const versionCheck = this.isValidMoodleVersion(info); + if (versionCheck != this.VALID_VERSION) { + // The Moodle version is not supported, reject. + return this.treatInvalidAppVersion(versionCheck, site.getURL(), site.getId()); + } + + // Try to get the site config. + let config; + + try { + config = await this.getSiteConfig(site); + } catch (error) { + // Error getting config, keep the current one. + } + + const newValues: any = { + info: JSON.stringify(info), + loggedOut: site.isLoggedOut() ? 1 : 0, + }; + + if (typeof config != 'undefined') { + site.setConfig(config); + newValues.config = JSON.stringify(config); + } + + try { + await this.appDB.updateRecords(CoreSitesProvider.SITES_TABLE, newValues, { id: siteId }); + } finally { + CoreEvents.instance.trigger(CoreEventsProvider.SITE_UPDATED, info, siteId); + } + } catch (error) { + // Ignore that we cannot fetch site info. Probably the auth token is invalid. + } + } + + /** + * Updates a site's info. + * + * @param siteUrl Site's URL. + * @param username Username. + * @return A promise to be resolved when the site is updated. + */ + updateSiteInfoByUrl(siteUrl: string, username: string): Promise { + const siteId = this.createSiteID(siteUrl, username); + + return this.updateSiteInfo(siteId); + } + + /** + * Get the site IDs a URL belongs to. + * Someone can have more than one account in the same site, that's why this function returns an array of IDs. + * + * @param url URL to check. + * @param prioritize True if it should prioritize current site. If the URL belongs to current site then it won't + * check any other site, it will only return current site. + * @param username If set, it will return only the sites where the current user has this username. + * @return Promise resolved with the site IDs (array). + */ + async getSiteIdsFromUrl(url: string, prioritize?: boolean, username?: string): Promise { + await this.dbReady; + + // If prioritize is true, check current site first. + if (prioritize && this.currentSite && this.currentSite.containsUrl(url)) { + if (!username || this.currentSite.getInfo().username == username) { + return [this.currentSite.getId()]; + } + } + + // Check if URL has http(s) protocol. + if (!url.match(/^https?:\/\//i)) { + // URL doesn't have http(s) protocol. Check if it has any protocol. + if (CoreUrlUtils.instance.isAbsoluteURL(url)) { + // It has some protocol. Return empty array. + return []; + } else { + // No protocol, probably a relative URL. Return current site. + if (this.currentSite) { + return [this.currentSite.getId()]; + } else { + return []; + } + } + } + + try { + const siteEntries = await this.appDB.getAllRecords(CoreSitesProvider.SITES_TABLE); + const ids = []; + const promises = []; + + siteEntries.forEach((site) => { + if (!this.sites[site.id]) { + promises.push(this.makeSiteFromSiteListEntry(site)); + } + + if (this.sites[site.id].containsUrl(url)) { + if (!username || this.sites[site.id].getInfo().username == username) { + ids.push(site.id); + } + } + }); + + await Promise.all(promises); + + return ids; + } catch (error) { + // Shouldn't happen. + return []; + } + } + + /** + * Get the site ID stored in DB as current site. + * + * @return Promise resolved with the site ID. + */ + async getStoredCurrentSiteId(): Promise { + await this.dbReady; + + const currentSite = await this.appDB.getRecord(CoreSitesProvider.CURRENT_SITE_TABLE, { id: 1 }); + + return currentSite.siteId; + } + + /** + * Get the public config of a certain site. + * + * @param siteUrl URL of the site. + * @return Promise resolved with the public config. + */ + getSitePublicConfig(siteUrl: string): Promise { + const temporarySite = new CoreSite(undefined, siteUrl); + + return temporarySite.getPublicConfig(); + } + + /** + * Get site config. + * + * @param site The site to get the config. + * @return Promise resolved with config if available. + */ + protected getSiteConfig(site: CoreSite): Promise { + if (!site.wsAvailable('tool_mobile_get_config')) { + // WS not available, cannot get config. + return Promise.resolve(); + } + + return site.getConfig(undefined, true); + } + + /** + * Check if a certain feature is disabled in a site. + * + * @param name Name of the feature to check. + * @param siteId The site ID. If not defined, current site (if available). + * @return Promise resolved with true if disabled. + */ + isFeatureDisabled(name: string, siteId?: string): Promise { + return this.getSite(siteId).then((site) => { + return site.isFeatureDisabled(name); + }); + } + + /** + * Create a table in all the sites databases. + * + * @param table Table schema. + * @deprecated. Please use registerSiteSchema instead. + */ + createTableFromSchema(table: SQLiteDBTableSchema): void { + this.createTablesFromSchema([table]); + } + + /** + * Create several tables in all the sites databases. + * + * @param tables List of tables schema. + * @deprecated. Please use registerSiteSchema instead. + */ + createTablesFromSchema(tables: SQLiteDBTableSchema[]): void { + // Add the tables to the list of schemas. This list is to create all the tables in new sites. + this.siteTablesSchemas = this.siteTablesSchemas.concat(tables); + + // Now create these tables in current sites. + for (const id in this.sites) { + this.sites[id].getDb().createTablesFromSchema(tables); + } + } + + /** + * Check if a WS is available in the current site, if any. + * + * @param method WS name. + * @param checkPrefix When true also checks with the compatibility prefix. + * @return Whether the WS is available. + */ + wsAvailableInCurrentSite(method: string, checkPrefix: boolean = true): boolean { + const site = this.getCurrentSite(); + + return site && site.wsAvailable(method, checkPrefix); + } + + /** + * Check if a site is a legacy site by its info. + * + * @param info The site info. + * @return Whether it's a legacy Moodle. + * @deprecated since 3.7.1 + */ + isLegacyMoodleByInfo(info: any): boolean { + return false; + } + + /** + * Register a site schema. + * + * @param schema The schema to register. + * @return Promise resolved when done. + */ + async registerSiteSchema(schema: CoreSiteSchema): Promise { + if (this.currentSite) { + try { + // Site has already been created, apply the schema directly. + const schemas: {[name: string]: CoreRegisteredSiteSchema} = {}; + schemas[schema.name] = schema; + + if (!schema.onlyCurrentSite) { + // Apply it to all sites. + const siteIds = await this.getSitesIds(); + + await Promise.all(siteIds.map(async (siteId) => { + const site = await this.getSite(siteId); + + return this.applySiteSchemas(site, schemas); + })); + } else { + // Apply it to the specified site only. + (schema as CoreRegisteredSiteSchema).siteId = this.currentSite.getId(); + + await this.applySiteSchemas(this.currentSite, schemas); + } + } finally { + // Add the schema to the list. It's done in the end to prevent a schema being applied twice. + this.siteSchemas[schema.name] = schema; + } + + } else if (!schema.onlyCurrentSite) { + // Add the schema to the list, it will be applied when the sites are created. + this.siteSchemas[schema.name] = schema; + } + } + + /** + * Install and upgrade all the registered schemas and tables. + * + * @param site Site. + * @return Promise resolved when done. + */ + migrateSiteSchemas(site: CoreSite): Promise { + + if (this.siteSchemasMigration[site.id]) { + return this.siteSchemasMigration[site.id]; + } + + this.logger.debug(`Migrating all schemas of ${site.id}`); + + // First create tables not registerd with name/version. + const promise = site.getDb().createTablesFromSchema(this.siteTablesSchemas).then(() => { + return this.applySiteSchemas(site, this.siteSchemas); + }); + + this.siteSchemasMigration[site.id] = promise; + + return promise.finally(() => { + delete this.siteSchemasMigration[site.id]; + }); + } + + /** + * Install and upgrade the supplied schemas for a certain site. + * + * @param site Site. + * @param schemas Schemas to migrate. + * @return Promise resolved when done. + */ + protected applySiteSchemas(site: CoreSite, schemas: {[name: string]: CoreRegisteredSiteSchema}): Promise { + const db = site.getDb(); + + // Fetch installed versions of the schema. + return db.getAllRecords(CoreSitesProvider.SCHEMA_VERSIONS_TABLE).then((records) => { + const versions = {}; + records.forEach((record) => { + versions[record.name] = record.version; + }); + + const promises = []; + for (const name in schemas) { + const schema = schemas[name]; + const oldVersion = versions[name] || 0; + if (oldVersion >= schema.version || (schema.siteId && site.getId() != schema.siteId)) { + // Version already applied or the schema shouldn't be registered to this site. + continue; + } + + this.logger.debug(`Migrating schema '${name}' of ${site.id} from version ${oldVersion} to ${schema.version}`); + + let promise: Promise = Promise.resolve(); + if (schema.tables) { + promise = promise.then(() => db.createTablesFromSchema(schema.tables)); + } + if (schema.migrate) { + promise = promise.then(() => schema.migrate(db, oldVersion, site.id)); + } + + // Set installed version. + promise = promise.then(() => db.insertRecord(CoreSitesProvider.SCHEMA_VERSIONS_TABLE, + {name, version: schema.version})); + + promises.push(promise); + } + + return Promise.all(promises); + }); + } + + /** + * Check if a URL is the root URL of any of the stored sites. + * + * @param url URL to check. + * @param username Username to check. + * @return Promise resolved with site to use and the list of sites that have + * the URL. Site will be undefined if it isn't the root URL of any stored site. + */ + isStoredRootURL(url: string, username?: string): Promise<{site: CoreSite, siteIds: string[]}> { + // Check if the site is stored. + return this.getSiteIdsFromUrl(url, true, username).then((siteIds) => { + const result = { + siteIds, + site: undefined, + }; + + if (siteIds.length > 0) { + // If more than one site is returned it usually means there are different users stored. Use any of them. + return this.getSite(siteIds[0]).then((site) => { + const siteUrl = CoreTextUtils.instance.removeEndingSlash( + CoreUrlUtils.instance.removeProtocolAndWWW(site.getURL())); + const treatedUrl = CoreTextUtils.instance.removeEndingSlash(CoreUrlUtils.instance.removeProtocolAndWWW(url)); + + if (siteUrl == treatedUrl) { + result.site = site; + } + + return result; + }); + } + + return result; + }); + } + + /** + * Returns the Site Schema names that can be cleared on space storage. + * + * @param site The site that will be cleared. + * @return Name of the site schemas. + */ + getSiteTableSchemasToClear(site: CoreSite): string[] { + let reset = []; + for (const name in this.siteSchemas) { + const schema = this.siteSchemas[name]; + + if (schema.canBeCleared && (!schema.siteId || site.getId() == schema.siteId)) { + reset = reset.concat(this.siteSchemas[name].canBeCleared); + } + } + + return reset; + } + + /** + * Returns presets for a given reading strategy. + * + * @param strategy Reading strategy. + * @return PreSets options object. + */ + getReadingStrategyPreSets(strategy: CoreSitesReadingStrategy): CoreSiteWSPreSets { + switch (strategy) { + case CoreSitesReadingStrategy.PreferCache: + return { + omitExpires: true, + }; + case CoreSitesReadingStrategy.OnlyCache: + return { + omitExpires: true, + forceOffline: true, + }; + case CoreSitesReadingStrategy.PreferNetwork: + return { + getFromCache: false, + }; + case CoreSitesReadingStrategy.OnlyNetwork: + return { + getFromCache: false, + emergencyCache: false, + }; + default: + return {}; + } + } + + /** + * Returns site info found on the backend. + * + * @param search Searched text. + * @return Site info list. + */ + async findSites(search: string): Promise { + return []; + } +} + +export class CoreSites extends makeSingleton(CoreSitesProvider) {} + +/** + * Response of checking if a site exists and its configuration. + */ +export type CoreSiteCheckResponse = { + /** + * Code to identify the authentication method to use. + */ + code: number; + + /** + * Site url to use (might have changed during the process). + */ + siteUrl: string; + + /** + * Service used. + */ + service: string; + + /** + * Code of the warning message to show to the user. + */ + warning?: string; + + /** + * Site public config (if available). + */ + config?: any; +}; + +/** + * Response of getting user token. + */ +export type CoreSiteUserTokenResponse = { + /** + * User token. + */ + token: string; + + /** + * Site URL to use. + */ + siteUrl: string; + + /** + * User private token. + */ + privateToken?: string; +}; + +/** + * Site's basic info. + */ +export type CoreSiteBasicInfo = { + /** + * Site ID. + */ + id: string; + + /** + * Site URL. + */ + siteUrl: string; + + /** + * User's full name. + */ + fullName: string; + + /** + * Site's name. + */ + siteName: string; + + /** + * User's avatar. + */ + avatar: string; + + /** + * Badge to display in the site. + */ + badge?: number; + + /** + * Site home ID. + */ + siteHomeId?: number; +}; + +/** + * Site schema and migration function. + */ +export type CoreSiteSchema = { + /** + * Name of the schema. + */ + name: string; + + /** + * Latest version of the schema (integer greater than 0). + */ + version: number; + + /** + * Names of the tables of the site schema that can be cleared. + */ + canBeCleared?: string[]; + + /** + * If true, the schema will only be applied to the current site. Otherwise it will be applied to all sites. + * If you're implementing a site plugin, please set it to true. + */ + onlyCurrentSite?: boolean; + + /** + * Tables to create when installing or upgrading the schema. + */ + tables?: SQLiteDBTableSchema[]; + + /** + * Migrates the schema in a site to the latest version. + * + * Called when installing and upgrading the schema, after creating the defined tables. + * + * @param db Site database. + * @param oldVersion Old version of the schema or 0 if not installed. + * @param siteId Site Id to migrate. + * @return Promise resolved when done. + */ + migrate?(db: SQLiteDB, oldVersion: number, siteId: string): Promise | void; +}; + +/** + * Data about sites to be listed. + */ +export type CoreLoginSiteInfo = { + /** + * Site name. + */ + name: string; + + /** + * Site alias. + */ + alias?: string; + + /** + * URL of the site. + */ + url: string; + + /** + * Image URL of the site. + */ + imageurl?: string; + + /** + * City of the site. + */ + city?: string; + + /** + * Countrycode of the site. + */ + countrycode?: string; +}; + +/** + * Registered site schema. + */ +export type CoreRegisteredSiteSchema = CoreSiteSchema & { + /** + * Site ID to apply the schema to. If not defined, all sites. + */ + siteId?: string; +}; + +/** + * Possible reading strategies (for cache). + */ +export const enum CoreSitesReadingStrategy { + OnlyCache, + PreferCache, + OnlyNetwork, + PreferNetwork, +} + +/** + * Common options used when calling a WS through CoreSite. + */ +export type CoreSitesCommonWSOptions = { + readingStrategy?: CoreSitesReadingStrategy; // Reading strategy. + siteId?: string; // Site ID. If not defined, current site. +}; diff --git a/src/app/services/sync.ts b/src/app/services/sync.ts new file mode 100644 index 000000000..1954a59e6 --- /dev/null +++ b/src/app/services/sync.ts @@ -0,0 +1,211 @@ +// (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 { CoreEvents, CoreEventsProvider } from '@services/events'; +import { CoreSites, CoreSiteSchema } from '@services/sites'; +import { makeSingleton } from '@singletons/core.singletons'; + +/* + * Service that provides some features regarding synchronization. +*/ +@Injectable() +export class CoreSyncProvider { + + // Variables for the database. + protected SYNC_TABLE = 'sync'; + protected siteSchema: CoreSiteSchema = { + name: 'CoreSyncProvider', + version: 1, + tables: [ + { + name: this.SYNC_TABLE, + columns: [ + { + name: 'component', + type: 'TEXT', + notNull: true + }, + { + name: 'id', + type: 'TEXT', + notNull: true + }, + { + name: 'time', + type: 'INTEGER' + }, + { + name: 'warnings', + type: 'TEXT' + } + ], + primaryKeys: ['component', 'id'] + } + ] + }; + + // Store blocked sync objects. + protected blockedItems: { [siteId: string]: { [blockId: string]: { [operation: string]: boolean } } } = {}; + + constructor() { + CoreSites.instance.registerSiteSchema(this.siteSchema); + + // Unblock all blocks on logout. + CoreEvents.instance.on(CoreEventsProvider.LOGOUT, (data) => { + this.clearAllBlocks(data.siteId); + }); + } + + /** + * Block a component and ID so it cannot be synchronized. + * + * @param component Component name. + * @param id Unique ID per component. + * @param operation Operation name. If not defined, a default text is used. + * @param siteId Site ID. If not defined, current site. + */ + blockOperation(component: string, id: string | number, operation?: string, siteId?: string): void { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const uniqueId = this.getUniqueSyncBlockId(component, id); + + if (!this.blockedItems[siteId]) { + this.blockedItems[siteId] = {}; + } + + if (!this.blockedItems[siteId][uniqueId]) { + this.blockedItems[siteId][uniqueId] = {}; + } + + operation = operation || '-'; + + this.blockedItems[siteId][uniqueId][operation] = true; + } + + /** + * Clear all blocks for a site or all sites. + * + * @param siteId If set, clear the blocked objects only for this site. Otherwise clear them for all sites. + */ + clearAllBlocks(siteId?: string): void { + if (siteId) { + delete this.blockedItems[siteId]; + } else { + this.blockedItems = {}; + } + } + + /** + * Clear all blocks for a certain component. + * + * @param component Component name. + * @param id Unique ID per component. + * @param siteId Site ID. If not defined, current site. + */ + clearBlocks(component: string, id: string | number, siteId?: string): void { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const uniqueId = this.getUniqueSyncBlockId(component, id); + if (this.blockedItems[siteId]) { + delete this.blockedItems[siteId][uniqueId]; + } + } + + /** + * Returns a sync record. + * @param component Component name. + * @param id Unique ID per component. + * @param siteId Site ID. If not defined, current site. + * @return Record if found or reject. + */ + getSyncRecord(component: string, id: string | number, siteId?: string): Promise { + return CoreSites.instance.getSiteDb(siteId).then((db) => { + return db.getRecord(this.SYNC_TABLE, { component: component, id: id }); + }); + } + + /** + * Inserts or Updates info of a sync record. + * @param component Component name. + * @param id Unique ID per component. + * @param data Data that updates the record. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with done. + */ + insertOrUpdateSyncRecord(component: string, id: string | number, data: any, siteId?: string): Promise { + return CoreSites.instance.getSiteDb(siteId).then((db) => { + data.component = component; + data.id = id; + + return db.insertRecord(this.SYNC_TABLE, data); + }); + } + + /** + * Convenience function to create unique identifiers for a component and id. + * + * @param component Component name. + * @param id Unique ID per component. + * @return Unique sync id. + */ + protected getUniqueSyncBlockId(component: string, id: string | number): string { + return component + '#' + id; + } + + /** + * Check if a component is blocked. + * One block can have different operations. Here we check how many operations are being blocking the object. + * + * @param component Component name. + * @param id Unique ID per component. + * @param siteId Site ID. If not defined, current site. + * @return Whether it's blocked. + */ + isBlocked(component: string, id: string | number, siteId?: string): boolean { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (!this.blockedItems[siteId]) { + return false; + } + + const uniqueId = this.getUniqueSyncBlockId(component, id); + if (!this.blockedItems[siteId][uniqueId]) { + return false; + } + + return Object.keys(this.blockedItems[siteId][uniqueId]).length > 0; + } + + /** + * Unblock an operation on a component and ID. + * + * @param component Component name. + * @param id Unique ID per component. + * @param operation Operation name. If not defined, a default text is used. + * @param siteId Site ID. If not defined, current site. + */ + unblockOperation(component: string, id: string | number, operation?: string, siteId?: string): void { + operation = operation || '-'; + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const uniqueId = this.getUniqueSyncBlockId(component, id); + + if (this.blockedItems[siteId] && this.blockedItems[siteId][uniqueId]) { + delete this.blockedItems[siteId][uniqueId][operation]; + } + } +} + +export class CoreSync extends makeSingleton(CoreSyncProvider) {} diff --git a/src/app/services/update-manager.ts b/src/app/services/update-manager.ts new file mode 100644 index 000000000..b751e5d6d --- /dev/null +++ b/src/app/services/update-manager.ts @@ -0,0 +1,68 @@ +// (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 { CoreConfig } from '@services/config'; +import { CoreInitHandler, CoreInitDelegate } from '@services/init'; +import CoreConfigConstants from '@app/config.json'; +import { makeSingleton } from '@singletons/core.singletons'; +import { CoreLogger } from '@singletons/logger'; + +/** + * Factory to handle app updates. This factory shouldn't be used outside of core. + * + * This service handles processes that need to be run when updating the app, like migrate Ionic 1 database data to Ionic 3. + */ +@Injectable() +export class CoreUpdateManagerProvider implements CoreInitHandler { + // Data for init delegate. + name = 'CoreUpdateManager'; + priority = CoreInitDelegate.MAX_RECOMMENDED_PRIORITY + 300; + blocking = true; + + protected VERSION_APPLIED = 'version_applied'; + protected logger: CoreLogger; + + constructor() { + this.logger = CoreLogger.getInstance('CoreUpdateManagerProvider'); + } + + /** + * Check if the app has been updated and performs the needed processes. + * This function shouldn't be used outside of core. + * + * @return Promise resolved when the update process finishes. + */ + async load(): Promise { + const promises = []; + const versionCode = CoreConfigConstants.versioncode; + + const versionApplied: number = await CoreConfig.instance.get(this.VERSION_APPLIED, 0); + + if (versionCode >= 3900 && versionApplied < 3900 && versionApplied > 0) { + // @todo: H5P update. + } + + try { + await Promise.all(promises); + + await CoreConfig.instance.set(this.VERSION_APPLIED, versionCode); + } catch (error) { + this.logger.error(`Error applying update from ${versionApplied} to ${versionCode}`, error); + } + } +} + +export class CoreUpdateManager extends makeSingleton(CoreUpdateManagerProvider) {} diff --git a/src/app/services/utils/dom.ts b/src/app/services/utils/dom.ts new file mode 100644 index 000000000..a25a1df9a --- /dev/null +++ b/src/app/services/utils/dom.ts @@ -0,0 +1,1492 @@ +// (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, SimpleChange, ElementRef } from '@angular/core'; +import { DomSanitizer } from '@angular/platform-browser'; +import { Md5 } from 'ts-md5/dist/md5'; +import { Subject } from 'rxjs'; + +import { CoreApp } from '@services/app'; +import { CoreConfig } from '@services/config'; +import { CoreEvents, CoreEventsProvider } from '@services/events'; +import { CoreFile } from '@services/file'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreConstants } from '@core/constants'; + +import { makeSingleton, Translate } from '@singletons/core.singletons'; +import { CoreLogger } from '@singletons/logger'; + +/* + * "Utils" service with helper functions for UI, DOM elements and HTML code. + */ +@Injectable() +export class CoreDomUtilsProvider { + // List of input types that support keyboard. + protected INPUT_SUPPORT_KEYBOARD = ['date', 'datetime', 'datetime-local', 'email', 'month', 'number', 'password', + 'search', 'tel', 'text', 'time', 'url', 'week']; + protected INSTANCE_ID_ATTR_NAME = 'core-instance-id'; + + protected template = document.createElement('template'); // A template element to convert HTML to element. + + protected matchesFn: string; // Name of the "matches" function to use when simulating a closest call. + protected instances: {[id: string]: any} = {}; // Store component/directive instances by id. + protected lastInstanceId = 0; + protected debugDisplay = false; // Whether to display debug messages. Store it in a variable to make it synchronous. + protected displayedAlerts = {}; // To prevent duplicated alerts. + protected logger: CoreLogger; + + constructor(protected domSanitizer: DomSanitizer) { + + this.logger = CoreLogger.getInstance('CoreDomUtilsProvider'); + + // Check if debug messages should be displayed. + CoreConfig.instance.get(CoreConstants.SETTINGS_DEBUG_DISPLAY, false).then((debugDisplay) => { + this.debugDisplay = !!debugDisplay; + }); + } + + /** + * Equivalent to element.closest(). If the browser doesn't support element.closest, it will + * traverse the parents to achieve the same functionality. + * Returns the closest ancestor of the current element (or the current element itself) which matches the selector. + * + * @param element DOM Element. + * @param selector Selector to search. + * @return Closest ancestor. + */ + closest(element: Element, selector: string): Element { + // Try to use closest if the browser supports it. + if (typeof element.closest == 'function') { + return element.closest(selector); + } + + if (!this.matchesFn) { + // Find the matches function supported by the browser. + ['matches', 'webkitMatchesSelector', 'mozMatchesSelector', 'msMatchesSelector', 'oMatchesSelector'].some((fn) => { + if (typeof document.body[fn] == 'function') { + this.matchesFn = fn; + + return true; + } + + return false; + }); + + if (!this.matchesFn) { + return; + } + } + + // Traverse parents. + while (element) { + if (element[this.matchesFn](selector)) { + return element; + } + element = element.parentElement; + } + } + + /** + * If the download size is higher than a certain threshold shows a confirm dialog. + * + * @param size Object containing size to download and a boolean to indicate if its totally or partialy calculated. + * @param message Code of the message to show. Default: 'core.course.confirmdownload'. + * @param unknownMessage ID of the message to show if size is unknown. + * @param wifiThreshold Threshold to show confirm in WiFi connection. Default: CoreWifiDownloadThreshold. + * @param limitedThreshold Threshold to show confirm in limited connection. Default: CoreDownloadThreshold. + * @param alwaysConfirm True to show a confirm even if the size isn't high, false otherwise. + * @return Promise resolved when the user confirms or if no confirm needed. + */ + confirmDownloadSize(size: any, message?: string, unknownMessage?: string, wifiThreshold?: number, limitedThreshold?: number, + alwaysConfirm?: boolean): Promise { + const readableSize = CoreTextUtils.instance.bytesToSize(size.size, 2); + + const getAvailableBytes = new Promise((resolve): void => { + if (CoreApp.instance.isDesktop()) { + // Free space calculation is not supported on desktop. + resolve(null); + } else { + CoreFile.instance.calculateFreeSpace().then((availableBytes) => { + if (CoreApp.instance.isAndroid()) { + return availableBytes; + } else { + // Space calculation is not accurate on iOS, but it gets more accurate when space is lower. + // We'll only use it when space is <500MB, or we're downloading more than twice the reported space. + if (availableBytes < CoreConstants.IOS_FREE_SPACE_THRESHOLD || size.size > availableBytes / 2) { + return availableBytes; + } else { + return null; + } + } + }).then((availableBytes) => { + resolve(availableBytes); + }); + } + }); + + const getAvailableSpace = getAvailableBytes.then((availableBytes: number) => { + if (availableBytes === null) { + return ''; + } else { + const availableSize = CoreTextUtils.instance.bytesToSize(availableBytes, 2); + if (CoreApp.instance.isAndroid() && size.size > availableBytes - CoreConstants.MINIMUM_FREE_SPACE) { + return Promise.reject(Translate.instance.instant('core.course.insufficientavailablespace', + { size: readableSize })); + } + + return Translate.instance.instant('core.course.availablespace', {available: availableSize}); + } + }); + + return getAvailableSpace.then((availableSpace) => { + wifiThreshold = typeof wifiThreshold == 'undefined' ? CoreConstants.WIFI_DOWNLOAD_THRESHOLD : wifiThreshold; + limitedThreshold = typeof limitedThreshold == 'undefined' ? CoreConstants.DOWNLOAD_THRESHOLD : limitedThreshold; + + let wifiPrefix = ''; + if (CoreApp.instance.isNetworkAccessLimited()) { + wifiPrefix = Translate.instance.instant('core.course.confirmlimiteddownload'); + } + + if (size.size < 0 || (size.size == 0 && !size.total)) { + // Seems size was unable to be calculated. Show a warning. + unknownMessage = unknownMessage || 'core.course.confirmdownloadunknownsize'; + + return this.showConfirm(wifiPrefix + Translate.instance.instant(unknownMessage, {availableSpace: availableSpace})); + } else if (!size.total) { + // Filesize is only partial. + + return this.showConfirm(wifiPrefix + Translate.instance.instant('core.course.confirmpartialdownloadsize', + { size: readableSize, availableSpace: availableSpace })); + } else if (alwaysConfirm || size.size >= wifiThreshold || + (CoreApp.instance.isNetworkAccessLimited() && size.size >= limitedThreshold)) { + message = message || (size.size === 0 ? 'core.course.confirmdownloadzerosize' : 'core.course.confirmdownload'); + + return this.showConfirm(wifiPrefix + Translate.instance.instant(message, + { size: readableSize, availableSpace: availableSpace })); + } + + return Promise.resolve(); + }); + } + + /** + * Convert some HTML as text into an HTMLElement. This HTML is put inside a div or a body. + * + * @param html Text to convert. + * @return Element. + */ + convertToElement(html: string): HTMLElement { + // Add a div to hold the content, that's the element that will be returned. + this.template.innerHTML = '
' + html + '
'; + + return this.template.content.children[0]; + } + + /** + * Create a "cancelled" error. These errors won't display an error message in showErrorModal functions. + * + * @return The error object. + */ + createCanceledError(): any { + return {coreCanceled: true}; + } + + /** + * Given a list of changes for a component input detected by a KeyValueDiffers, create an object similar to the one + * passed to the ngOnChanges functions. + * + * @param changes Changes detected by KeyValueDiffer. + * @return Changes in a format like ngOnChanges. + */ + createChangesFromKeyValueDiff(changes: any): { [name: string]: SimpleChange } { + const newChanges: { [name: string]: SimpleChange } = {}; + + // Added items are considered first change. + changes.forEachAddedItem((item) => { + newChanges[item.key] = new SimpleChange(item.previousValue, item.currentValue, true); + }); + + // Changed or removed items aren't first change. + changes.forEachChangedItem((item) => { + newChanges[item.key] = new SimpleChange(item.previousValue, item.currentValue, false); + }); + changes.forEachRemovedItem((item) => { + newChanges[item.key] = new SimpleChange(item.previousValue, item.currentValue, true); + }); + + return newChanges; + } + + /** + * Extract the downloadable URLs from an HTML code. + * + * @param html HTML code. + * @return List of file urls. + * @deprecated since 3.8. Use CoreFilepoolProvider.extractDownloadableFilesFromHtml instead. + */ + extractDownloadableFilesFromHtml(html: string): string[] { + this.logger.error('The function extractDownloadableFilesFromHtml has been moved to CoreFilepoolProvider.' + + ' Please use that function instead of this one.'); + + const urls = []; + let elements; + + const element = this.convertToElement(html); + elements = element.querySelectorAll('a, img, audio, video, source, track'); + + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + let url = element.tagName === 'A' ? element.href : element.src; + + if (url && CoreUrlUtils.instance.isDownloadableUrl(url) && urls.indexOf(url) == -1) { + urls.push(url); + } + + // Treat video poster. + if (element.tagName == 'VIDEO' && element.getAttribute('poster')) { + url = element.getAttribute('poster'); + if (url && CoreUrlUtils.instance.isDownloadableUrl(url) && urls.indexOf(url) == -1) { + urls.push(url); + } + } + } + + return urls; + } + + /** + * Extract the downloadable URLs from an HTML code and returns them in fake file objects. + * + * @param html HTML code. + * @return List of fake file objects with file URLs. + * @deprecated since 3.8. Use CoreFilepoolProvider.extractDownloadableFilesFromHtmlAsFakeFileObjects instead. + */ + extractDownloadableFilesFromHtmlAsFakeFileObjects(html: string): any[] { + const urls = this.extractDownloadableFilesFromHtml(html); + + // Convert them to fake file objects. + return urls.map((url) => { + return { + fileurl: url + }; + }); + } + + /** + * Search all the URLs in a CSS file content. + * + * @param code CSS code. + * @return List of URLs. + */ + extractUrlsFromCSS(code: string): string[] { + // First of all, search all the url(...) occurrences that don't include "data:". + const urls = []; + const matches = code.match(/url\(\s*["']?(?!data:)([^)]+)\)/igm); + + if (!matches) { + return urls; + } + + // Extract the URL form each match. + matches.forEach((match) => { + const submatches = match.match(/url\(\s*['"]?([^'"]*)['"]?\s*\)/im); + if (submatches && submatches[1]) { + urls.push(submatches[1]); + } + }); + + return urls; + } + + /** + * Fix syntax errors in HTML. + * + * @param html HTML text. + * @return Fixed HTML text. + */ + fixHtml(html: string): string { + this.template.innerHTML = html; + + const attrNameRegExp = /[^\x00-\x20\x7F-\x9F"'>\/=]+/; + + const fixElement = (element: Element): void => { + // Remove attributes with an invalid name. + Array.from(element.attributes).forEach((attr) => { + if (!attrNameRegExp.test(attr.name)) { + element.removeAttributeNode(attr); + } + }); + + Array.from(element.children).forEach(fixElement); + }; + + Array.from(this.template.content.children).forEach(fixElement); + + return this.template.innerHTML; + } + + /** + * Focus an element and open keyboard. + * + * @param el HTML element to focus. + */ + focusElement(el: HTMLElement): void { + if (el && el.focus) { + el.focus(); + if (CoreApp.instance.isAndroid() && this.supportsInputKeyboard(el)) { + // On some Android versions the keyboard doesn't open automatically. + CoreApp.instance.openKeyboard(); + } + } + } + + /** + * Formats a size to be used as width/height of an element. + * If the size is already valid (like '500px' or '50%') it won't be modified. + * Returned size will have a format like '500px'. + * + * @param size Size to format. + * @return Formatted size. If size is not valid, returns an empty string. + */ + formatPixelsSize(size: any): string { + if (typeof size == 'string' && (size.indexOf('px') > -1 || size.indexOf('%') > -1 || size == 'auto' || size == 'initial')) { + // It seems to be a valid size. + return size; + } + + size = parseInt(size, 10); + if (!isNaN(size)) { + return size + 'px'; + } + + return ''; + } + + /** + * Returns the contents of a certain selection in a DOM element. + * + * @param element DOM element to search in. + * @param selector Selector to search. + * @return Selection contents. Undefined if not found. + */ + getContentsOfElement(element: HTMLElement, selector: string): string { + if (element) { + const selected = element.querySelector(selector); + if (selected) { + return selected.innerHTML; + } + } + } + + /** + * Get the data from a form. It will only collect elements that have a name. + * + * @param form The form to get the data from. + * @return Object with the data. The keys are the names of the inputs. + */ + getDataFromForm(form: HTMLFormElement): any { + if (!form || !form.elements) { + return {}; + } + + const data = {}; + + for (let i = 0; i < form.elements.length; i++) { + const element: any = form.elements[i]; + const name = element.name || ''; + + // Ignore submit inputs. + if (!name || element.type == 'submit' || element.tagName == 'BUTTON') { + continue; + } + + // Get the value. + if (element.type == 'checkbox') { + data[name] = !!element.checked; + } else if (element.type == 'radio') { + if (element.checked) { + data[name] = element.value; + } + } else { + data[name] = element.value; + } + } + + return data; + } + + /** + * Returns the attribute value of a string element. Only the first element will be selected. + * + * @param html HTML element in string. + * @param attribute Attribute to get. + * @return Attribute value. + */ + getHTMLElementAttribute(html: string, attribute: string): string { + return this.convertToElement(html).children[0].getAttribute('src'); + } + + /** + * Returns height of an element. + * + * @param element DOM element to measure. + * @param usePadding Whether to use padding to calculate the measure. + * @param useMargin Whether to use margin to calculate the measure. + * @param useBorder Whether to use borders to calculate the measure. + * @param innerMeasure If inner measure is needed: padding, margin or borders will be substracted. + * @return Height in pixels. + */ + getElementHeight(element: any, usePadding?: boolean, useMargin?: boolean, useBorder?: boolean, + innerMeasure?: boolean): number { + return this.getElementMeasure(element, false, usePadding, useMargin, useBorder, innerMeasure); + } + + /** + * Returns height or width of an element. + * + * @param element DOM element to measure. + * @param getWidth Whether to get width or height. + * @param usePadding Whether to use padding to calculate the measure. + * @param useMargin Whether to use margin to calculate the measure. + * @param useBorder Whether to use borders to calculate the measure. + * @param innerMeasure If inner measure is needed: padding, margin or borders will be substracted. + * @return Measure in pixels. + */ + getElementMeasure(element: any, getWidth?: boolean, usePadding?: boolean, useMargin?: boolean, useBorder?: boolean, + innerMeasure?: boolean): number { + + const offsetMeasure = getWidth ? 'offsetWidth' : 'offsetHeight'; + const measureName = getWidth ? 'width' : 'height'; + const clientMeasure = getWidth ? 'clientWidth' : 'clientHeight'; + const priorSide = getWidth ? 'Left' : 'Top'; + const afterSide = getWidth ? 'Right' : 'Bottom'; + let measure = element[offsetMeasure] || element[measureName] || element[clientMeasure] || 0; + + // Measure not correctly taken. + if (measure <= 0) { + const style = getComputedStyle(element); + if (style && style.display == '') { + element.style.display = 'inline-block'; + measure = element[offsetMeasure] || element[measureName] || element[clientMeasure] || 0; + element.style.display = ''; + } + } + + if (usePadding || useMargin || useBorder) { + const computedStyle = getComputedStyle(element); + let surround = 0; + + if (usePadding) { + surround += this.getComputedStyleMeasure(computedStyle, 'padding' + priorSide) + + this.getComputedStyleMeasure(computedStyle, 'padding' + afterSide); + } + if (useMargin) { + surround += this.getComputedStyleMeasure(computedStyle, 'margin' + priorSide) + + this.getComputedStyleMeasure(computedStyle, 'margin' + afterSide); + } + if (useBorder) { + surround += this.getComputedStyleMeasure(computedStyle, 'border' + priorSide + 'Width') + + this.getComputedStyleMeasure(computedStyle, 'border' + afterSide + 'Width'); + } + if (innerMeasure) { + measure = measure > surround ? measure - surround : 0; + } else { + measure += surround; + } + } + + return measure; + } + + /** + * Returns the computed style measure or 0 if not found or NaN. + * + * @param style Style from getComputedStyle. + * @param measure Measure to get. + * @return Result of the measure. + */ + getComputedStyleMeasure(style: any, measure: string): number { + return parseInt(style[measure], 10) || 0; + } + + /** + * Get the HTML code to render a connection warning icon. + * + * @return HTML Code. + */ + getConnectionWarningIconHtml(): string { + return '
' + + '' + + '' + + '
'; + } + + /** + * Returns width of an element. + * + * @param element DOM element to measure. + * @param usePadding Whether to use padding to calculate the measure. + * @param useMargin Whether to use margin to calculate the measure. + * @param useBorder Whether to use borders to calculate the measure. + * @param innerMeasure If inner measure is needed: padding, margin or borders will be substracted. + * @return Width in pixels. + */ + getElementWidth(element: any, usePadding?: boolean, useMargin?: boolean, useBorder?: boolean, + innerMeasure?: boolean): number { + return this.getElementMeasure(element, true, usePadding, useMargin, useBorder, innerMeasure); + } + + /** + * Retrieve the position of a element relative to another element. + * + * @param container Element to search in. + * @param selector Selector to find the element to gets the position. + * @param positionParentClass Parent Class where to stop calculating the position. Default scroll-content. + * @return positionLeft, positionTop of the element relative to. + */ + getElementXY(container: HTMLElement, selector?: string, positionParentClass?: string): number[] { + let element: HTMLElement = (selector ? container.querySelector(selector) : container); + let positionTop = 0; + let positionLeft = 0; + + if (!positionParentClass) { + positionParentClass = 'scroll-content'; + } + + if (!element) { + return null; + } + + while (element) { + positionLeft += (element.offsetLeft - element.scrollLeft + element.clientLeft); + positionTop += (element.offsetTop - element.scrollTop + element.clientTop); + + const offsetElement = element.offsetParent; + element = element.parentElement; + + // Every parent class has to be checked but the position has to be got form offsetParent. + while (offsetElement != element && element) { + // If positionParentClass element is reached, stop adding tops. + if (element.className.indexOf(positionParentClass) != -1) { + element = null; + } else { + element = element.parentElement; + } + } + + // Finally, check again. + if (element && element.className.indexOf(positionParentClass) != -1) { + element = null; + } + } + + return [positionLeft, positionTop]; + } + + /** + * Given an error message, return a suitable error title. + * + * @param message The error message. + * @return Title. + */ + private getErrorTitle(message: string): any { + if (message == Translate.instance.instant('core.networkerrormsg') || + message == Translate.instance.instant('core.fileuploader.errormustbeonlinetoupload')) { + + return this.domSanitizer.bypassSecurityTrustHtml(this.getConnectionWarningIconHtml()); + } + + return CoreTextUtils.instance.decodeHTML(Translate.instance.instant('core.error')); + } + + /** + * Get the error message from an error, including debug data if needed. + * + * @param error Message to show. + * @param needsTranslate Whether the error needs to be translated. + * @return Error message, null if no error should be displayed. + */ + getErrorMessage(error: any, needsTranslate?: boolean): string { + let extraInfo = ''; + + if (typeof error == 'object') { + if (this.debugDisplay) { + // Get the debug info. Escape the HTML so it is displayed as it is in the view. + if (error.debuginfo) { + extraInfo = '

' + CoreTextUtils.instance.escapeHTML(error.debuginfo, false); + } + if (error.backtrace) { + extraInfo += '

' + CoreTextUtils.instance.replaceNewLines( + CoreTextUtils.instance.escapeHTML(error.backtrace, false), '
'); + } + + // tslint:disable-next-line + console.error(error); + } + + // We received an object instead of a string. Search for common properties. + if (this.isCanceledError(error)) { + // It's a canceled error, don't display an error. + return null; + } + + error = CoreTextUtils.instance.getErrorMessageFromError(error); + if (!error) { + // No common properties found, just stringify it. + error = JSON.stringify(error); + extraInfo = ''; // No need to add extra info because it's already in the error. + } + + // Try to remove tokens from the contents. + const matches = error.match(/token"?[=|:]"?(\w*)/, ''); + if (matches && matches[1]) { + error = error.replace(new RegExp(matches[1], 'g'), 'secret'); + } + } + + if (error == CoreConstants.DONT_SHOW_ERROR) { + // The error shouldn't be shown, stop. + return null; + } + + let message = CoreTextUtils.instance.decodeHTML(needsTranslate ? Translate.instance.instant(error) : error); + + if (extraInfo) { + message += extraInfo; + } + + return message; + } + + /** + * Retrieve component/directive instance. + * Please use this function only if you cannot retrieve the instance using parent/child methods: ViewChild (or similar) + * or Angular's injection. + * + * @param element The root element of the component/directive. + * @return The instance, undefined if not found. + */ + getInstanceByElement(element: Element): any { + const id = element.getAttribute(this.INSTANCE_ID_ATTR_NAME); + + return this.instances[id]; + } + + /** + * Check whether an error is an error caused because the user canceled a showConfirm. + * + * @param error Error to check. + * @return Whether it's a canceled error. + */ + isCanceledError(error: any): boolean { + return error && error.coreCanceled; + } + + /** + * Wait an element to exists using the findFunction. + * + * @param findFunction The function used to find the element. + * @return Resolved if found, rejected if too many tries. + */ + waitElementToExist(findFunction: () => HTMLElement): Promise { + const promiseInterval = { + promise: null, + resolve: null, + reject: null + }; + + let tries = 100; + + promiseInterval.promise = new Promise((resolve, reject): void => { + promiseInterval.resolve = resolve; + promiseInterval.reject = reject; + }); + + const clear = setInterval(() => { + const element: HTMLElement = findFunction(); + + if (element) { + clearInterval(clear); + promiseInterval.resolve(element); + } else { + tries--; + + if (tries <= 0) { + clearInterval(clear); + promiseInterval.reject(); + } + } + }, 100); + + return promiseInterval.promise; + } + + /** + * Handle bootstrap tooltips in a certain element. + * + * @param element Element to check. + */ + handleBootstrapTooltips(element: HTMLElement): void { + const els = Array.from(element.querySelectorAll('[data-toggle="tooltip"]')); + + els.forEach((el) => { + const content = el.getAttribute('title') || el.getAttribute('data-original-title'); + const trigger = el.getAttribute('data-trigger') || 'hover focus'; + const treated = el.getAttribute('data-bstooltip-treated'); + + if (!content || treated === 'true' || + (trigger.indexOf('hover') == -1 && trigger.indexOf('focus') == -1 && trigger.indexOf('click') == -1)) { + return; + } + + el.setAttribute('data-bstooltip-treated', 'true'); // Mark it as treated. + + // Store the title in data-original-title instead of title, like BS does. + el.setAttribute('data-original-title', content); + el.setAttribute('title', ''); + + el.addEventListener('click', (e) => { + // @todo + }); + }); + } + + /** + * Check if an element is outside of screen (viewport). + * + * @param scrollEl The element that must be scrolled. + * @param element DOM element to check. + * @return Whether the element is outside of the viewport. + */ + isElementOutsideOfScreen(scrollEl: HTMLElement, element: HTMLElement): boolean { + const elementRect = element.getBoundingClientRect(); + + if (!elementRect) { + return false; + } + + const elementMidPoint = Math.round((elementRect.bottom + elementRect.top) / 2); + + const scrollElRect = scrollEl.getBoundingClientRect(); + const scrollTopPos = (scrollElRect && scrollElRect.top) || 0; + + return elementMidPoint > window.innerHeight || elementMidPoint < scrollTopPos; + } + + /** + * Check if rich text editor is enabled. + * + * @return Promise resolved with boolean: true if enabled, false otherwise. + */ + isRichTextEditorEnabled(): Promise { + if (this.isRichTextEditorSupported()) { + return CoreConfig.instance.get(CoreConstants.SETTINGS_RICH_TEXT_EDITOR, true).then((enabled) => { + return !!enabled; + }); + } + + return Promise.resolve(false); + } + + /** + * Check if rich text editor is supported in the platform. + * + * @return Whether it's supported. + */ + isRichTextEditorSupported(): boolean { + return true; + } + + /** + * Move children from one HTMLElement to another. + * + * @param oldParent The old parent. + * @param newParent The new parent. + * @param prepend If true, adds the children to the beginning of the new parent. + * @return List of moved children. + */ + moveChildren(oldParent: HTMLElement, newParent: HTMLElement, prepend?: boolean): Node[] { + const movedChildren: Node[] = []; + const referenceNode = prepend ? newParent.firstChild : null; + + while (oldParent.childNodes.length > 0) { + const child = oldParent.childNodes[0]; + movedChildren.push(child); + + newParent.insertBefore(child, referenceNode); + } + + return movedChildren; + } + + /** + * Search and remove a certain element from inside another element. + * + * @param element DOM element to search in. + * @param selector Selector to search. + */ + removeElement(element: HTMLElement, selector: string): void { + if (element) { + const selected = element.querySelector(selector); + if (selected) { + selected.remove(); + } + } + } + + /** + * Search and remove a certain element from an HTML code. + * + * @param html HTML code to change. + * @param selector Selector to search. + * @param removeAll True if it should remove all matches found, false if it should only remove the first one. + * @return HTML without the element. + */ + removeElementFromHtml(html: string, selector: string, removeAll?: boolean): string { + let selected; + + const element = this.convertToElement(html); + + if (removeAll) { + selected = element.querySelectorAll(selector); + for (let i = 0; i < selected.length; i++) { + selected[i].remove(); + } + } else { + selected = element.querySelector(selector); + if (selected) { + selected.remove(); + } + } + + return element.innerHTML; + } + + /** + * Remove a component/directive instance using the DOM Element. + * + * @param element The root element of the component/directive. + */ + removeInstanceByElement(element: Element): void { + const id = element.getAttribute(this.INSTANCE_ID_ATTR_NAME); + delete this.instances[id]; + } + + /** + * Remove a component/directive instance using the ID. + * + * @param id The ID to remove. + */ + removeInstanceById(id: string): void { + delete this.instances[id]; + } + + /** + * Search for certain classes in an element contents and replace them with the specified new values. + * + * @param element DOM element. + * @param map Mapping of the classes to replace. Keys must be the value to replace, values must be + * the new class name. Example: {'correct': 'core-question-answer-correct'}. + */ + replaceClassesInElement(element: HTMLElement, map: any): void { + for (const key in map) { + const foundElements = element.querySelectorAll('.' + key); + + for (let i = 0; i < foundElements.length; i++) { + const foundElement = foundElements[i]; + foundElement.className = foundElement.className.replace(key, map[key]); + } + } + } + + /** + * Given an HTML, search all links and media and tries to restore original sources using the paths object. + * + * @param html HTML code. + * @param paths Object linking URLs in the html code with the real URLs to use. + * @param anchorFn Function to call with each anchor. Optional. + * @return Treated HTML code. + */ + restoreSourcesInHtml(html: string, paths: object, anchorFn?: (anchor: HTMLElement, href: string) => void): string { + const element = this.convertToElement(html); + + // Treat elements with src (img, audio, video, ...). + const media = Array.from(element.querySelectorAll('img, video, audio, source, track')); + media.forEach((media: HTMLElement) => { + let newSrc = paths[CoreTextUtils.instance.decodeURIComponent(media.getAttribute('src'))]; + + if (typeof newSrc != 'undefined') { + media.setAttribute('src', newSrc); + } + + // Treat video posters. + if (media.tagName == 'VIDEO' && media.getAttribute('poster')) { + newSrc = paths[CoreTextUtils.instance.decodeURIComponent(media.getAttribute('poster'))]; + if (typeof newSrc !== 'undefined') { + media.setAttribute('poster', newSrc); + } + } + }); + + // Now treat links. + const anchors = Array.from(element.querySelectorAll('a')); + anchors.forEach((anchor: HTMLElement) => { + const href = CoreTextUtils.instance.decodeURIComponent(anchor.getAttribute('href')); + const newUrl = paths[href]; + + if (typeof newUrl != 'undefined') { + anchor.setAttribute('href', newUrl); + + if (typeof anchorFn == 'function') { + anchorFn(anchor, href); + } + } + }); + + return element.innerHTML; + } + + /** + * Scroll to somehere in the content. + * Checks hidden property _scroll to avoid errors if view is not active. + * + * @param content Content where to execute the function. + * @param x The x-value to scroll to. + * @param y The y-value to scroll to. + * @param duration Duration of the scroll animation in milliseconds. Defaults to `300`. + * @return Returns a promise which is resolved when the scroll has completed. + */ + scrollTo(content: any, x: number, y: number, duration?: number, done?: () => void): Promise { + // @todo + return Promise.resolve(); + } + + /** + * Scroll to Bottom of the content. + * Checks hidden property _scroll to avoid errors if view is not active. + * + * @param content Content where to execute the function. + * @param duration Duration of the scroll animation in milliseconds. Defaults to `300`. + * @return Returns a promise which is resolved when the scroll has completed. + */ + scrollToBottom(content: any, duration?: number): Promise { + // @todo + return Promise.resolve(); + } + + /** + * Scroll to Top of the content. + * Checks hidden property _scroll to avoid errors if view is not active. + * + * @param content Content where to execute the function. + * @param duration Duration of the scroll animation in milliseconds. Defaults to `300`. + * @return Returns a promise which is resolved when the scroll has completed. + */ + scrollToTop(content: any, duration?: number): Promise { + // @todo + return Promise.resolve(); + } + + /** + * Returns contentHeight of the content. + * Checks hidden property _scroll to avoid errors if view is not active. + * + * @param content Content where to execute the function. + * @return Content contentHeight or 0. + */ + getContentHeight(content: any): number { + // @todo + return 0; + } + + /** + * Returns scrollHeight of the content. + * Checks hidden property _scroll to avoid errors if view is not active. + * + * @param content Content where to execute the function. + * @return Content scrollHeight or 0. + */ + getScrollHeight(content: any): number { + // @todo + return 0; + } + + /** + * Returns scrollTop of the content. + * Checks hidden property _scrollContent to avoid errors if view is not active. + * Using navite value of scroll to avoid having non updated values. + * + * @param content Content where to execute the function. + * @return Content scrollTop or 0. + */ + getScrollTop(content: any): number { + // @todo + return 0; + } + + /** + * Scroll to a certain element. + * + * @param content The content that must be scrolled. + * @param element The element to scroll to. + * @param scrollParentClass Parent class where to stop calculating the position. Default scroll-content. + * @return True if the element is found, false otherwise. + */ + scrollToElement(content: any, element: HTMLElement, scrollParentClass?: string): boolean { + const position = this.getElementXY(element, undefined, scrollParentClass); + if (!position) { + return false; + } + + this.scrollTo(content, position[0], position[1]); + + return true; + } + + /** + * Scroll to a certain element using a selector to find it. + * + * @param content The content that must be scrolled. + * @param selector Selector to find the element to scroll to. + * @param scrollParentClass Parent class where to stop calculating the position. Default scroll-content. + * @return True if the element is found, false otherwise. + */ + scrollToElementBySelector(content: any, selector: string, scrollParentClass?: string): boolean { + const position = this.getElementXY(content.getScrollElement(), selector, scrollParentClass); + if (!position) { + return false; + } + + this.scrollTo(content, position[0], position[1]); + + return true; + } + + /** + * Search for an input with error (core-input-error directive) and scrolls to it if found. + * + * @param content The content that must be scrolled. + * @param [scrollParentClass] Parent class where to stop calculating the position. Default scroll-content. + * @return True if the element is found, false otherwise. + */ + scrollToInputError(content: any, scrollParentClass?: string): boolean { + if (!content) { + return false; + } + + return this.scrollToElementBySelector(content, '.core-input-error', scrollParentClass); + } + + /** + * Set whether debug messages should be displayed. + * + * @param value Whether to display or not. + */ + setDebugDisplay(value: boolean): void { + this.debugDisplay = value; + } + + /** + * Show an alert modal with a button to close it. + * + * @param title Title to show. + * @param message Message to show. + * @param buttonText Text of the button. + * @param autocloseTime Number of milliseconds to wait to close the modal. If not defined, modal won't be closed. + * @return Promise resolved with the alert modal. + */ + async showAlert(title: string, message: string, buttonText?: string, autocloseTime?: number): Promise { + return this.showAlertWithOptions({ + title, + message, + buttons: [buttonText || Translate.instance.instant('core.ok')] + }, autocloseTime); + } + + /** + * General show an alert modal. + * + * @param options Alert options to pass to the alert. + * @param autocloseTime Number of milliseconds to wait to close the modal. If not defined, modal won't be closed. + * @return Promise resolved with the alert modal. + */ + async showAlertWithOptions(options: any = {}, autocloseTime?: number): Promise { + // @todo + return Promise.resolve(); + } + + /** + * Show an alert modal with a button to close it, translating the values supplied. + * + * @param title Title to show. + * @param message Message to show. + * @param buttonText Text of the button. + * @param autocloseTime Number of milliseconds to wait to close the modal. If not defined, modal won't be closed. + * @return Promise resolved with the alert modal. + */ + showAlertTranslated(title: string, message: string, buttonText?: string, autocloseTime?: number): Promise { + title = title ? Translate.instance.instant(title) : title; + message = message ? Translate.instance.instant(message) : message; + buttonText = buttonText ? Translate.instance.instant(buttonText) : buttonText; + + return this.showAlert(title, message, buttonText, autocloseTime); + } + + /** + * Shortcut for a delete confirmation modal. + * + * @param translateMessage String key to show in the modal body translated. Default: 'core.areyousure'. + * @param translateArgs Arguments to pass to translate if necessary. + * @param options More options. See https://ionicframework.com/docs/v3/api/components/alert/AlertController/ + * @return Promise resolved if the user confirms and rejected with a canceled error if he cancels. + */ + showDeleteConfirm(translateMessage: string = 'core.areyousure', translateArgs: any = {}, options?: any): Promise { + return this.showConfirm(Translate.instance.instant(translateMessage, translateArgs), undefined, + Translate.instance.instant('core.delete'), undefined, options); + } + + /** + * Show a confirm modal. + * + * @param message Message to show in the modal body. + * @param title Title of the modal. + * @param okText Text of the OK button. + * @param cancelText Text of the Cancel button. + * @param options More options. See https://ionicframework.com/docs/v3/api/components/alert/AlertController/ + * @return Promise resolved if the user confirms and rejected with a canceled error if he cancels. + */ + showConfirm(message: string, title?: string, okText?: string, cancelText?: string, options: any = {}): Promise { + return new Promise((resolve, reject): void => { + + options.title = title; + options.message = message; + + options.buttons = [ + { + text: cancelText || Translate.instance.instant('core.cancel'), + role: 'cancel', + handler: (): void => { + reject(this.createCanceledError()); + } + }, + { + text: okText || Translate.instance.instant('core.ok'), + handler: (data: any): void => { + resolve(data); + } + } + ]; + + if (!title) { + options.cssClass = (options.cssClass || '') + ' core-nohead'; + } + + this.showAlertWithOptions(options, 0); + }); + } + + /** + * Show an alert modal with an error message. + * + * @param error Message to show. + * @param needsTranslate Whether the error needs to be translated. + * @param autocloseTime Number of milliseconds to wait to close the modal. If not defined, modal won't be closed. + * @return Promise resolved with the alert modal. + */ + showErrorModal(error: any, needsTranslate?: boolean, autocloseTime?: number): Promise { + const message = this.getErrorMessage(error, needsTranslate); + + if (message === null) { + // Message doesn't need to be displayed, stop. + return Promise.resolve(null); + } + + return this.showAlert(this.getErrorTitle(message), message, undefined, autocloseTime); + } + + /** + * Show an alert modal with an error message. It uses a default message if error is not a string. + * + * @param error Message to show. + * @param defaultError Message to show if the error is not a string. + * @param needsTranslate Whether the error needs to be translated. + * @param autocloseTime Number of milliseconds to wait to close the modal. If not defined, modal won't be closed. + * @return Promise resolved with the alert modal. + */ + showErrorModalDefault(error: any, defaultError: any, needsTranslate?: boolean, autocloseTime?: number): Promise { + if (this.isCanceledError(error)) { + // It's a canceled error, don't display an error. + return; + } + + let errorMessage = error; + + if (error && typeof error != 'string') { + errorMessage = CoreTextUtils.instance.getErrorMessageFromError(error); + } + + return this.showErrorModal(typeof errorMessage == 'string' ? error : defaultError, needsTranslate, autocloseTime); + } + + /** + * Show an alert modal with the first warning error message. It uses a default message if error is not a string. + * + * @param warnings Warnings returned. + * @param defaultError Message to show if the error is not a string. + * @param needsTranslate Whether the error needs to be translated. + * @param autocloseTime Number of milliseconds to wait to close the modal. If not defined, modal won't be closed. + * @return Promise resolved with the alert modal. + */ + showErrorModalFirstWarning(warnings: any, defaultError: any, needsTranslate?: boolean, autocloseTime?: number): Promise { + const error = warnings && warnings.length && warnings[0].message; + + return this.showErrorModalDefault(error, defaultError, needsTranslate, autocloseTime); + } + + /** + * Displays a loading modal window. + * + * @param text The text of the modal window. Default: core.loading. + * @param needsTranslate Whether the 'text' needs to be translated. + * @return Loading modal instance. + * @description + * Usage: + * let modal = domUtils.showModalLoading(myText); + * ... + * modal.dismiss(); + */ + showModalLoading(text?: string, needsTranslate?: boolean): any { + // @todo + } + + /** + * Show a modal warning the user that he should use a different app. + * + * @param message The warning message. + * @param link Link to the app to download if any. + */ + showDownloadAppNoticeModal(message: string, link?: string): void { + // @todo + } + + /** + * Show a prompt modal to input some data. + * + * @param message Modal message. + * @param title Modal title. + * @param placeholder Placeholder of the input element. By default, "Password". + * @param type Type of the input element. By default, password. + * @param options More options to pass to the alert. + * @return Promise resolved with the input data if the user clicks OK, rejected if cancels. + */ + showPrompt(message: string, title?: string, placeholder?: string, type: string = 'password'): Promise { + // @todo + return Promise.resolve(); + } + + /** + * Show a prompt modal to input a textarea. + * + * @param title Modal title. + * @param message Modal message. + * @param buttons Buttons to pass to the modal. + * @param placeholder Placeholder of the input element if any. + * @return Promise resolved when modal presented. + */ + showTextareaPrompt(title: string, message: string, buttons: (string | any)[], placeholder?: string): Promise { + // @todo + return Promise.resolve(); + } + + /** + * Displays an autodimissable toast modal window. + * + * @param text The text of the toast. + * @param needsTranslate Whether the 'text' needs to be translated. + * @param duration Duration in ms of the dimissable toast. + * @param cssClass Class to add to the toast. + * @param dismissOnPageChange Dismiss the Toast on page change. + * @return Toast instance. + */ + showToast(text: string, needsTranslate?: boolean, duration: number = 2000, cssClass: string = '', + dismissOnPageChange: boolean = true): any { + + // @todo + } + + /** + * Stores a component/directive instance. + * + * @param element The root element of the component/directive. + * @param instance The instance to store. + * @return ID to identify the instance. + */ + storeInstanceByElement(element: Element, instance: any): string { + const id = String(this.lastInstanceId++); + + element.setAttribute(this.INSTANCE_ID_ATTR_NAME, id); + this.instances[id] = instance; + + return id; + } + + /** + * Check if an element supports input via keyboard. + * + * @param el HTML element to check. + * @return Whether it supports input using keyboard. + */ + supportsInputKeyboard(el: any): boolean { + return el && !el.disabled && (el.tagName.toLowerCase() == 'textarea' || + (el.tagName.toLowerCase() == 'input' && this.INPUT_SUPPORT_KEYBOARD.indexOf(el.type) != -1)); + } + + /** + * Converts HTML formatted text to DOM element(s). + * + * @param text HTML text. + * @return Same text converted to HTMLCollection. + */ + toDom(text: string): HTMLCollection { + const element = this.convertToElement(text); + + return element.children; + } + + /** + * Treat anchors inside alert/modals. + * + * @param container The HTMLElement that can contain anchors. + */ + treatAnchors(container: HTMLElement): void { + const anchors = Array.from(container.querySelectorAll('a')); + + anchors.forEach((anchor) => { + anchor.addEventListener('click', (event) => { + if (event.defaultPrevented) { + // Stop. + return; + } + + const href = anchor.getAttribute('href'); + if (href) { + event.preventDefault(); + event.stopPropagation(); + + CoreUtils.instance.openInBrowser(href); + } + }); + }); + } + + /** + * View an image in a modal. + * + * @param image URL of the image. + * @param title Title of the page or modal. + * @param component Component to link the image to if needed. + * @param componentId An ID to use in conjunction with the component. + * @param fullScreen Whether the modal should be full screen. + */ + viewImage(image: string, title?: string, component?: string, componentId?: string | number, fullScreen?: boolean): void { + // @todo + } + + /** + * Wait for images to load. + * + * @param element The element to search in. + * @return Promise resolved with a boolean: whether there was any image to load. + */ + waitForImages(element: HTMLElement): Promise { + const imgs = Array.from(element.querySelectorAll('img')); + const promises = []; + let hasImgToLoad = false; + + imgs.forEach((img) => { + if (img && !img.complete) { + hasImgToLoad = true; + + // Wait for image to load or fail. + promises.push(new Promise((resolve, reject): void => { + const imgLoaded = (): void => { + resolve(); + img.removeEventListener('load', imgLoaded); + img.removeEventListener('error', imgLoaded); + }; + + img.addEventListener('load', imgLoaded); + img.addEventListener('error', imgLoaded); + })); + } + }); + + return Promise.all(promises).then(() => { + return hasImgToLoad; + }); + } + + /** + * Wrap an HTMLElement with another element. + * + * @param el The element to wrap. + * @param wrapper Wrapper. + */ + wrapElement(el: HTMLElement, wrapper: HTMLElement): void { + // Insert the wrapper before the element. + el.parentNode.insertBefore(wrapper, el); + // Now move the element into the wrapper. + wrapper.appendChild(el); + } + + /** + * Trigger form cancelled event. + * + * @param form Form element. + * @param siteId The site affected. If not provided, no site affected. + */ + triggerFormCancelledEvent(formRef: ElementRef, siteId?: string): void { + if (!formRef) { + return; + } + + CoreEvents.instance.trigger(CoreEventsProvider.FORM_ACTION, { + action: 'cancel', + form: formRef.nativeElement, + }, siteId); + } + + /** + * Trigger form submitted event. + * + * @param form Form element. + * @param online Whether the action was done in offline or not. + * @param siteId The site affected. If not provided, no site affected. + */ + triggerFormSubmittedEvent(formRef: ElementRef, online?: boolean, siteId?: string): void { + if (!formRef) { + return; + } + + CoreEvents.instance.trigger(CoreEventsProvider.FORM_ACTION, { + action: 'submit', + form: formRef.nativeElement, + online: !!online, + }, siteId); + } +} + +export class CoreDomUtils extends makeSingleton(CoreDomUtilsProvider) {} diff --git a/src/app/services/utils/iframe.ts b/src/app/services/utils/iframe.ts new file mode 100644 index 000000000..33c0094b9 --- /dev/null +++ b/src/app/services/utils/iframe.ts @@ -0,0 +1,450 @@ +// (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 { WKUserScriptWindow, WKUserScriptInjectionTime } from 'cordova-plugin-wkuserscript'; + +import { CoreApp, CoreAppProvider } from '@services/app'; +import { CoreFile } from '@services/file'; +import { CoreFileHelper } from '@services/file-helper'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreUtils } from '@services/utils/utils'; + +import { makeSingleton, Translate, Network, Platform, NgZone } from '@singletons/core.singletons'; +import { CoreLogger } from '@singletons/logger'; +import { CoreUrl } from '@singletons/url'; +import { CoreWindow } from '@singletons/window'; + +/* + * "Utils" service with helper functions for iframes, embed and similar. + */ +@Injectable() +export class CoreIframeUtilsProvider { + static FRAME_TAGS = ['iframe', 'frame', 'object', 'embed']; + + protected logger: CoreLogger; + + constructor() { + this.logger = CoreLogger.getInstance('CoreUtilsProvider'); + + const win = window; + + if (CoreApp.instance.isIOS() && win.WKUserScript) { + Platform.instance.ready().then(() => { + // Inject code to the iframes because we cannot access the online ones. + const wwwPath = CoreFile.instance.getWWWAbsolutePath(); + const linksPath = CoreTextUtils.instance.concatenatePaths(wwwPath, 'assets/js/iframe-treat-links.js'); + const recaptchaPath = CoreTextUtils.instance.concatenatePaths(wwwPath, 'assets/js/iframe-recaptcha.js'); + + win.WKUserScript.addScript({id: 'CoreIframeUtilsLinksScript', file: linksPath}); + win.WKUserScript.addScript({ + id: 'CoreIframeUtilsRecaptchaScript', + file: recaptchaPath, + injectionTime: WKUserScriptInjectionTime.END, + }); + + // Handle post messages received by iframes. + window.addEventListener('message', this.handleIframeMessage.bind(this)); + }); + } + } + + /** + * Check if a frame uses an online URL but the app is offline. If it does, the iframe is hidden and a warning is shown. + * + * @param element The frame to check (iframe, embed, ...). + * @param isSubframe Whether it's a frame inside another frame. + * @return True if frame is online and the app is offline, false otherwise. + */ + checkOnlineFrameInOffline(element: any, isSubframe?: boolean): boolean { + const src = element.src || element.data; + + if (src && src != 'about:blank' && !CoreUrlUtils.instance.isLocalFileUrl(src) && !CoreApp.instance.isOnline()) { + if (element.classList.contains('core-iframe-offline-disabled')) { + // Iframe already hidden, stop. + return true; + } + + // The frame has an online URL but the app is offline. Show a warning, or a link if the URL can be opened in the app. + const div = document.createElement('div'); + + div.setAttribute('text-center', ''); + div.setAttribute('padding', ''); + div.classList.add('core-iframe-offline-warning'); + + const site = CoreSites.instance.getCurrentSite(); + const username = site ? site.getInfo().username : undefined; + // @todo Handle link + + // Add a class to specify that the iframe is hidden. + element.classList.add('core-iframe-offline-disabled'); + + if (isSubframe) { + // We cannot apply CSS styles in subframes, just hide the iframe. + element.style.display = 'none'; + } + + // If the network changes, check it again. + const subscription = Network.instance.onConnect().subscribe(() => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + NgZone.instance.run(() => { + if (!this.checkOnlineFrameInOffline(element, isSubframe)) { + // Now the app is online, no need to check connection again. + subscription.unsubscribe(); + } + }); + }); + + return true; + } else if (element.classList.contains('core-iframe-offline-disabled')) { + // Reload the frame. + element.src = element.src; + element.data = element.data; + + // Remove the warning and show the iframe + CoreDomUtils.instance.removeElement(element.parentElement, 'div.core-iframe-offline-warning'); + element.classList.remove('core-iframe-offline-disabled'); + + if (isSubframe) { + element.style.display = ''; + } + } + + return false; + } + + /** + * Given an element, return the content window and document. + * Please notice that the element should be an iframe, embed or similar. + * + * @param element Element to treat (iframe, embed, ...). + * @return Window and Document. + */ + getContentWindowAndDocument(element: any): { window: Window, document: Document } { + let contentWindow: Window = element.contentWindow; + let contentDocument: Document; + + try { + contentDocument = element.contentDocument || (contentWindow && contentWindow.document); + } catch (ex) { + // Ignore errors. + } + + if (!contentWindow && contentDocument) { + // It's probably an . Try to get the window. + contentWindow = contentDocument.defaultView; + } + + if (!contentWindow && element.getSVGDocument) { + // It's probably an . Try to get the window and the document. + try { + contentDocument = element.getSVGDocument(); + } catch (ex) { + // Ignore errors. + } + + if (contentDocument && contentDocument.defaultView) { + contentWindow = contentDocument.defaultView; + } else if (element.window) { + contentWindow = element.window; + } else if (element.getWindow) { + contentWindow = element.getWindow(); + } + } + + return { window: contentWindow, document: contentDocument }; + } + + /** + * Handle some iframe messages. + * + * @param event Message event. + */ + handleIframeMessage(event: MessageEvent): void { + if (!event.data || event.data.environment != 'moodleapp' || event.data.context != 'iframe') { + return; + } + + switch (event.data.action) { + case 'window_open': + this.windowOpen(event.data.url, event.data.name); + break; + + case 'link_clicked': + this.linkClicked(event.data.link); + break; + + default: + break; + } + } + + /** + * Redefine the open method in the contentWindow of an element and the sub frames. + * Please notice that the element should be an iframe, embed or similar. + * + * @param element Element to treat (iframe, embed, ...). + * @param contentWindow The window of the element contents. + * @param contentDocument The document of the element contents. + * @param navCtrl NavController to use if a link can be opened in the app. + */ + redefineWindowOpen(element: any, contentWindow: Window, contentDocument: Document, navCtrl?: any): void { + if (contentWindow) { + // Intercept window.open. + ( contentWindow).open = (url: string, name: string): Window => { + this.windowOpen(url, name, element, navCtrl); + + return null; + }; + } + + if (contentDocument) { + // Search sub frames. + CoreIframeUtilsProvider.FRAME_TAGS.forEach((tag) => { + const elements = Array.from(contentDocument.querySelectorAll(tag)); + elements.forEach((subElement) => { + this.treatFrame(subElement, true, navCtrl); + }); + }); + } + } + + /** + * Intercept window.open in a frame and its subframes, shows an error modal instead. + * Search links () and open them in browser or InAppBrowser if needed. + * + * @param element Element to treat (iframe, embed, ...). + * @param isSubframe Whether it's a frame inside another frame. + * @param navCtrl NavController to use if a link can be opened in the app. + */ + treatFrame(element: any, isSubframe?: boolean, navCtrl?: any): void { + if (element) { + this.checkOnlineFrameInOffline(element, isSubframe); + + let winAndDoc = this.getContentWindowAndDocument(element); + // Redefine window.open in this element and sub frames, it might have been loaded already. + this.redefineWindowOpen(element, winAndDoc.window, winAndDoc.document, navCtrl); + // Treat links. + this.treatFrameLinks(element, winAndDoc.document); + + element.addEventListener('load', () => { + this.checkOnlineFrameInOffline(element, isSubframe); + + // Element loaded, redefine window.open and treat links again. + winAndDoc = this.getContentWindowAndDocument(element); + this.redefineWindowOpen(element, winAndDoc.window, winAndDoc.document, navCtrl); + this.treatFrameLinks(element, winAndDoc.document); + + if (winAndDoc.window) { + // Send a resize events to the iframe so it calculates the right size if needed. + setTimeout(() => { + winAndDoc.window.dispatchEvent(new Event('resize')); + }, 1000); + } + }); + } + } + + /** + * Search links () in a frame and open them in browser or InAppBrowser if needed. + * Only links that haven't been treated by the frame's Javascript will be treated. + * + * @param element Element to treat (iframe, embed, ...). + * @param contentDocument The document of the element contents. + */ + treatFrameLinks(element: any, contentDocument: Document): void { + if (!contentDocument) { + return; + } + + contentDocument.addEventListener('click', (event) => { + if (event.defaultPrevented) { + // Event already prevented by some other code. + return; + } + + // Find the link being clicked. + let el = event.target; + while (el && el.tagName !== 'A') { + el = el.parentElement; + } + + const link = el; + if (!link || link.treated) { + return; + } + + // Add click listener to the link, this way if the iframe has added a listener to the link it will be executed first. + link.treated = true; + link.addEventListener('click', this.linkClicked.bind(this, link, element)); + }, { + capture: true // Use capture to fix this listener not called if the element clicked is too deep in the DOM. + }); + } + + /** + * Handle a window.open called by a frame. + * + * @param url URL passed to window.open. + * @param name Name passed to window.open. + * @param element HTML element of the frame. + * @param navCtrl NavController to use if a link can be opened in the app. + * @return Promise resolved when done. + */ + protected async windowOpen(url: string, name: string, element?: any, navCtrl?: any): Promise { + const scheme = CoreUrlUtils.instance.getUrlScheme(url); + if (!scheme) { + // It's a relative URL, use the frame src to create the full URL. + const src = element && (element.src || element.data); + if (src) { + const dirAndFile = CoreFile.instance.getFileAndDirectoryFromPath(src); + if (dirAndFile.directory) { + url = CoreTextUtils.instance.concatenatePaths(dirAndFile.directory, url); + } else { + this.logger.warn('Cannot get iframe dir path to open relative url', url, element); + + return; + } + } else { + this.logger.warn('Cannot get iframe src to open relative url', url, element); + + return; + } + } + + if (name == '_self') { + // Link should be loaded in the same frame. + if (!element) { + this.logger.warn('Cannot load URL in iframe because the element was not supplied', url); + + return; + } + + if (element.tagName.toLowerCase() == 'object') { + element.setAttribute('data', url); + } else { + element.setAttribute('src', url); + } + } else if (CoreUrlUtils.instance.isLocalFileUrl(url)) { + // It's a local file. + const filename = url.substr(url.lastIndexOf('/') + 1); + + if (!CoreFileHelper.instance.isOpenableInApp({ filename })) { + try { + await CoreFileHelper.instance.showConfirmOpenUnsupportedFile(); + } catch (error) { + return; // Cancelled, stop. + } + } + + try { + await CoreUtils.instance.openFile(url); + } catch (error) { + CoreDomUtils.instance.showErrorModal(error); + } + } else { + // It's an external link, check if it can be opened in the app. + await CoreWindow.open(url, name, { + navCtrl, + }); + } + } + + /** + * A link inside a frame was clicked. + * + * @param link Data of the link clicked. + * @param element Frame element. + * @param event Click event. + * @return Promise resolved when done. + */ + protected async linkClicked(link: {href: string, target?: string}, element?: HTMLFrameElement | HTMLObjectElement, + event?: Event): Promise { + if (event && event.defaultPrevented) { + // Event already prevented by some other code. + return; + } + + const urlParts = CoreUrl.parse(link.href); + if (!link.href || (urlParts.protocol && urlParts.protocol == 'javascript')) { + // Links with no URL and Javascript links are ignored. + return; + } + + if (!CoreUrlUtils.instance.isLocalFileUrlScheme(urlParts.protocol, urlParts.domain)) { + // Scheme suggests it's an external resource. + event && event.preventDefault(); + + const frameSrc = element && (( element).src || ( element).data); + + // If the frame is not local, check the target to identify how to treat the link. + if (element && !CoreUrlUtils.instance.isLocalFileUrl(frameSrc) && (!link.target || link.target == '_self')) { + // Load the link inside the frame itself. + if (element.tagName.toLowerCase() == 'object') { + element.setAttribute('data', link.href); + } else { + element.setAttribute('src', link.href); + } + + return; + } + + // The frame is local or the link needs to be opened in a new window. Open in browser. + if (!CoreSites.instance.isLoggedIn()) { + CoreUtils.instance.openInBrowser(link.href); + } else { + await CoreSites.instance.getCurrentSite().openInBrowserWithAutoLoginIfSameSite(link.href); + } + } else if (link.target == '_parent' || link.target == '_top' || link.target == '_blank') { + // Opening links with _parent, _top or _blank can break the app. We'll open it in InAppBrowser. + event && event.preventDefault(); + + const filename = link.href.substr(link.href.lastIndexOf('/') + 1); + + if (!CoreFileHelper.instance.isOpenableInApp({ filename })) { + try { + await CoreFileHelper.instance.showConfirmOpenUnsupportedFile(); + } catch (error) { + return; // Cancelled, stop. + } + } + + try { + await CoreUtils.instance.openFile(link.href); + } catch (error) { + CoreDomUtils.instance.showErrorModal(error); + } + } else if (CoreApp.instance.isIOS() && (!link.target || link.target == '_self') && element) { + // In cordova ios 4.1.0 links inside iframes stopped working. We'll manually treat them. + event && event.preventDefault(); + if (element.tagName.toLowerCase() == 'object') { + element.setAttribute('data', link.href); + } else { + element.setAttribute('src', link.href); + } + } + } +} + +export class CoreIframeUtils extends makeSingleton(CoreIframeUtilsProvider) {} + +/** + * Subtype of HTMLAnchorElement, with some calculated data. + */ +type CoreIframeHTMLAnchorElement = HTMLAnchorElement & { + treated?: boolean; // Whether the element has been treated already. +}; diff --git a/src/app/services/utils/mimetype.ts b/src/app/services/utils/mimetype.ts new file mode 100644 index 000000000..9437ce74d --- /dev/null +++ b/src/app/services/utils/mimetype.ts @@ -0,0 +1,553 @@ +// (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 { CoreFile } from '@services/file'; +import { CoreTextUtils } from '@services/utils/text'; +import { makeSingleton, Translate, Http } from '@singletons/core.singletons'; +import { CoreLogger } from '@singletons/logger'; + +/* + * "Utils" service with helper functions for mimetypes and extensions. + */ +@Injectable() +export class CoreMimetypeUtilsProvider { + protected logger: CoreLogger; + protected extToMime = {}; // Object to map extensions -> mimetypes. + protected mimeToExt = {}; // Object to map mimetypes -> extensions. + protected groupsMimeInfo = {}; // Object to hold extensions and mimetypes that belong to a certain "group" (audio, video, ...). + protected extensionRegex = /^[a-z0-9]+$/; + + constructor() { + this.logger = CoreLogger.getInstance('CoreMimetypeUtilsProvider'); + + Http.instance.get('assets/exttomime.json').subscribe((result) => { + this.extToMime = result; + }, (err) => { + // Error, shouldn't happen. + }); + + Http.instance.get('assets/mimetoext.json').subscribe((result) => { + this.mimeToExt = result; + }, (err) => { + // Error, shouldn't happen. + }); + } + + /** + * Check if a file extension can be embedded without using iframes. + * + * @param extension Extension. + * @return Whether it can be embedded. + */ + canBeEmbedded(extension: string): boolean { + return this.isExtensionInGroup(extension, ['web_image', 'web_video', 'web_audio']); + } + + /** + * Clean a extension, removing the dot, hash, extra params... + * + * @param extension Extension to clean. + * @return Clean extension. + */ + cleanExtension(extension: string): string { + if (!extension) { + return extension; + } + + // If the extension has parameters, remove them. + let position = extension.indexOf('?'); + if (position > -1) { + extension = extension.substr(0, position); + } + + // If the extension has an anchor, remove it. + position = extension.indexOf('#'); + if (position > -1) { + extension = extension.substr(0, position); + } + + // Remove hash in extension if there's any (added by filepool). + extension = extension.replace(/_.{32}$/, ''); + + // Remove dot from the extension if found. + if (extension && extension[0] == '.') { + extension = extension.substr(1); + } + + return extension; + } + + /** + * Fill the mimetypes and extensions info for a certain group. + * + * @param group Group name. + */ + protected fillGroupMimeInfo(group: string): void { + const mimetypes = {}; // Use an object to prevent duplicates. + const extensions = []; // Extensions are unique. + + for (const extension in this.extToMime) { + const data = this.extToMime[extension]; + if (data.type && data.groups && data.groups.indexOf(group) != -1) { + // This extension has the group, add it to the list. + mimetypes[data.type] = true; + extensions.push(extension); + } + } + + this.groupsMimeInfo[group] = { + mimetypes: Object.keys(mimetypes), + extensions, + }; + } + + /** + * Get the extension of a mimetype. Returns undefined if not found. + * + * @param mimetype Mimetype. + * @param url URL of the file. It will be used if there's more than one possible extension. + * @return Extension. + */ + getExtension(mimetype: string, url?: string): string { + mimetype = mimetype || ''; + mimetype = mimetype.split(';')[0]; // Remove codecs from the mimetype if any. + + if (mimetype == 'application/x-forcedownload' || mimetype == 'application/forcedownload') { + // Couldn't get the right mimetype, try to guess it. + return this.guessExtensionFromUrl(url); + } + + const extensions = this.mimeToExt[mimetype]; + if (extensions && extensions.length) { + if (extensions.length > 1 && url) { + // There's more than one possible extension. Check if the URL has extension. + const candidate = this.guessExtensionFromUrl(url); + if (extensions.indexOf(candidate) != -1) { + return candidate; + } + } + + return extensions[0]; + } + } + + /** + * Set the embed type to display an embedded file and mimetype if not found. + * + * @param file File object. + * @paran path Alternative path that will override fileurl from file object. + */ + getEmbeddedHtml(file: any, path?: string): string { + let ext; + const filename = file.filename || file.name; + + if (file.mimetype) { + ext = this.getExtension(file.mimetype); + } else { + ext = this.getFileExtension(filename); + file.mimetype = this.getMimeType(ext); + } + + if (this.canBeEmbedded(ext)) { + file.embedType = this.getExtensionType(ext); + + path = CoreFile.instance.convertFileSrc(path || file.fileurl || file.url || (file.toURL && file.toURL())); + + if (file.embedType == 'image') { + return ''; + } + if (file.embedType == 'audio' || file.embedType == 'video') { + return '<' + file.embedType + ' controls title="' + filename + '" src="' + path + '">' + + '' + + ''; + } + } + + return ''; + } + + /** + * Get the URL of the icon of an extension. + * + * @param extension Extension. + * @return Icon URL. + */ + getExtensionIcon(extension: string): string { + const icon = this.getExtensionIconName(extension) || 'unknown'; + + return this.getFileIconForType(icon); + } + + /** + * Get the name of the icon of an extension. + * + * @param extension Extension. + * @return Icon. Undefined if not found. + */ + getExtensionIconName(extension: string): string { + if (this.extToMime[extension]) { + if (this.extToMime[extension].icon) { + return this.extToMime[extension].icon; + } else { + const type = this.extToMime[extension].type.split('/')[0]; + if (type == 'video' || type == 'text' || type == 'image' || type == 'document' || type == 'audio') { + return type; + } + } + } + } + + /** + * Get the "type" (string) of an extension, something like "image", "video" or "audio". + * + * @param extension Extension. + * @return Type of the extension. + */ + getExtensionType(extension: string): string { + extension = this.cleanExtension(extension); + + if (this.extToMime[extension] && this.extToMime[extension].string) { + return this.extToMime[extension].string; + } + } + + /** + * Get all the possible extensions of a mimetype. Returns empty array if not found. + * + * @param mimetype Mimetype. + * @return Extensions. + */ + getExtensions(mimetype: string): string[] { + mimetype = mimetype || ''; + mimetype = mimetype.split(';')[0]; // Remove codecs from the mimetype if any. + + return this.mimeToExt[mimetype] || []; + } + + /** + * Get a file icon URL based on its file name. + * + * @param The name of the file. + * @return The path to a file icon. + */ + getFileIcon(filename: string): string { + const ext = this.getFileExtension(filename); + const icon = this.getExtensionIconName(ext) || 'unknown'; + + return this.getFileIconForType(icon); + } + + /** + * Get the folder icon URL. + * + * @return The path to a folder icon. + */ + getFolderIcon(): string { + return 'assets/img/files/folder-64.png'; + } + + /** + * Given a type (audio, video, html, ...), return its file icon path. + * + * @param type The type to get the icon. + * @return The icon path. + */ + getFileIconForType(type: string): string { + return 'assets/img/files/' + type + '-64.png'; + } + + /** + * Guess the extension of a file from its URL. + * This is very weak and unreliable. + * + * @param fileUrl The file URL. + * @return The lowercased extension without the dot, or undefined. + */ + guessExtensionFromUrl(fileUrl: string): string { + const split = fileUrl.split('.'); + let candidate; + let extension; + let position; + + if (split.length > 1) { + candidate = split.pop().toLowerCase(); + // Remove params if any. + position = candidate.indexOf('?'); + if (position > -1) { + candidate = candidate.substr(0, position); + } + + if (this.extensionRegex.test(candidate)) { + extension = candidate; + } + } + + // Check extension corresponds to a mimetype to know if it's valid. + if (extension && typeof this.getMimeType(extension) == 'undefined') { + this.logger.warn('Guess file extension: Not valid extension ' + extension); + + return; + } + + return extension; + } + + /** + * Returns the file extension of a file. + * When the file does not have an extension, it returns undefined. + * + * @param filename The file name. + * @return The lowercased extension, or undefined. + */ + getFileExtension(filename: string): string { + const dot = filename.lastIndexOf('.'); + let ext; + + if (dot > -1) { + ext = filename.substr(dot + 1).toLowerCase(); + ext = this.cleanExtension(ext); + + // Check extension corresponds to a mimetype to know if it's valid. + if (typeof this.getMimeType(ext) == 'undefined') { + this.logger.warn('Get file extension: Not valid extension ' + ext); + + return; + } + } + + return ext; + } + + /** + * Get the mimetype/extension info belonging to a certain group. + * + * @param group Group name. + * @param field The field to get. If not supplied, all the info will be returned. + * @return Info for the group. + */ + getGroupMimeInfo(group: string, field?: string): any { + if (typeof this.groupsMimeInfo[group] == 'undefined') { + this.fillGroupMimeInfo(group); + } + + if (field) { + return this.groupsMimeInfo[group][field]; + } + + return this.groupsMimeInfo[group]; + } + + /** + * Get the mimetype of an extension. Returns undefined if not found. + * + * @param extension Extension. + * @return Mimetype. + */ + getMimeType(extension: string): string { + extension = this.cleanExtension(extension); + + if (this.extToMime[extension] && this.extToMime[extension].type) { + return this.extToMime[extension].type; + } + } + + /** + * Obtains descriptions for file types (e.g. 'Microsoft Word document') from the language file. + * Based on Moodle's get_mimetype_description. + * + * @param obj Instance of FileEntry OR object with 'filename' and 'mimetype' OR string with mimetype. + * @param capitalise If true, capitalises first character of result. + * @return Type description. + */ + getMimetypeDescription(obj: any, capitalise?: boolean): string { + const langPrefix = 'assets.mimetypes.'; + let filename = ''; + let mimetype = ''; + let extension = ''; + + if (typeof obj == 'object' && typeof obj.file == 'function') { + // It's a FileEntry. Don't use the file function because it's asynchronous and the type isn't reliable. + filename = obj.name; + } else if (typeof obj == 'object') { + filename = obj.filename || ''; + mimetype = obj.mimetype || ''; + } else { + mimetype = obj; + } + + if (filename) { + extension = this.getFileExtension(filename); + + if (!mimetype) { + // Try to calculate the mimetype using the extension. + mimetype = this.getMimeType(extension); + } + } + + if (!mimetype) { + // Don't have the mimetype, stop. + return ''; + } + + if (!extension) { + extension = this.getExtension(mimetype); + } + + const mimetypeStr = this.getMimetypeType(mimetype) || ''; + const chunks = mimetype.split('/'); + const attr = { + mimetype, + ext: extension || '', + mimetype1: chunks[0], + mimetype2: chunks[1] || '', + }; + const translateParams = {}; + + for (const key in attr) { + const value = attr[key]; + translateParams[key] = value; + translateParams[key.toUpperCase()] = value.toUpperCase(); + translateParams[CoreTextUtils.instance.ucFirst(key)] = CoreTextUtils.instance.ucFirst(value); + } + + // MIME types may include + symbol but this is not permitted in string ids. + const safeMimetype = mimetype.replace(/\+/g, '_'); + const safeMimetypeStr = mimetypeStr.replace(/\+/g, '_'); + const safeMimetypeTrns = Translate.instance.instant(langPrefix + safeMimetype, { $a: translateParams }); + const safeMimetypeStrTrns = Translate.instance.instant(langPrefix + safeMimetypeStr, { $a: translateParams }); + const defaultTrns = Translate.instance.instant(langPrefix + 'default', { $a: translateParams }); + let result = mimetype; + + if (safeMimetypeTrns != langPrefix + safeMimetype) { + result = safeMimetypeTrns; + } else if (safeMimetypeStrTrns != langPrefix + safeMimetypeStr) { + result = safeMimetypeStrTrns; + } else if (defaultTrns != langPrefix + 'default') { + result = defaultTrns; + } + + if (capitalise) { + result = CoreTextUtils.instance.ucFirst(result); + } + + return result; + } + + /** + * Get the "type" (string) of a mimetype, something like "image", "video" or "audio". + * + * @param mimetype Mimetype. + * @return Type of the mimetype. + */ + getMimetypeType(mimetype: string): string { + mimetype = mimetype.split(';')[0]; // Remove codecs from the mimetype if any. + + const extensions = this.mimeToExt[mimetype]; + if (!extensions) { + return; + } + + for (let i = 0; i < extensions.length; i++) { + const extension = extensions[i]; + if (this.extToMime[extension] && this.extToMime[extension].string) { + return this.extToMime[extension].string; + } + } + } + + /** + * Get the icon of a mimetype. + * + * @param mimetype Mimetype. + * @return Type of the mimetype. + */ + getMimetypeIcon(mimetype: string): string { + mimetype = mimetype.split(';')[0]; // Remove codecs from the mimetype if any. + + const extensions = this.mimeToExt[mimetype] || []; + let icon = 'unknown'; + + for (let i = 0; i < extensions.length; i++) { + const iconName = this.getExtensionIconName(extensions[i]); + + if (iconName) { + icon = iconName; + break; + } + } + + return this.getFileIconForType(icon); + } + + /** + * Given a group name, return the translated name. + * + * @param name Group name. + * @return Translated name. + */ + getTranslatedGroupName(name: string): string { + const key = 'assets.mimetypes.group:' + name; + const translated = Translate.instance.instant(key); + + return translated != key ? translated : name; + } + + /** + * Check if an extension belongs to at least one of the groups. + * Similar to Moodle's file_mimetype_in_typegroup, but using the extension instead of mimetype. + * + * @param extension Extension. + * @param groups List of groups to check. + * @return Whether the extension belongs to any of the groups. + */ + isExtensionInGroup(extension: string, groups: string[]): boolean { + extension = this.cleanExtension(extension); + + if (groups && groups.length && this.extToMime[extension] && this.extToMime[extension].groups) { + for (let i = 0; i < this.extToMime[extension].groups.length; i++) { + const group = this.extToMime[extension].groups[i]; + if (groups.indexOf(group) != -1) { + return true; + } + } + } + + return false; + } + + /** + * Remove the extension from a path (if any). + * + * @param path Path. + * @return Path without extension. + */ + removeExtension(path: string): string { + const position = path.lastIndexOf('.'); + let extension; + + if (position > -1) { + // Check extension corresponds to a mimetype to know if it's valid. + extension = path.substr(position + 1).toLowerCase(); + if (typeof this.getMimeType(extension) != 'undefined') { + return path.substr(0, position); // Remove extension. + } + } + + return path; + } +} + +export class CoreMimetypeUtils extends makeSingleton(CoreMimetypeUtilsProvider) {} diff --git a/src/app/services/utils/text.ts b/src/app/services/utils/text.ts new file mode 100644 index 000000000..e6b6745ce --- /dev/null +++ b/src/app/services/utils/text.ts @@ -0,0 +1,1168 @@ +// (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 { DomSanitizer, SafeUrl } from '@angular/platform-browser'; + +import { CoreApp } from '@services/app'; +import { CoreLang } from '@services/lang'; +import { makeSingleton, Translate } from '@singletons/core.singletons'; + +/** + * Different type of errors the app can treat. + */ +export type CoreTextErrorObject = { + message?: string; + error?: string; + content?: string; + body?: string; +}; + +/* + * "Utils" service with helper functions for text. +*/ +@Injectable() +export class CoreTextUtilsProvider { + + // List of regular expressions to convert the old nomenclature to new nomenclature for disabled features. + protected DISABLED_FEATURES_COMPAT_REGEXPS = [ + {old: /\$mmLoginEmailSignup/g, new: 'CoreLoginEmailSignup'}, + {old: /\$mmSideMenuDelegate/g, new: 'CoreMainMenuDelegate'}, + {old: /\$mmCoursesDelegate/g, new: 'CoreCourseOptionsDelegate'}, + {old: /\$mmUserDelegate/g, new: 'CoreUserDelegate'}, + {old: /\$mmCourseDelegate/g, new: 'CoreCourseModuleDelegate'}, + {old: /_mmCourses/g, new: '_CoreCourses'}, + {old: /_mmaFrontpage/g, new: '_CoreSiteHome'}, + {old: /_mmaGrades/g, new: '_CoreGrades'}, + {old: /_mmaCompetency/g, new: '_AddonCompetency'}, + {old: /_mmaNotifications/g, new: '_AddonNotifications'}, + {old: /_mmaMessages/g, new: '_AddonMessages'}, + {old: /_mmaCalendar/g, new: '_AddonCalendar'}, + {old: /_mmaFiles/g, new: '_AddonFiles'}, + {old: /_mmaParticipants/g, new: '_CoreUserParticipants'}, + {old: /_mmaCourseCompletion/g, new: '_AddonCourseCompletion'}, + {old: /_mmaNotes/g, new: '_AddonNotes'}, + {old: /_mmaBadges/g, new: '_AddonBadges'}, + {old: /files_privatefiles/g, new: 'AddonFilesPrivateFiles'}, + {old: /files_sitefiles/g, new: 'AddonFilesSiteFiles'}, + {old: /files_upload/g, new: 'AddonFilesUpload'}, + {old: /_mmaModAssign/g, new: '_AddonModAssign'}, + {old: /_mmaModBook/g, new: '_AddonModBook'}, + {old: /_mmaModChat/g, new: '_AddonModChat'}, + {old: /_mmaModChoice/g, new: '_AddonModChoice'}, + {old: /_mmaModData/g, new: '_AddonModData'}, + {old: /_mmaModFeedback/g, new: '_AddonModFeedback'}, + {old: /_mmaModFolder/g, new: '_AddonModFolder'}, + {old: /_mmaModForum/g, new: '_AddonModForum'}, + {old: /_mmaModGlossary/g, new: '_AddonModGlossary'}, + {old: /_mmaModH5pactivity/g, new: '_AddonModH5PActivity'}, + {old: /_mmaModImscp/g, new: '_AddonModImscp'}, + {old: /_mmaModLabel/g, new: '_AddonModLabel'}, + {old: /_mmaModLesson/g, new: '_AddonModLesson'}, + {old: /_mmaModLti/g, new: '_AddonModLti'}, + {old: /_mmaModPage/g, new: '_AddonModPage'}, + {old: /_mmaModQuiz/g, new: '_AddonModQuiz'}, + {old: /_mmaModResource/g, new: '_AddonModResource'}, + {old: /_mmaModScorm/g, new: '_AddonModScorm'}, + {old: /_mmaModSurvey/g, new: '_AddonModSurvey'}, + {old: /_mmaModUrl/g, new: '_AddonModUrl'}, + {old: /_mmaModWiki/g, new: '_AddonModWiki'}, + {old: /_mmaModWorkshop/g, new: '_AddonModWorkshop'}, + {old: /remoteAddOn_/g, new: 'sitePlugin_'}, + ]; + + protected template = document.createElement('template'); // A template element to convert HTML to element. + + constructor(private sanitizer: DomSanitizer) { } + + /** + * Add ending slash from a path or URL. + * + * @param text Text to treat. + * @return Treated text. + */ + addEndingSlash(text: string): string { + if (!text) { + return ''; + } + + if (text.slice(-1) != '/') { + return text + '/'; + } + + return text; + } + + /** + * Add some text to an error message. + * + * @param error Error message or object. + * @param text Text to add. + * @return Modified error. + */ + addTextToError(error: string | CoreTextErrorObject, text: string): string | CoreTextErrorObject { + if (typeof error == 'string') { + return error + text; + } + + if (error) { + if (typeof error.message == 'string') { + error.message += text; + } else if (typeof error.error == 'string') { + error.error += text; + } else if (typeof error.content == 'string') { + error.content += text; + } else if (typeof error.body == 'string') { + error.body += text; + } + } + + return error; + } + + /** + * Given an address as a string, return a URL to open the address in maps. + * + * @param address The address. + * @return URL to view the address. + */ + buildAddressURL(address: string): SafeUrl { + return this.sanitizer.bypassSecurityTrustUrl((CoreApp.instance.isAndroid() ? 'geo:0,0?q=' : 'http://maps.google.com?q=') + + encodeURIComponent(address)); + } + + /** + * Given a list of sentences, build a message with all of them wrapped in

. + * + * @param messages Messages to show. + * @return Message with all the messages. + */ + buildMessage(messages: string[]): string { + let result = ''; + + messages.forEach((message) => { + if (message) { + result += `

${message}

`; + } + }); + + return result; + } + + /** + * Build a message with several paragraphs. + * + * @param paragraphs List of paragraphs. + * @return Built message. + */ + buildSeveralParagraphsMessage(paragraphs: (string | CoreTextErrorObject)[]): string { + // Filter invalid messages, and convert them to messages in case they're errors. + const messages: string[] = []; + + paragraphs.forEach((paragraph) => { + // If it's an error, get its message. + const message = this.getErrorMessageFromError(paragraph); + + if (paragraph) { + messages.push(message); + } + }); + + if (messages.length < 2) { + return messages[0] || ''; + } + + let builtMessage = messages[0]; + + for (let i = 1; i < messages.length; i++) { + builtMessage = Translate.instance.instant('core.twoparagraphs', { p1: builtMessage, p2: messages[i] }); + } + + return builtMessage; + } + + /** + * Convert size in bytes into human readable format + * + * @param bytes Number of bytes to convert. + * @param precision Number of digits after the decimal separator. + * @return Size in human readable format. + */ + bytesToSize(bytes: number, precision: number = 2): string { + + if (typeof bytes == 'undefined' || bytes === null || bytes < 0) { + return Translate.instance.instant('core.notapplicable'); + } + + if (precision < 0) { + precision = 2; + } + + const keys = ['core.sizeb', 'core.sizekb', 'core.sizemb', 'core.sizegb', 'core.sizetb']; + const units = Translate.instance.instant(keys); + let pos = 0; + + if (bytes >= 1024) { + while (bytes >= 1024) { + pos++; + bytes = bytes / 1024; + } + // Round to "precision" decimals if needed. + bytes = Number(Math.round(parseFloat(bytes + 'e+' + precision)) + 'e-' + precision); + } + + return Translate.instance.instant('core.humanreadablesize', { size: bytes, unit: units[keys[pos]] }); + } + + /** + * Clean HTML tags. + * + * @param text The text to be cleaned. + * @param singleLine True if new lines should be removed (all the text in a single line). + * @return Clean text. + */ + cleanTags(text: string, singleLine?: boolean): string { + if (typeof text != 'string') { + return text; + } + + if (!text) { + return ''; + } + + // First, we use a regexpr. + text = text.replace(/(<([^>]+)>)/ig, ''); + // Then, we rely on the browser. We need to wrap the text to be sure is HTML. + const element = this.convertToElement(text); + text = element.textContent; + // Recover or remove new lines. + text = this.replaceNewLines(text, singleLine ? ' ' : '
'); + + return text; + } + + /** + * Concatenate two paths, adding a slash between them if needed. + * + * @param leftPath Left path. + * @param rightPath Right path. + * @return Concatenated path. + */ + concatenatePaths(leftPath: string, rightPath: string): string { + if (!leftPath) { + return rightPath; + } else if (!rightPath) { + return leftPath; + } + + const lastCharLeft = leftPath.slice(-1); + const firstCharRight = rightPath.charAt(0); + + if (lastCharLeft === '/' && firstCharRight === '/') { + return leftPath + rightPath.substr(1); + } else if (lastCharLeft !== '/' && firstCharRight !== '/') { + return leftPath + '/' + rightPath; + } else { + return leftPath + rightPath; + } + } + + /** + * Convert some HTML as text into an HTMLElement. This HTML is put inside a div or a body. + * This function is the same as in DomUtils, but we cannot use that one because of circular dependencies. + * + * @param html Text to convert. + * @return Element. + */ + protected convertToElement(html: string): HTMLElement { + // Add a div to hold the content, that's the element that will be returned. + this.template.innerHTML = '
' + html + '
'; + + return this.template.content.children[0]; + } + + /** + * Count words in a text. + * + * @param text Text to count. + * @return Number of words. + */ + countWords(text: string): number { + if (!text || typeof text != 'string') { + return 0; + } + const blockTags = ['address', 'article', 'aside', 'blockquote', 'br', ' details', 'dialog', 'dd', 'div', 'dl', 'dt', + 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', + 'li', 'main', 'nav', 'ol', 'p', 'pre', 'section', 'table', 'ul']; + + // Clean HTML scripts and tags. + text = text.replace(/]*>([\S\s]*?)<\/script>/gmi, ''); + // Replace block tags by space to get word count aware of line break and remove inline tags. + text = text.replace(/<(\/[ ]*)?([a-zA-Z0-9]+)[^>]*>/gi, (str, p1, match) => { + if (blockTags.indexOf(match) >= 0) { + return ' '; + } + + return ''; + }); + // Decode HTML entities. + text = this.decodeHTMLEntities(text); + // Replace underscores (which are classed as word characters) with spaces. + text = text.replace(/_/gi, ' '); + + // This RegEx will detect any word change including Unicode chars. Some languages without spaces won't be counted fine. + return text.match(/\S+/gi).length; + } + + /** + * Decode an escaped HTML text. This implementation is based on PHP's htmlspecialchars_decode. + * + * @param text Text to decode. + * @return Decoded text. + */ + decodeHTML(text: string | number): string { + if (typeof text == 'undefined' || text === null || (typeof text == 'number' && isNaN(text))) { + return ''; + } else if (typeof text != 'string') { + return '' + text; + } + + return text + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '\'') + .replace(/ /g, ' '); + } + + /** + * Decode HTML entities in a text. Equivalent to PHP html_entity_decode. + * + * @param text Text to decode. + * @return Decoded text. + */ + decodeHTMLEntities(text: string): string { + if (text) { + const element = this.convertToElement(text); + text = element.textContent; + } + + return text; + } + + /** + * Same as Javascript's decodeURI, but if an exception is thrown it will return the original URI. + * + * @param uri URI to decode. + * @return Decoded URI, or original URI if an exception is thrown. + */ + decodeURI(uri: string): string { + try { + return decodeURI(uri); + } catch (ex) { + // Error, use the original URI. + } + + return uri; + } + + /** + * Same as Javascript's decodeURIComponent, but if an exception is thrown it will return the original URI. + * + * @param uri URI to decode. + * @return Decoded URI, or original URI if an exception is thrown. + */ + decodeURIComponent(uri: string): string { + try { + return decodeURIComponent(uri); + } catch (ex) { + // Error, use the original URI. + } + + return uri; + } + + /** + * Escapes some characters in a string to be used as a regular expression. + * + * @param text Text to escape. + * @return Escaped text. + */ + escapeForRegex(text: string): string { + if (!text || typeof text != 'string') { + return ''; + } + + return text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); + } + + /** + * Escape an HTML text. This implementation is based on PHP's htmlspecialchars. + * + * @param text Text to escape. + * @param doubleEncode If false, it will not convert existing html entities. Defaults to true. + * @return Escaped text. + */ + escapeHTML(text: string | number, doubleEncode: boolean = true): string { + if (typeof text == 'undefined' || text === null || (typeof text == 'number' && isNaN(text))) { + return ''; + } else if (typeof text != 'string') { + return '' + text; + } + + if (doubleEncode) { + text = text.replace(/&/g, '&'); + } else { + text = text.replace(/&(?!amp;)(?!lt;)(?!gt;)(?!quot;)(?!#039;)/g, '&'); + } + + return text + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + /** + * Shows a text on a new page. + * + * @param title Title of the new state. + * @param text Content of the text to be expanded. + * @param component Component to link the embedded files to. + * @param componentId An ID to use in conjunction with the component. + * @param files List of files to display along with the text. + * @param filter Whether the text should be filtered. + * @param contextLevel The context level. + * @param instanceId The instance ID related to the context. + * @param courseId Course ID the text belongs to. It can be used to improve performance with filters. + * @deprecated since 3.8.3. Please use viewText instead. + */ + expandText(title: string, text: string, component?: string, componentId?: string | number, files?: any[], + filter?: boolean, contextLevel?: string, instanceId?: number, courseId?: number): void { + + return this.viewText(title, text, { + component, + componentId, + files, + filter, + contextLevel, + instanceId, + courseId, + }); + } + + /** + * Formats a text, in HTML replacing new lines by correct html new lines. + * + * @param text Text to format. + * @return Formatted text. + */ + formatHtmlLines(text: string): string { + const hasHTMLTags = this.hasHTMLTags(text); + if (text.indexOf('

') == -1) { + // Wrap the text in

tags. + text = '

' + text + '

'; + } + + if (!hasHTMLTags) { + // The text doesn't have HTML, replace new lines for
. + return this.replaceNewLines(text, '
'); + } + + return text; + } + + /** + * Formats a text, treating multilang tags and cleaning HTML if needed. + * + * @param text Text to format. + * @param clean Whether HTML tags should be removed. + * @param singleLine Whether new lines should be removed. Only valid if clean is true. + * @param shortenLength Number of characters to shorten the text. + * @param highlight Text to highlight. + * @return Promise resolved with the formatted text. + * @deprecated since 3.8.0. Please use CoreFilterProvider.formatText instead. + */ + formatText(text: string, clean?: boolean, singleLine?: boolean, shortenLength?: number, highlight?: string): Promise { + return this.treatMultilangTags(text).then((formatted) => { + if (clean) { + formatted = this.cleanTags(formatted, singleLine); + } + if (shortenLength > 0) { + formatted = this.shortenText(formatted, shortenLength); + } + if (highlight) { + formatted = this.highlightText(formatted, highlight); + } + + return formatted; + }); + } + + /** + * Get the error message from an error object. + * + * @param error Error object. + * @return Error message, undefined if not found. + */ + getErrorMessageFromError(error: string | CoreTextErrorObject): string { + if (typeof error == 'string') { + return error; + } + + return error && (error.message || error.error || error.content || error.body); + } + + /** + * Get the pluginfile URL to replace @@PLUGINFILE@@ wildcards. + * + * @param files Files to extract the URL from. They need to have the URL in a 'url' or 'fileurl' attribute. + * @return Pluginfile URL, undefined if no files found. + */ + getTextPluginfileUrl(files: any[]): string { + if (files && files.length) { + const fileURL = files[0].url || files[0].fileurl; + + // Remove text after last slash (encoded or not). + return fileURL.substr(0, Math.max(fileURL.lastIndexOf('/'), fileURL.lastIndexOf('%2F'))); + } + + return undefined; + } + + /** + * Check if a text contains HTML tags. + * + * @param text Text to check. + * @return Whether it has HTML tags. + */ + hasHTMLTags(text: string): boolean { + return /<[a-z][\s\S]*>/i.test(text); + } + + /** + * Highlight all occurrences of a certain text inside another text. It will add some HTML code to highlight it. + * + * @param text Full text. + * @param searchText Text to search and highlight. + * @return Highlighted text. + */ + highlightText(text: string, searchText: string): string { + if (!text || typeof text != 'string') { + return ''; + } else if (!searchText) { + return text; + } + + const regex = new RegExp('(' + searchText + ')', 'gi'); + + return text.replace(regex, '$1'); + } + + /** + * Check if HTML content is blank. + * + * @param content HTML content. + * @return True if the string does not contain actual content: text, images, etc. + */ + htmlIsBlank(content: string): boolean { + if (!content) { + return true; + } + + this.template.innerHTML = content; + + return this.template.content.textContent == '' && this.template.content.querySelector('img, object, hr') === null; + } + + /** + * Check if a text contains Unicode long chars. + * Using as threshold Hex value D800 + * + * @param text Text to check. + * @return True if has Unicode chars, false otherwise. + */ + hasUnicode(text: string): boolean { + for (let x = 0; x < text.length; x++) { + if (text.charCodeAt(x) > 55295) { + return true; + } + } + + return false; + } + + /** + * Check if an object has any long Unicode char. + * + * @param data Object to be checked. + * @return If the data has any long Unicode char on it. + */ + hasUnicodeData(data: object): boolean { + for (const el in data) { + if (typeof data[el] == 'object') { + if (this.hasUnicodeData(data[el])) { + return true; + } + } else if (typeof data[el] == 'string' && this.hasUnicode(data[el])) { + return true; + } + } + + return false; + } + + /** + * Same as Javascript's JSON.parse, but it will handle errors. + * + * @param json JSON text. + * @param defaultValue Default value t oreturn if the parse fails. Defaults to the original value. + * @param logErrorFn An error to call with the exception to log the error. If not supplied, no error. + * @return JSON parsed as object or what it gets. + */ + parseJSON(json: string, defaultValue?: any, logErrorFn?: (error?: any) => void): any { + try { + return JSON.parse(json); + } catch (ex) { + // Error, log the error if needed. + if (logErrorFn) { + logErrorFn(ex); + } + } + + // Error parsing, return the default value or the original value. + return typeof defaultValue != 'undefined' ? defaultValue : json; + } + + /** + * Remove ending slash from a path or URL. + * + * @param text Text to treat. + * @return Treated text. + */ + removeEndingSlash(text: string): string { + if (!text) { + return ''; + } + + if (text.slice(-1) == '/') { + return text.substr(0, text.length - 1); + } + + return text; + } + + /** + * Replace all characters that cause problems with files in Android and iOS. + * + * @param text Text to treat. + * @return Treated text. + */ + removeSpecialCharactersForFiles(text: string): string { + if (!text || typeof text != 'string') { + return ''; + } + + return text.replace(/[#:\/\?\\]+/g, '_'); + } + + /** + * Replace all the new lines on a certain text. + * + * @param text The text to be treated. + * @param newValue Text to use instead of new lines. + * @return Treated text. + */ + replaceNewLines(text: string, newValue: string): string { + if (!text || typeof text != 'string') { + return ''; + } + + return text.replace(/(?:\r\n|\r|\n)/g, newValue); + } + + /** + * Replace @@PLUGINFILE@@ wildcards with the real URL in a text. + * + * @param Text to treat. + * @param files Files to extract the pluginfile URL from. They need to have the URL in a url or fileurl attribute. + * @return Treated text. + */ + replacePluginfileUrls(text: string, files: any[]): string { + if (text && typeof text == 'string') { + const fileURL = this.getTextPluginfileUrl(files); + if (fileURL) { + return text.replace(/@@PLUGINFILE@@/g, fileURL); + } + } + + return text; + } + + /** + * Replace pluginfile URLs with @@PLUGINFILE@@ wildcards. + * + * @param text Text to treat. + * @param files Files to extract the pluginfile URL from. They need to have the URL in a url or fileurl attribute. + * @return Treated text. + */ + restorePluginfileUrls(text: string, files: any[]): string { + if (text && typeof text == 'string') { + const fileURL = this.getTextPluginfileUrl(files); + if (fileURL) { + return text.replace(new RegExp(this.escapeForRegex(fileURL), 'g'), '@@PLUGINFILE@@'); + } + } + + return text; + } + + /** + * Rounds a number to use a certain amout of decimals or less. + * Difference between this function and float's toFixed: + * 7.toFixed(2) -> 7.00 + * roundToDecimals(7, 2) -> 7 + * + * @param num Number to round. + * @param decimals Number of decimals. By default, 2. + * @return Rounded number. + */ + roundToDecimals(num: number, decimals: number = 2): number { + const multiplier = Math.pow(10, decimals); + + return Math.round(num * multiplier) / multiplier; + } + + /** + * Add quotes to HTML characters. + * + * Returns text with HTML characters (like "<", ">", etc.) properly quoted. + * Based on Moodle's s() function. + * + * @param text Text to treat. + * @return Treated text. + */ + s(text: string): string { + if (!text) { + return ''; + } + + return this.escapeHTML(text).replace(/&#(\d+|x[0-9a-f]+);/i, '&#$1;'); + } + + /** + * Shortens a text to length and adds an ellipsis. + * + * @param text The text to be shortened. + * @param length The desired length. + * @return Shortened text. + */ + shortenText(text: string, length: number): string { + if (text.length > length) { + text = text.substr(0, length); + + // Now, truncate at the last word boundary (if exists). + const lastWordPos = text.lastIndexOf(' '); + if (lastWordPos > 0) { + text = text.substr(0, lastWordPos); + } + text += '…'; + } + + return text; + } + + /** + * Strip Unicode long char of a given text. + * Using as threshold Hex value D800 + * + * @param text Text to check. + * @return Without the Unicode chars. + */ + stripUnicode(text: string): string { + let stripped = ''; + for (let x = 0; x < text.length; x++) { + if (text.charCodeAt(x) <= 55295) { + stripped += text.charAt(x); + } + } + + return stripped; + } + + /** + * Replace text within a portion of a string. Equivalent to PHP's substr_replace. + * Credits to http://locutus.io/php/strings/substr_replace/ + * + * @param str The string to treat. + * @param replace The value to put inside the string. + * @param start The index where to start putting the new string. If negative, it will count from the end of the string. + * @param length Length of the portion of string which is to be replaced. If negative, it represents the number of characters + * from the end of string at which to stop replacing. If not provided, replace until the end of the string. + * @return Treated string. + */ + substrReplace(str: string, replace: string, start: number, length?: number): string { + length = typeof length != 'undefined' ? length : str.length; + + if (start < 0) { + start = start + str.length; + } + + if (length < 0) { + length = length + str.length - start; + } + + return [ + str.slice(0, start), + replace.substr(0, length), + replace.slice(length), + str.slice(start + length) + ].join(''); + } + + /** + * Treat the list of disabled features, replacing old nomenclature with the new one. + * + * @param features List of disabled features. + * @return Treated list. + */ + treatDisabledFeatures(features: string): string { + if (!features) { + return ''; + } + + for (let i = 0; i < this.DISABLED_FEATURES_COMPAT_REGEXPS.length; i++) { + const entry = this.DISABLED_FEATURES_COMPAT_REGEXPS[i]; + + features = features.replace(entry.old, entry.new); + } + + return features; + } + + /** + * Treat the multilang tags from a HTML code, leaving only the current language. + * + * @param text The text to be treated. + * @return Promise resolved with the formatted text. + * @deprecated since 3.8.0. Now this is handled by AddonFilterMultilangHandler. + */ + treatMultilangTags(text: string): Promise { + if (!text || typeof text != 'string') { + return Promise.resolve(''); + } + + return CoreLang.instance.getCurrentLanguage().then((language) => { + // Match the current language. + const anyLangRegEx = /<(?:lang|span)[^>]+lang="[a-zA-Z0-9_-]+"[^>]*>(.*?)<\/(?:lang|span)>/g; + let currentLangRegEx = new RegExp('<(?:lang|span)[^>]+lang="' + language + '"[^>]*>(.*?)<\/(?:lang|span)>', 'g'); + + if (!text.match(currentLangRegEx)) { + // Current lang not found. Try to find the first language. + const matches = text.match(anyLangRegEx); + if (matches && matches[0]) { + language = matches[0].match(/lang="([a-zA-Z0-9_-]+)"/)[1]; + currentLangRegEx = new RegExp('<(?:lang|span)[^>]+lang="' + language + '"[^>]*>(.*?)<\/(?:lang|span)>', 'g'); + } else { + // No multi-lang tag found, stop. + return text; + } + } + // Extract contents of current language. + text = text.replace(currentLangRegEx, '$1'); + // Delete the rest of languages + text = text.replace(anyLangRegEx, ''); + + return text; + }); + } + + /** + * If a number has only 1 digit, add a leading zero to it. + * + * @param num Number to convert. + * @return Number with leading zeros. + */ + twoDigits(num: string | number): string { + if (num < 10) { + return '0' + num; + } else { + return '' + num; // Convert to string for coherence. + } + } + + /** + * Make a string's first character uppercase. + * + * @param text Text to treat. + * @return Treated text. + */ + ucFirst(text: string): string { + return text.charAt(0).toUpperCase() + text.slice(1); + } + + /** + * Unserialize Array from PHP. + * Taken from: https://github.com/kvz/locutus/blob/master/src/php/var/unserialize.js + * + * @param data String to unserialize. + * @param logErrorFn An error to call with the exception to log the error. If not supplied, no error. + * @return Unserialized data. + */ + unserialize(data: string, logErrorFn?: (error?: string) => void): any { + // Discuss at: http://locutus.io/php/unserialize/ + // Original by: Arpad Ray (mailto:arpad@php.net) + // Improved by: Pedro Tainha (http://www.pedrotainha.com) + // Improved by: Kevin van Zonneveld (http://kvz.io) + // Improved by: Kevin van Zonneveld (http://kvz.io) + // Improved by: Chris + // Improved by: James + // Improved by: Le Torbi + // Improved by: Eli Skeggs + // Bugfixed by: dptr1988 + // Bugfixed by: Kevin van Zonneveld (http://kvz.io) + // Bugfixed by: Brett Zamir (http://brett-zamir.me) + // Bugfixed by: philippsimon (https://github.com/philippsimon/) + // Revised by: d3x + // Input by: Brett Zamir (http://brett-zamir.me) + // Input by: Martin (http://www.erlenwiese.de/) + // Input by: kilops + // Input by: Jaroslaw Czarniak + // Input by: lovasoa (https://github.com/lovasoa/) + // Note 1: We feel the main purpose of this function should be + // Note 1: to ease the transport of data between php & js + // Note 1: Aiming for PHP-compatibility, we have to translate objects to arrays + // Example 1: unserialize('a:3:{i:0;s:5:"Kevin";i:1;s:3:"van";i:2;s:9:"Zonneveld";}') + // Returns 1: ['Kevin', 'van', 'Zonneveld'] + // Example 2: unserialize('a:2:{s:9:"firstName";s:5:"Kevin";s:7:"midName";s:3:"van";}') + // Returns 2: {firstName: 'Kevin', midName: 'van'} + // Example 3: unserialize('a:3:{s:2:"ü";s:2:"ü";s:3:"四";s:3:"四";s:4:"𠜎";s:4:"𠜎";}') + // Returns 3: {'ü': 'ü', '四': '四', '𠜎': '𠜎'} + + const utf8Overhead = (str: string): number => { + let s = str.length; + + for (let i = str.length - 1; i >= 0; i--) { + const code = str.charCodeAt(i); + if (code > 0x7f && code <= 0x7ff) { + s++; + } else if (code > 0x7ff && code <= 0xffff) { + s += 2; + } + // Trail surrogate. + if (code >= 0xDC00 && code <= 0xDFFF) { + i--; + } + } + + return s - 1; + }; + + const error = (type: string, msg: string): void => { + if (logErrorFn) { + logErrorFn(type + msg); + } + }; + + const readUntil = (data: string, offset: number, stopchr: string): Array => { + let i = 2; + const buf = []; + let chr = data.slice(offset, offset + 1); + + while (chr !== stopchr) { + if ((i + offset) > data.length) { + error('Error', 'Invalid'); + } + buf.push(chr); + chr = data.slice(offset + (i - 1), offset + i); + i += 1; + } + + return [buf.length, buf.join('')]; + }; + + const readChrs = (data: string, offset: number, length: number): Array => { + let chr; + const buf = []; + + for (let i = 0; i < length; i++) { + chr = data.slice(offset + (i - 1), offset + i); + buf.push(chr); + length -= utf8Overhead(chr); + } + + return [buf.length, buf.join('')]; + }; + + const _unserialize = (data: string, offset: number): any => { + let dtype, + dataoffset, + keyandchrs, + keys, + contig, + length, + array, + readdata, + readData, + ccount, + stringlength, + i, + key, + kprops, + kchrs, + vprops, + vchrs, + value, + chrs = 0, + typeconvert = (x: any): any => { + return x; + }; + + if (!offset) { + offset = 0; + } + dtype = (data.slice(offset, offset + 1)).toLowerCase(); + + dataoffset = offset + 2; + + switch (dtype) { + case 'i': + typeconvert = (x: any): number => { + return parseInt(x, 10); + }; + readData = readUntil(data, dataoffset, ';'); + chrs = readData[0]; + readdata = readData[1]; + dataoffset += chrs + 1; + break; + case 'b': + typeconvert = (x: any): boolean => { + return parseInt(x, 10) !== 0; + }; + readData = readUntil(data, dataoffset, ';'); + chrs = readData[0]; + readdata = readData[1]; + dataoffset += chrs + 1; + break; + case 'd': + typeconvert = (x: any): number => { + return parseFloat(x); + }; + readData = readUntil(data, dataoffset, ';'); + chrs = readData[0]; + readdata = readData[1]; + dataoffset += chrs + 1; + break; + case 'n': + readdata = null; + break; + case 's': + ccount = readUntil(data, dataoffset, ':'); + chrs = ccount[0]; + stringlength = ccount[1]; + dataoffset += chrs + 2; + + readData = readChrs(data, dataoffset + 1, parseInt(stringlength, 10)); + chrs = readData[0]; + readdata = readData[1]; + dataoffset += chrs + 2; + if (chrs !== parseInt(stringlength, 10) && chrs !== readdata.length) { + error('SyntaxError', 'String length mismatch'); + } + break; + case 'a': + readdata = {}; + + keyandchrs = readUntil(data, dataoffset, ':'); + chrs = keyandchrs[0]; + keys = keyandchrs[1]; + dataoffset += chrs + 2; + + length = parseInt(keys, 10); + contig = true; + + for (let i = 0; i < length; i++) { + kprops = _unserialize(data, dataoffset); + kchrs = kprops[1]; + key = kprops[2]; + dataoffset += kchrs; + + vprops = _unserialize(data, dataoffset); + vchrs = vprops[1]; + value = vprops[2]; + dataoffset += vchrs; + + if (key !== i) { + contig = false; + } + + readdata[key] = value; + } + + if (contig) { + array = new Array(length); + for (i = 0; i < length; i++) { + array[i] = readdata[i]; + } + readdata = array; + } + + dataoffset += 1; + break; + default: + error('SyntaxError', 'Unknown / Unhandled data type(s): ' + dtype); + break; + } + + return [dtype, dataoffset - offset, typeconvert(readdata)]; + }; + + return _unserialize((data + ''), 0)[2]; + } + + /** + * Shows a text on a new page. + * + * @param title Title of the new state. + * @param text Content of the text to be expanded. + * @param component Component to link the embedded files to. + * @param componentId An ID to use in conjunction with the component. + * @param files List of files to display along with the text. + * @param filter Whether the text should be filtered. + * @param contextLevel The context level. + * @param instanceId The instance ID related to the context. + * @param courseId Course ID the text belongs to. It can be used to improve performance with filters. + */ + viewText(title: string, text: string, options?: CoreTextUtilsViewTextOptions): void { + // @todo + } +} + +/** + * Options for viewText. + */ +export type CoreTextUtilsViewTextOptions = { + component?: string; // Component to link the embedded files to. + componentId?: string | number; // An ID to use in conjunction with the component. + files?: any[]; // List of files to display along with the text. + filter?: boolean; // Whether the text should be filtered. + contextLevel?: string; // The context level. + instanceId?: number; // The instance ID related to the context. + courseId?: number; // Course ID the text belongs to. It can be used to improve performance with filters. + displayCopyButton?: boolean; // Whether to display a button to copy the text. + // modalOptions?: ModalOptions; // Modal options. @todo +}; + +export class CoreTextUtils extends makeSingleton(CoreTextUtilsProvider) {} diff --git a/src/app/services/utils/time.ts b/src/app/services/utils/time.ts new file mode 100644 index 000000000..05ec9a78b --- /dev/null +++ b/src/app/services/utils/time.ts @@ -0,0 +1,371 @@ +// (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 * as moment from 'moment'; +import { CoreConstants } from '@core/constants'; +import { makeSingleton, Translate } from '@singletons/core.singletons'; + +/* + * "Utils" service with helper functions for date and time. +*/ +@Injectable() +export class CoreTimeUtilsProvider { + + protected FORMAT_REPLACEMENTS = { // To convert PHP strf format to Moment format. + '%a': 'ddd', + '%A': 'dddd', + '%d': 'DD', + '%e': 'D', // Not exactly the same. PHP adds a space instead of leading zero, Moment doesn't. + '%j': 'DDDD', + '%u': 'E', + '%w': 'e', // It might not behave exactly like PHP, the first day could be calculated differently. + '%U': 'ww', // It might not behave exactly like PHP, the first week could be calculated differently. + '%V': 'WW', + '%W': 'ww', // It might not behave exactly like PHP, the first week could be calculated differently. + '%b': 'MMM', + '%B': 'MMMM', + '%h': 'MMM', + '%m': 'MM', + '%C' : '', // Not supported by Moment. + '%g': 'GG', + '%G': 'GGGG', + '%y': 'YY', + '%Y': 'YYYY', + '%H': 'HH', + '%k': 'H', // Not exactly the same. PHP adds a space instead of leading zero, Moment doesn't. + '%I': 'hh', + '%l': 'h', // Not exactly the same. PHP adds a space instead of leading zero, Moment doesn't. + '%M': 'mm', + '%p': 'A', + '%P': 'a', + '%r': 'hh:mm:ss A', + '%R': 'HH:mm', + '%S': 'ss', + '%T': 'HH:mm:ss', + '%X': 'LTS', + '%z': 'ZZ', + '%Z': 'ZZ', // Not supported by Moment, it was deprecated. Use the same as %z. + '%c': 'LLLL', + '%D': 'MM/DD/YY', + '%F': 'YYYY-MM-DD', + '%s': 'X', + '%x': 'L', + '%n': '\n', + '%t': '\t', + '%%': '%' + }; + + /** + * Convert a PHP format to a Moment format. + * + * @param format PHP format. + * @return Converted format. + */ + convertPHPToMoment(format: string): string { + if (typeof format != 'string') { + // Not valid. + return ''; + } + + let converted = ''; + let escaping = false; + + for (let i = 0; i < format.length; i++) { + let char = format[i]; + + if (char == '%') { + // It's a PHP format, try to convert it. + i++; + char += format[i] || ''; + + if (escaping) { + // We were escaping some characters, stop doing it now. + escaping = false; + converted += ']'; + } + + converted += typeof this.FORMAT_REPLACEMENTS[char] != 'undefined' ? this.FORMAT_REPLACEMENTS[char] : char; + } else { + // Not a PHP format. We need to escape them, otherwise the letters could be confused with Moment formats. + if (!escaping) { + escaping = true; + converted += '['; + } + + converted += char; + } + } + + if (escaping) { + // Finish escaping. + converted += ']'; + } + + return converted; + } + + /** + * Fix format to use in an ion-datetime. + * + * @param format Format to use. + * @return Fixed format. + */ + fixFormatForDatetime(format: string): string { + if (!format) { + return ''; + } + + // The component ion-datetime doesn't support escaping characters ([]), so we remove them. + let fixed = format.replace(/[\[\]]/g, ''); + + if (fixed.indexOf('A') != -1) { + // Do not use am/pm format because there is a bug in ion-datetime. + fixed = fixed.replace(/ ?A/g, ''); + fixed = fixed.replace(/h/g, 'H'); + } + + return fixed; + } + + /** + * Returns hours, minutes and seconds in a human readable format + * + * @param seconds A number of seconds + * @return Seconds in a human readable format. + */ + formatTime(seconds: number): string { + const totalSecs = Math.abs(seconds); + const years = Math.floor(totalSecs / CoreConstants.SECONDS_YEAR); + let remainder = totalSecs - (years * CoreConstants.SECONDS_YEAR); + const days = Math.floor(remainder / CoreConstants.SECONDS_DAY); + + remainder = totalSecs - (days * CoreConstants.SECONDS_DAY); + + const hours = Math.floor(remainder / CoreConstants.SECONDS_HOUR); + remainder = remainder - (hours * CoreConstants.SECONDS_HOUR); + + const mins = Math.floor(remainder / CoreConstants.SECONDS_MINUTE); + const secs = remainder - (mins * CoreConstants.SECONDS_MINUTE); + + const ss = Translate.instance.instant('core.' + (secs == 1 ? 'sec' : 'secs')); + const sm = Translate.instance.instant('core.' + (mins == 1 ? 'min' : 'mins')); + const sh = Translate.instance.instant('core.' + (hours == 1 ? 'hour' : 'hours')); + const sd = Translate.instance.instant('core.' + (days == 1 ? 'day' : 'days')); + const sy = Translate.instance.instant('core.' + (years == 1 ? 'year' : 'years')); + let oyears = ''; + let odays = ''; + let ohours = ''; + let omins = ''; + let osecs = ''; + + if (years) { + oyears = years + ' ' + sy; + } + if (days) { + odays = days + ' ' + sd; + } + if (hours) { + ohours = hours + ' ' + sh; + } + if (mins) { + omins = mins + ' ' + sm; + } + if (secs) { + osecs = secs + ' ' + ss; + } + + if (years) { + return oyears + ' ' + odays; + } + if (days) { + return odays + ' ' + ohours; + } + if (hours) { + return ohours + ' ' + omins; + } + if (mins) { + return omins + ' ' + osecs; + } + if (secs) { + return osecs; + } + + return Translate.instance.instant('core.now'); + } + + /** + * Returns hours, minutes and seconds in a human readable format. + * + * @param duration Duration in seconds + * @param precision Number of elements to have in precision. 0 or undefined to full precission. + * @return Duration in a human readable format. + */ + formatDuration(duration: number, precision?: number): string { + precision = precision || 5; + + const eventDuration = moment.duration(duration, 'seconds'); + let durationString = ''; + + if (precision && eventDuration.years() > 0) { + durationString += ' ' + moment.duration(eventDuration.years(), 'years').humanize(); + precision--; + } + if (precision && eventDuration.months() > 0) { + durationString += ' ' + moment.duration(eventDuration.months(), 'months').humanize(); + precision--; + } + if (precision && eventDuration.days() > 0) { + durationString += ' ' + moment.duration(eventDuration.days(), 'days').humanize(); + precision--; + } + if (precision && eventDuration.hours() > 0) { + durationString += ' ' + moment.duration(eventDuration.hours(), 'hours').humanize(); + precision--; + } + if (precision && eventDuration.minutes() > 0) { + durationString += ' ' + moment.duration(eventDuration.minutes(), 'minutes').humanize(); + precision--; + } + + return durationString.trim(); + } + + /** + * Returns duration in a short human readable format: minutes and seconds, in fromat: 3' 27''. + * + * @param duration Duration in seconds + * @return Duration in a short human readable format. + */ + formatDurationShort(duration: number): string { + + const minutes = Math.floor(duration / 60); + const seconds = duration - minutes * 60; + const durations = []; + + if (minutes > 0) { + durations.push(minutes + '\''); + } + + if (seconds > 0 || minutes === 0) { + durations.push(seconds + '\'\''); + } + + return durations.join(' '); + } + + /** + * Return the current timestamp in a "readable" format: YYYYMMDDHHmmSS. + * + * @return The readable timestamp. + */ + readableTimestamp(): string { + return moment(Date.now()).format('YYYYMMDDHHmmSS'); + } + + /** + * Return the current timestamp (UNIX format, seconds). + * + * @return The current timestamp in seconds. + */ + timestamp(): number { + return Math.round(Date.now() / 1000); + } + + /** + * Convert a timestamp into a readable date. + * + * @param timestamp Timestamp in milliseconds. + * @param format The format to use (lang key). Defaults to core.strftimedaydatetime. + * @param convert If true (default), convert the format from PHP to Moment. Set it to false for Moment formats. + * @param fixDay If true (default) then the leading zero from %d is removed. + * @param fixHour If true (default) then the leading zero from %I is removed. + * @return Readable date. + */ + userDate(timestamp: number, format?: string, convert: boolean = true, fixDay: boolean = true, fixHour: boolean = true): string { + format = Translate.instance.instant(format ? format : 'core.strftimedaydatetime'); + + if (fixDay) { + format = format.replace(/%d/g, '%e'); + } + + if (fixHour) { + format = format.replace('%I', '%l'); + } + + // Format could be in PHP format, convert it to moment. + if (convert) { + format = this.convertPHPToMoment(format); + } + + return moment(timestamp).format(format); + } + + /** + * Convert a timestamp to the format to set to a datetime input. + * + * @param timestamp Timestamp to convert (in ms). If not provided, current time. + * @return Formatted time. + */ + toDatetimeFormat(timestamp?: number): string { + timestamp = timestamp || Date.now(); + + return this.userDate(timestamp, 'YYYY-MM-DDTHH:mm:ss.SSS', false) + 'Z'; + } + + /** + * Convert a text into user timezone timestamp. + * + * @param date To convert to timestamp. + * @return Converted timestamp. + */ + convertToTimestamp(date: string): number { + if (typeof date == 'string' && date.slice(-1) == 'Z') { + return moment(date).unix() - (moment().utcOffset() * 60); + } + + return moment(date).unix(); + } + + /** + * Return the localized ISO format (i.e DDMMYY) from the localized moment format. Useful for translations. + * DO NOT USE this function for ion-datetime format. Moment escapes characters with [], but ion-datetime doesn't support it. + * + * @param localizedFormat Format to use. + * @return Localized ISO format + */ + getLocalizedDateFormat(localizedFormat: any): string { + return moment.localeData().longDateFormat(localizedFormat); + } + + /** + * For a given timestamp get the midnight value in the user's timezone. + * + * The calculation is performed relative to the user's midnight timestamp + * for today to ensure that timezones are preserved. + * + * @param timestamp The timestamp to calculate from. If not defined, return today's midnight. + * @return The midnight value of the user's timestamp. + */ + getMidnightForTimestamp(timestamp?: number): number { + if (timestamp) { + return moment(timestamp * 1000).startOf('day').unix(); + } else { + return moment().startOf('day').unix(); + } + } +} + +export class CoreTimeUtils extends makeSingleton(CoreTimeUtilsProvider) {} diff --git a/src/app/services/utils/url.ts b/src/app/services/utils/url.ts new file mode 100644 index 000000000..0ebee5cbe --- /dev/null +++ b/src/app/services/utils/url.ts @@ -0,0 +1,520 @@ +// (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 { CoreLang } from '@services/lang'; +import { CoreTextUtils } from '@services/utils/text'; +import CoreConfigConstants from '@app/config.json'; +import { makeSingleton } from '@singletons/core.singletons'; +import { CoreUrl } from '@singletons/url'; + +/* + * "Utils" service with helper functions for URLs. + */ +@Injectable() +export class CoreUrlUtilsProvider { + + /** + * Add or remove 'www' from a URL. The url needs to have http or https protocol. + * + * @param url URL to modify. + * @return Modified URL. + */ + addOrRemoveWWW(url: string): string { + if (url) { + if (url.match(/http(s)?:\/\/www\./)) { + // Already has www. Remove it. + url = url.replace('www.', ''); + } else { + url = url.replace('https://', 'https://www.'); + url = url.replace('http://', 'http://www.'); + } + } + + return url; + } + + /** + * Add params to a URL. + * + * @param url URL to add the params to. + * @param params Object with the params to add. + * @param anchor Anchor text if needed. + * @param boolToNumber Whether to convert bools to 1 or 0. + * @return URL with params. + */ + addParamsToUrl(url: string, params?: {[key: string]: any}, anchor?: string, boolToNumber?: boolean): string { + let separator = url.indexOf('?') != -1 ? '&' : '?'; + + for (const key in params) { + let value = params[key]; + + if (boolToNumber && typeof value == 'boolean') { + // Convert booleans to 1 or 0. + value = value ? 1 : 0; + } + + // Ignore objects. + if (typeof value != 'object') { + url += separator + key + '=' + value; + separator = '&'; + } + } + + if (anchor) { + url += '#' + anchor; + } + + return url; + } + + /** + * Given a URL and a text, return an HTML link. + * + * @param url URL. + * @param text Text of the link. + * @return Link. + */ + buildLink(url: string, text: string): string { + return '
' + text + ''; + } + + /** + * Check whether we can use tokenpluginfile.php endpoint for a certain URL. + * + * @param url URL to check. + * @param siteUrl The URL of the site the URL belongs to. + * @param accessKey User access key for tokenpluginfile. + * @return Whether tokenpluginfile.php can be used. + */ + canUseTokenPluginFile(url: string, siteUrl: string, accessKey?: string): boolean { + // Do not use tokenpluginfile if site doesn't use slash params, the URL doesn't work. + // Also, only use it for "core" pluginfile endpoints. Some plugins can implement their own endpoint (like customcert). + return accessKey && !url.match(/[\&?]file=/) && ( + url.indexOf(CoreTextUtils.instance.concatenatePaths(siteUrl, 'pluginfile.php')) === 0 || + url.indexOf(CoreTextUtils.instance.concatenatePaths(siteUrl, 'webservice/pluginfile.php')) === 0); + } + + /** + * Extracts the parameters from a URL and stores them in an object. + * + * @param url URL to treat. + * @return Object with the params. + */ + extractUrlParams(url: string): any { + const regex = /[?&]+([^=&]+)=?([^&]*)?/gi; + const subParamsPlaceholder = '@@@SUBPARAMS@@@'; + const params: any = {}; + const urlAndHash = url.split('#'); + const questionMarkSplit = urlAndHash[0].split('?'); + let subParams; + + if (questionMarkSplit.length > 2) { + // There is more than one question mark in the URL. This can happen if any of the params is a URL with params. + // We only want to treat the first level of params, so we'll remove this second list of params and restore it later. + questionMarkSplit.splice(0, 2); + + subParams = '?' + questionMarkSplit.join('?'); + urlAndHash[0] = urlAndHash[0].replace(subParams, subParamsPlaceholder); + } + + urlAndHash[0].replace(regex, (match: string, key: string, value: string): string => { + params[key] = typeof value != 'undefined' ? CoreTextUtils.instance.decodeURIComponent(value) : ''; + + if (subParams) { + params[key] = params[key].replace(subParamsPlaceholder, subParams); + } + + return match; + }); + + if (urlAndHash.length > 1) { + // Remove the URL from the array. + urlAndHash.shift(); + + // Add the hash as a param with a special name. Use a join in case there is more than one #. + params.urlHash = urlAndHash.join('#'); + } + + return params; + } + + /** + * Generic function for adding the wstoken to Moodle urls and for pointing to the correct script. + * For download remote files from Moodle we need to use the special /webservice/pluginfile passing + * the ws token as a get parameter. + * + * @param url The url to be fixed. + * @param token Token to use. + * @param siteUrl The URL of the site the URL belongs to. + * @param accessKey User access key for tokenpluginfile. + * @return Fixed URL. + */ + fixPluginfileURL(url: string, token: string, siteUrl: string, accessKey?: string): string { + if (!url) { + return ''; + } + + url = url.replace(/&/g, '&'); + + const canUseTokenPluginFile = accessKey && this.canUseTokenPluginFile(url, siteUrl, accessKey); + + // First check if we need to fix this url or is already fixed. + if (!canUseTokenPluginFile && url.indexOf('token=') != -1) { + return url; + } + + // Check if is a valid URL (contains the pluginfile endpoint) and belongs to the site. + if (!this.isPluginFileUrl(url) || url.indexOf(CoreTextUtils.instance.addEndingSlash(siteUrl)) !== 0) { + return url; + } + + if (canUseTokenPluginFile) { + // Use tokenpluginfile.php. + url = url.replace(/(\/webservice)?\/pluginfile\.php/, '/tokenpluginfile.php/' + accessKey); + } else { + // Use pluginfile.php. Some webservices returns directly the correct download url, others not. + if (url.indexOf(CoreTextUtils.instance.concatenatePaths(siteUrl, 'pluginfile.php')) === 0) { + url = url.replace('/pluginfile', '/webservice/pluginfile'); + } + + url = this.addParamsToUrl(url, {token}); + } + + return this.addParamsToUrl(url, {offline: 1}); // Always send offline=1 (it's for external repositories). + } + + /** + * Formats a URL, trim, lowercase, etc... + * + * @param url The url to be formatted. + * @return Fromatted url. + */ + formatURL(url: string): string { + url = url.trim(); + + // Check if the URL starts by http or https. + if (! /^http(s)?\:\/\/.*/i.test(url)) { + // Test first allways https. + url = 'https://' + url; + } + + // http always in lowercase. + url = url.replace(/^http/i, 'http'); + url = url.replace(/^https/i, 'https'); + + // Replace last slash. + url = url.replace(/\/$/, ''); + + return url; + } + + /** + * Returns the URL to the documentation of the app, based on Moodle version and current language. + * + * @param release Moodle release. + * @param page Docs page to go to. + * @return Promise resolved with the Moodle docs URL. + */ + getDocsUrl(release?: string, page: string = 'Mobile_app'): Promise { + let docsUrl = 'https://docs.moodle.org/en/' + page; + + if (typeof release != 'undefined') { + const version = release.substr(0, 3).replace('.', ''); + // Check is a valid number. + if (Number(version) >= 24) { + // Append release number. + docsUrl = docsUrl.replace('https://docs.moodle.org/', 'https://docs.moodle.org/' + version + '/'); + } + } + + return CoreLang.instance.getCurrentLanguage().then((lang) => { + return docsUrl.replace('/en/', '/' + lang + '/'); + }).catch(() => { + return docsUrl; + }); + } + + /** + * Returns the Youtube Embed Video URL or null if not found. + * + * @param url URL + * @return Youtube Embed Video URL or null if not found. + */ + getYoutubeEmbedUrl(url: string): string { + if (!url) { + return; + } + + let videoId; + const params: any = {}; + + url = CoreTextUtils.instance.decodeHTML(url); + + // Get the video ID. + let match = url.match(/^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/); + + if (match && match[2].length === 11) { + videoId = match[2]; + } + + // No videoId, do not continue. + if (!videoId) { + return; + } + + // Now get the playlist (if any). + match = url.match(/[?&]list=([^#\&\?]+)/); + + if (match && match[1]) { + params.list = match[1]; + } + + // Now get the start time (if any). + match = url.match(/[?&]start=(\d+)/); + + if (match && match[1]) { + params.start = parseInt(match[1], 10); + } else { + // No start param, but it could have a time param. + match = url.match(/[?&]t=(\d+h)?(\d+m)?(\d+s)?/); + if (match) { + params.start = (match[1] ? parseInt(match[1], 10) * 3600 : 0) + (match[2] ? parseInt(match[2], 10) * 60 : 0) + + (match[3] ? parseInt(match[3], 10) : 0); + } + } + + return this.addParamsToUrl('https://www.youtube.com/embed/' + videoId, params); + } + + /** + * Given a URL, returns what's after the last '/' without params. + * Example: + * http://mysite.com/a/course.html?id=1 -> course.html + * + * @param url URL to treat. + * @return Last file without params. + */ + getLastFileWithoutParams(url: string): string { + let filename = url.substr(url.lastIndexOf('/') + 1); + if (filename.indexOf('?') != -1) { + filename = filename.substr(0, filename.indexOf('?')); + } + + return filename; + } + + /** + * Get the protocol from a URL. + * E.g. http://www.google.com returns 'http'. + * + * @param url URL to treat. + * @return Protocol, undefined if no protocol found. + */ + getUrlProtocol(url: string): string { + if (!url) { + return; + } + + const matches = url.match(/^([^\/:\.\?]*):\/\//); + if (matches && matches[1]) { + return matches[1]; + } + } + + /** + * Get the scheme from a URL. Please notice that, if a URL has protocol, it will return the protocol. + * E.g. javascript:doSomething() returns 'javascript'. + * + * @param url URL to treat. + * @return Scheme, undefined if no scheme found. + */ + getUrlScheme(url: string): string { + if (!url) { + return; + } + + const matches = url.match(/^([a-z][a-z0-9+\-.]*):/); + if (matches && matches[1]) { + return matches[1]; + } + } + + /* + * Gets a username from a URL like: user@mysite.com. + * + * @param url URL to treat. + * @return Username. Undefined if no username found. + */ + getUsernameFromUrl(url: string): string { + if (url.indexOf('@') > -1) { + // Get URL without protocol. + const withoutProtocol = url.replace(/^[^?@\/]*:\/\//, ''); + const matches = withoutProtocol.match(/[^@]*/); + + // Make sure that @ is at the start of the URL, not in a param at the end. + if (matches && matches.length && !matches[0].match(/[\/|?]/)) { + return matches[0]; + } + } + } + + /** + * Returns if a URL has any protocol (not a relative URL). + * + * @param url The url to test against the pattern. + * @return Whether the url is absolute. + */ + isAbsoluteURL(url: string): boolean { + return /^[^:]{2,}:\/\//i.test(url) || /^(tel:|mailto:|geo:)/.test(url); + } + + /** + * Returns if a URL is downloadable: plugin file OR theme/image.php OR gravatar. + * + * @param url The URL to test. + * @return Whether the URL is downloadable. + */ + isDownloadableUrl(url: string): boolean { + return this.isPluginFileUrl(url) || this.isThemeImageUrl(url) || this.isGravatarUrl(url); + } + + /** + * Returns if a URL is a gravatar URL. + * + * @param url The URL to test. + * @return Whether the URL is a gravatar URL. + */ + isGravatarUrl(url: string): boolean { + return url && url.indexOf('gravatar.com/avatar') !== -1; + } + + /** + * Check if a URL uses http or https protocol. + * + * @param url The url to test. + * @return Whether the url uses http or https protocol. + */ + isHttpURL(url: string): boolean { + return /^https?\:\/\/.+/i.test(url); + } + + /** + * Check whether an URL belongs to a local file. + * + * @param url URL to check. + * @return Whether the URL belongs to a local file. + */ + isLocalFileUrl(url: string): boolean { + const urlParts = CoreUrl.parse(url); + + return this.isLocalFileUrlScheme(urlParts.protocol, urlParts.domain); + } + + /** + * Check whether a URL scheme belongs to a local file. + * + * @param scheme Scheme to check. + * @param domain The domain. Needed because in Android the WebView scheme is http. + * @return Whether the scheme belongs to a local file. + */ + isLocalFileUrlScheme(scheme: string, domain: string): boolean { + if (scheme) { + scheme = scheme.toLowerCase(); + } + + return scheme == 'cdvfile' || + scheme == 'file' || + scheme == 'filesystem' || + scheme == CoreConfigConstants.ioswebviewscheme; + } + + /** + * Returns if a URL is a pluginfile URL. + * + * @param url The URL to test. + * @return Whether the URL is a pluginfile URL. + */ + isPluginFileUrl(url: string): boolean { + return url && url.indexOf('/pluginfile.php') !== -1; + } + + /** + * Returns if a URL is a theme image URL. + * + * @param url The URL to test. + * @return Whether the URL is a theme image URL. + */ + isThemeImageUrl(url: string): boolean { + return url && url.indexOf('/theme/image.php') !== -1; + } + + /** + * Remove protocol and www from a URL. + * + * @param url URL to treat. + * @return Treated URL. + */ + removeProtocolAndWWW(url: string): string { + // Remove protocol. + url = url.replace(/.*?:\/\//g, ''); + // Remove www. + url = url.replace(/^www./, ''); + + return url; + } + + /** + * Remove the parameters from a URL, returning the URL without them. + * + * @param url URL to treat. + * @return URL without params. + */ + removeUrlParams(url: string): string { + const matches = url.match(/^[^\?]+/); + + return matches && matches[0]; + } + + /** + * Modifies a pluginfile URL to use the default pluginfile script instead of the webservice one. + * + * @param url The url to be fixed. + * @param siteUrl The URL of the site the URL belongs to. + * @return Modified URL. + */ + unfixPluginfileURL(url: string, siteUrl?: string): string { + if (!url) { + return ''; + } + + url = url.replace(/&/g, '&'); + + // It site URL is supplied, check if the URL belongs to the site. + if (siteUrl && url.indexOf(CoreTextUtils.instance.addEndingSlash(siteUrl)) !== 0) { + return url; + } + + // Not a pluginfile URL. Treat webservice/pluginfile case. + url = url.replace(/\/webservice\/pluginfile\.php\//, '/pluginfile.php/'); + + // Make sure the URL doesn't contain the token. + url.replace(/([?&])token=[^&]*&?/, '$1'); + + return url; + } +} + +export class CoreUrlUtils extends makeSingleton(CoreUrlUtilsProvider) {} diff --git a/src/app/services/utils/utils.ts b/src/app/services/utils/utils.ts index 6bb8618f8..fa72b3e48 100644 --- a/src/app/services/utils/utils.ts +++ b/src/app/services/utils/utils.ts @@ -12,19 +12,71 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Injectable } from '@angular/core'; +import { Injectable, NgZone } from '@angular/core'; +import { InAppBrowserObject } from '@ionic-native/in-app-browser'; +import { Subscription } from 'rxjs'; + +import { CoreApp } from '@services/app'; +import { CoreEvents, CoreEventsProvider } from '@services/events'; +import { CoreFile } from '@services/file'; +import { CoreLang } from '@services/lang'; +import { CoreWS, CoreWSError } from '@services/ws'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreMimetypeUtils } from '@services/utils/mimetype'; +import { CoreTextUtils } from '@services/utils/text'; +import { + makeSingleton, Clipboard, InAppBrowser, Platform, FileOpener, WebIntent, QRScanner, Translate +} from '@singletons/core.singletons'; import { CoreLogger } from '@singletons/logger'; -import { makeSingleton } from '@singletons/core.singletons'; /* * "Utils" service with helper functions. */ @Injectable() export class CoreUtilsProvider { + protected DONT_CLONE = ['[object FileEntry]', '[object DirectoryEntry]', '[object DOMFileSystem]']; protected logger: CoreLogger; + protected iabInstance: InAppBrowserObject; + protected uniqueIds: {[name: string]: number} = {}; + protected qrScanData: {deferred: PromiseDefer, observable: Subscription}; - constructor() { + constructor(protected zone: NgZone) { this.logger = CoreLogger.getInstance('CoreUtilsProvider'); + + Platform.instance.ready().then(() => { + const win = window; + + if (win.cordova && win.cordova.InAppBrowser) { + // Override the default window.open with the InAppBrowser one. + win.open = win.cordova.InAppBrowser.open; + } + }); + } + + /** + * Given an error, add an extra warning to the error message and return the new error message. + * + * @param error Error object or message. + * @param defaultError Message to show if the error is not a string. + * @return New error message. + */ + addDataNotDownloadedError(error: any, defaultError?: string): string { + let errorMessage = error; + + if (error && typeof error != 'string') { + errorMessage = CoreTextUtils.instance.getErrorMessageFromError(error); + } + + if (typeof errorMessage != 'string') { + errorMessage = defaultError || ''; + } + + if (!this.isWebServiceError(error)) { + // Local error. Add an extra warning. + errorMessage += '

' + Translate.instance.instant('core.errorsomedatanotdownloaded'); + } + + return errorMessage; } /** @@ -64,29 +116,278 @@ export class CoreUtilsProvider { }); } + /** + * Converts an array of objects to an object, using a property of each entry as the key. + * It can also be used to convert an array of strings to an object where the keys are the elements of the array. + * E.g. [{id: 10, name: 'A'}, {id: 11, name: 'B'}] => {10: {id: 10, name: 'A'}, 11: {id: 11, name: 'B'}} + * + * @param array The array to convert. + * @param propertyName The name of the property to use as the key. If not provided, the whole item will be used. + * @param result Object where to put the properties. If not defined, a new object will be created. + * @return The object. + */ + arrayToObject(array: any[], propertyName?: string, result?: any): any { + result = result || {}; + array.forEach((entry) => { + const key = propertyName ? entry[propertyName] : entry; + result[key] = entry; + }); + + return result; + } + + /** + * Compare two objects. This function won't compare functions and proto properties, it's a basic compare. + * Also, this will only check if itemA's properties are in itemB with same value. This function will still + * return true if itemB has more properties than itemA. + * + * @param itemA First object. + * @param itemB Second object. + * @param maxLevels Number of levels to reach if 2 objects are compared. + * @param level Current deep level (when comparing objects). + * @param undefinedIsNull True if undefined is equal to null. Defaults to true. + * @return Whether both items are equal. + */ + basicLeftCompare(itemA: any, itemB: any, maxLevels: number = 0, level: number = 0, undefinedIsNull: boolean = true): boolean { + if (typeof itemA == 'function' || typeof itemB == 'function') { + return true; // Don't compare functions. + } else if (typeof itemA == 'object' && typeof itemB == 'object') { + if (level >= maxLevels) { + return true; // Max deep reached. + } + + let equal = true; + for (const name in itemA) { + const value = itemA[name]; + if (name == '$$hashKey') { + // Ignore $$hashKey property since it's a "calculated" property. + return; + } + + if (!this.basicLeftCompare(value, itemB[name], maxLevels, level + 1)) { + equal = false; + } + } + + return equal; + } else { + if (undefinedIsNull && ( + (typeof itemA == 'undefined' && itemB === null) || (itemA === null && typeof itemB == 'undefined'))) { + return true; + } + + // We'll treat "2" and 2 as the same value. + const floatA = parseFloat(itemA); + const floatB = parseFloat(itemB); + + if (!isNaN(floatA) && !isNaN(floatB)) { + return floatA == floatB; + } + + return itemA === itemB; + } + } + + /** + * Blocks leaving a view. + * @deprecated, use ionViewCanLeave instead. + */ + blockLeaveView(): void { + return; + } + + /** + * Check if a URL has a redirect. + * + * @param url The URL to check. + * @return Promise resolved with boolean_ whether there is a redirect. + */ + checkRedirect(url: string): Promise { + if (window.fetch) { + const win = window; // Convert to to be able to use AbortController (not supported by our TS version). + const initOptions: any = { + redirect: 'follow', + }; + let controller; + + // Some browsers implement fetch but no AbortController. + if (win.AbortController) { + controller = new win.AbortController(); + initOptions.signal = controller.signal; + } + + return this.timeoutPromise(window.fetch(url, initOptions), CoreWS.instance.getRequestTimeout()) + .then((response: Response) => { + return response.redirected; + }).catch((error) => { + if (error.timeout && controller) { + // Timeout, abort the request. + controller.abort(); + } + + // There was a timeout, cannot determine if there's a redirect. Assume it's false. + return false; + }); + } else { + // Cannot check if there is a redirect, assume it's false. + return Promise.resolve(false); + } + } + + /** + * Close the InAppBrowser window. + * + * @param closeAll Desktop only. True to close all secondary windows, false to close only the "current" one. + */ + closeInAppBrowser(closeAll?: boolean): void { + if (this.iabInstance) { + this.iabInstance.close(); + if (closeAll && CoreApp.instance.isDesktop()) { + // @todo require('electron').ipcRenderer.send('closeSecondaryWindows'); + } + } + } + + /** + * Clone a variable. It should be an object, array or primitive type. + * + * @param source The variable to clone. + * @param level Depth we are right now inside a cloned object. It's used to prevent reaching max call stack size. + * @return Cloned variable. + */ + clone(source: any, level: number = 0): any { + if (level >= 20) { + // Max 20 levels. + this.logger.error('Max depth reached when cloning object.', source); + + return source; + } + + if (Array.isArray(source)) { + // Clone the array and all the entries. + const newArray = []; + for (let i = 0; i < source.length; i++) { + newArray[i] = this.clone(source[i], level + 1); + } + + return newArray; + } else if (typeof source == 'object' && source !== null) { + // Check if the object shouldn't be copied. + if (source && source.toString && this.DONT_CLONE.indexOf(source.toString()) != -1) { + // Object shouldn't be copied, return it as it is. + return source; + } + + // Clone the object and all the subproperties. + const newObject = {}; + for (const name in source) { + newObject[name] = this.clone(source[name], level + 1); + } + + return newObject; + } else { + // Primitive type or unknown, return it as it is. + return source; + } + } + + /** + * Copy properties from one object to another. + * + * @param from Object to copy the properties from. + * @param to Object where to store the properties. + * @param clone Whether the properties should be cloned (so they are different instances). + */ + copyProperties(from: any, to: any, clone: boolean = true): void { + for (const name in from) { + if (clone) { + to[name] = this.clone(from[name]); + } else { + to[name] = from[name]; + } + } + } + + /** + * Copies a text to clipboard and shows a toast message. + * + * @param text Text to be copied + * @return Promise resolved when text is copied. + */ + copyToClipboard(text: string): Promise { + return Clipboard.instance.copy(text).then(() => { + // Show toast using ionicLoading. + return CoreDomUtils.instance.showToast('core.copiedtoclipboard', true); + }).catch(() => { + // Ignore errors. + }); + } + + /** + * Create a "fake" WS error for local errors. + * + * @param message The message to include in the error. + * @param needsTranslate If the message needs to be translated. + * @return Fake WS error. + */ + createFakeWSError(message: string, needsTranslate?: boolean): CoreWSError { + return CoreWS.instance.createFakeWSError(message, needsTranslate); + } + + /** + * Empties an array without losing its reference. + * + * @param array Array to empty. + */ + emptyArray(array: any[]): void { + array.length = 0; // Empty array without losing its reference. + } + + /** + * Removes all properties from an object without losing its reference. + * + * @param object Object to remove the properties. + */ + emptyObject(object: object): void { + for (const key in object) { + if (object.hasOwnProperty(key)) { + delete object[key]; + } + } + } + /** * Execute promises one depending on the previous. * - * @param orderedPromisesData Functions to be executed. + * @param orderedPromisesData Data to be executed including the following values: + * - func: Function to be executed. + * - context: Context to pass to the function. This allows using "this" inside the function. + * - params: Array of data to be sent to the function. + * - blocking: Boolean. If promise should block the following. * @return Promise resolved when all promises are resolved. */ - executeOrderedPromises(orderedPromisesData: OrderedPromiseData[]): Promise { + executeOrderedPromises(orderedPromisesData: any[]): Promise { const promises = []; let dependency = Promise.resolve(); // Execute all the processes in order. for (const i in orderedPromisesData) { const data = orderedPromisesData[i]; + let promise; // Add the process to the dependency stack. - const promise = dependency.finally(() => { + promise = dependency.finally(() => { + let prom; + try { - return data.function(); + prom = data.func.apply(data.context, data.params || []); } catch (e) { this.logger.error(e.message); return; } + + return prom; }); promises.push(promise); @@ -100,6 +401,784 @@ export class CoreUtilsProvider { return this.allPromises(promises); } + /** + * Flatten an object, moving subobjects' properties to the first level. + * It supports 2 notations: dot notation and square brackets. + * E.g.: {a: {b: 1, c: 2}, d: 3} -> {'a.b': 1, 'a.c': 2, d: 3} + * + * @param obj Object to flatten. + * @param useDotNotation Whether to use dot notation '.' or square brackets '['. + * @return Flattened object. + */ + flattenObject(obj: object, useDotNotation?: boolean): object { + const toReturn = {}; + + for (const name in obj) { + if (!obj.hasOwnProperty(name)) { + continue; + } + + const value = obj[name]; + if (typeof value == 'object' && !Array.isArray(value)) { + const flatObject = this.flattenObject(value); + for (const subName in flatObject) { + if (!flatObject.hasOwnProperty(subName)) { + continue; + } + + const newName = useDotNotation ? name + '.' + subName : name + '[' + subName + ']'; + toReturn[newName] = flatObject[subName]; + } + } else { + toReturn[name] = value; + } + } + + return toReturn; + } + + /** + * Given an array of strings, return only the ones that match a regular expression. + * + * @param array Array to filter. + * @param regex RegExp to apply to each string. + * @return Filtered array. + */ + filterByRegexp(array: string[], regex: RegExp): string[] { + if (!array || !array.length) { + return []; + } + + return array.filter((entry) => { + const matches = entry.match(regex); + + return matches && matches.length; + }); + } + + /** + * Filter the list of site IDs based on a isEnabled function. + * + * @param siteIds Site IDs to filter. + * @param isEnabledFn Function to call for each site. It receives a siteId param and all the params sent to this function + * after 'checkAll'. + * @param checkAll True if it should check all the sites, false if it should check only 1 and treat them all + * depending on this result. + * @param ...args All the params sent after checkAll will be passed to isEnabledFn. + * @return Promise resolved with the list of enabled sites. + */ + filterEnabledSites(siteIds: string[], isEnabledFn: (siteId, ...args: any[]) => boolean | Promise, checkAll?: boolean, + ...args: any[]): Promise { + const promises = []; + const enabledSites = []; + + for (const i in siteIds) { + const siteId = siteIds[i]; + if (checkAll || !promises.length) { + promises.push(Promise.resolve(isEnabledFn.apply(isEnabledFn, [siteId].concat(args))).then((enabled) => { + if (enabled) { + enabledSites.push(siteId); + } + })); + } + } + + return this.allPromises(promises).catch(() => { + // Ignore errors. + }).then(() => { + if (!checkAll) { + // Checking 1 was enough, so it will either return all the sites or none. + return enabledSites.length ? siteIds : []; + } else { + return enabledSites; + } + }); + } + + /** + * Given a float, prints it nicely. Localized floats must not be used in calculations! + * Based on Moodle's format_float. + * + * @param float The float to print. + * @return Locale float. + */ + formatFloat(float: any): string { + if (typeof float == 'undefined' || float === null || typeof float == 'boolean') { + return ''; + } + + const localeSeparator = Translate.instance.instant('core.decsep'); + + // Convert float to string. + float += ''; + + return float.replace('.', localeSeparator); + } + + /** + * Returns a tree formatted from a plain list. + * List has to be sorted by depth to allow this function to work correctly. Errors can be thrown if a child node is + * processed before a parent node. + * + * @param list List to format. + * @param parentFieldName Name of the parent field to match with children. + * @param idFieldName Name of the children field to match with parent. + * @param rootParentId The id of the root. + * @param maxDepth Max Depth to convert to tree. Children found will be in the last level of depth. + * @return Array with the formatted tree, children will be on each node under children field. + */ + formatTree(list: any[], parentFieldName: string = 'parent', idFieldName: string = 'id', rootParentId: number = 0, + maxDepth: number = 5): any[] { + const map = {}; + const mapDepth = {}; + const tree = []; + let parent; + let id; + + list.forEach((node, index): void => { + id = node[idFieldName]; + parent = node[parentFieldName]; + node.children = []; + + if (!id || !parent) { + this.logger.error(`Node with incorrect ${idFieldName}:${id} or ${parentFieldName}:${parent} found on formatTree`); + } + + // Use map to look-up the parents. + map[id] = index; + if (parent != rootParentId) { + const parentNode = list[map[parent]]; + if (parentNode) { + if (mapDepth[parent] == maxDepth) { + // Reached max level of depth. Proceed with flat order. Find parent object of the current node. + const parentOfParent = parentNode[parentFieldName]; + if (parentOfParent) { + // This element will be the child of the node that is two levels up the hierarchy + // (i.e. the child of node.parent.parent). + list[map[parentOfParent]].children.push(node); + // Assign depth level to the same depth as the parent (i.e. max depth level). + mapDepth[id] = mapDepth[parent]; + // Change the parent to be the one that is two levels up the hierarchy. + node.parent = parentOfParent; + } else { + this.logger.error(`Node parent of parent:${parentOfParent} not found on formatTree`); + } + } else { + parentNode.children.push(node); + // Increase the depth level. + mapDepth[id] = mapDepth[parent] + 1; + } + } else { + this.logger.error(`Node parent:${parent} not found on formatTree`); + } + } else { + tree.push(node); + + // Root elements are the first elements in the tree structure, therefore have the depth level 1. + mapDepth[id] = 1; + } + }); + + return tree; + } + + /** + * Get country name based on country code. + * + * @param code Country code (AF, ES, US, ...). + * @return Country name. If the country is not found, return the country code. + */ + getCountryName(code: string): string { + const countryKey = 'assets.countries.' + code; + const countryName = Translate.instance.instant(countryKey); + + return countryName !== countryKey ? countryName : code; + } + + /** + * Get list of countries with their code and translated name. + * + * @return Promise resolved with the list of countries. + */ + getCountryList(): Promise { + // Get the keys of the countries. + return this.getCountryKeysList().then((keys) => { + // Now get the code and the translated name. + const countries = {}; + + keys.forEach((key) => { + if (key.indexOf('assets.countries.') === 0) { + const code = key.replace('assets.countries.', ''); + countries[code] = Translate.instance.instant(key); + } + }); + + return countries; + }); + } + + /** + * Get list of countries with their code and translated name. Sorted by the name of the country. + * + * @return Promise resolved with the list of countries. + */ + getCountryListSorted(): Promise { + // Get the keys of the countries. + return this.getCountryList().then((countries) => { + // Sort translations. + const sortedCountries = []; + + Object.keys(countries).sort((a, b) => countries[a].localeCompare(countries[b])).forEach((key) => { + sortedCountries.push({code: key, name: countries[key]}); + }); + + return sortedCountries; + }); + } + + /** + * Get the list of language keys of the countries. + * + * @return Promise resolved with the countries list. Rejected if not translated. + */ + protected getCountryKeysList(): Promise { + // It's possible that the current language isn't translated, so try with default language first. + const defaultLang = CoreLang.instance.getDefaultLanguage(); + + return this.getCountryKeysListForLanguage(defaultLang).catch(() => { + // Not translated, try to use the fallback language. + const fallbackLang = CoreLang.instance.getFallbackLanguage(); + + if (fallbackLang === defaultLang) { + // Same language, just reject. + return Promise.reject('Countries not found.'); + } + + return this.getCountryKeysListForLanguage(fallbackLang); + }); + } + + /** + * Get the list of language keys of the countries, based on the translation table for a certain language. + * + * @param lang Language to check. + * @return Promise resolved with the countries list. Rejected if not translated. + */ + protected getCountryKeysListForLanguage(lang: string): Promise { + // Get the translation table for the language. + return CoreLang.instance.getTranslationTable(lang).then((table): any => { + // Gather all the keys for countries, + const keys = []; + + for (const name in table) { + if (name.indexOf('assets.countries.') === 0) { + keys.push(name); + } + } + + if (keys.length === 0) { + // Not translated, reject. + return Promise.reject('Countries not found.'); + } + + return keys; + }); + } + + /** + * Get the mimetype of a file given its URL. It'll try to guess it using the URL, if that fails then it'll + * perform a HEAD request to get it. It's done in this order because pluginfile.php can return wrong mimetypes. + * This function is in here instead of MimetypeUtils to prevent circular dependencies. + * + * @param url The URL of the file. + * @return Promise resolved with the mimetype. + */ + getMimeTypeFromUrl(url: string): Promise { + // First check if it can be guessed from the URL. + const extension = CoreMimetypeUtils.instance.guessExtensionFromUrl(url); + const mimetype = CoreMimetypeUtils.instance.getMimeType(extension); + + if (mimetype) { + return Promise.resolve(mimetype); + } + + // Can't be guessed, get the remote mimetype. + return CoreWS.instance.getRemoteFileMimeType(url).then((mimetype) => { + return mimetype || ''; + }); + } + + /** + * Get a unique ID for a certain name. + * + * @param name The name to get the ID for. + * @return Unique ID. + */ + getUniqueId(name: string): number { + if (!this.uniqueIds[name]) { + this.uniqueIds[name] = 0; + } + + return ++this.uniqueIds[name]; + } + + /** + * Given a list of files, check if there are repeated names. + * + * @param files List of files. + * @return String with error message if repeated, false if no repeated. + */ + hasRepeatedFilenames(files: any[]): string | boolean { + if (!files || !files.length) { + return false; + } + + const names = []; + + // Check if there are 2 files with the same name. + for (let i = 0; i < files.length; i++) { + const name = files[i].filename || files[i].name; + if (names.indexOf(name) > -1) { + return Translate.instance.instant('core.filenameexist', { $a: name }); + } else { + names.push(name); + } + } + + return false; + } + + /** + * Gets the index of the first string that matches a regular expression. + * + * @param array Array to search. + * @param regex RegExp to apply to each string. + * @return Index of the first string that matches the RegExp. -1 if not found. + */ + indexOfRegexp(array: string[], regex: RegExp): number { + if (!array || !array.length) { + return -1; + } + + for (let i = 0; i < array.length; i++) { + const entry = array[i]; + const matches = entry.match(regex); + + if (matches && matches.length) { + return i; + } + } + + return -1; + } + + /** + * Return true if the param is false (bool), 0 (number) or "0" (string). + * + * @param value Value to check. + * @return Whether the value is false, 0 or "0". + */ + isFalseOrZero(value: any): boolean { + return typeof value != 'undefined' && (value === false || value === 'false' || parseInt(value, 10) === 0); + } + + /** + * Return true if the param is true (bool), 1 (number) or "1" (string). + * + * @param value Value to check. + * @return Whether the value is true, 1 or "1". + */ + isTrueOrOne(value: any): boolean { + return typeof value != 'undefined' && (value === true || value === 'true' || parseInt(value, 10) === 1); + } + + /** + * Given an error returned by a WS call, check if the error is generated by the app or it has been returned by the WebSwervice. + * + * @param error Error to check. + * @return Whether the error was returned by the WebService. + */ + isWebServiceError(error: any): boolean { + return error && (typeof error.warningcode != 'undefined' || (typeof error.errorcode != 'undefined' && + error.errorcode != 'invalidtoken' && error.errorcode != 'userdeleted' && error.errorcode != 'upgraderunning' && + error.errorcode != 'forcepasswordchangenotice' && error.errorcode != 'usernotfullysetup' && + error.errorcode != 'sitepolicynotagreed' && error.errorcode != 'sitemaintenance' && + (error.errorcode != 'accessexception' || error.message.indexOf('Invalid token - token expired') == -1))); + } + + /** + * Given a list (e.g. a,b,c,d,e) this function returns an array of 1->a, 2->b, 3->c etc. + * Taken from make_menu_from_list on moodlelib.php (not the same but similar). + * + * @param list The string to explode into array bits + * @param defaultLabel Element that will become default option, if not defined, it won't be added. + * @param separator The separator used within the list string. Default ','. + * @param defaultValue Element that will become default option value. Default 0. + * @return The now assembled array + */ + makeMenuFromList(list: string, defaultLabel?: string, separator: string = ',', defaultValue?: any): any[] { + // Split and format the list. + const split = list.split(separator).map((label, index) => { + return { + label: label.trim(), + value: index + 1 + }; + }); + + if (defaultLabel) { + split.unshift({ + label: defaultLabel, + value: defaultValue || 0 + }); + } + + return split; + } + + /** + * Merge two arrays, removing duplicate values. + * + * @param array1 The first array. + * @param array2 The second array. + * @param [key] Key of the property that must be unique. If not specified, the whole entry. + * @return Merged array. + */ + mergeArraysWithoutDuplicates(array1: any[], array2: any[], key?: string): any[] { + return this.uniqueArray(array1.concat(array2), key); + } + + /** + * Check if a value isn't null or undefined. + * + * @param value Value to check. + * @return True if not null and not undefined. + */ + notNullOrUndefined(value: any): boolean { + return typeof value != 'undefined' && value !== null; + } + + /** + * Open a file using platform specific method. + * + * @param path The local path of the file to be open. + * @return Promise resolved when done. + */ + async openFile(path: string): Promise { + // Convert the path to a native path if needed. + path = CoreFile.instance.unconvertFileSrc(path); + + const extension = CoreMimetypeUtils.instance.getFileExtension(path); + const mimetype = CoreMimetypeUtils.instance.getMimeType(extension); + + if (mimetype == 'text/html' && CoreApp.instance.isAndroid()) { + // Open HTML local files in InAppBrowser, in system browser some embedded files aren't loaded. + this.openInApp(path); + + return; + } + + // Path needs to be decoded, the file won't be opened if the path has %20 instead of spaces and so. + try { + path = decodeURIComponent(path); + } catch (ex) { + // Error, use the original path. + } + + try { + await FileOpener.instance.open(path, mimetype); + } catch (error) { + this.logger.error('Error opening file ' + path + ' with mimetype ' + mimetype); + this.logger.error('Error: ', JSON.stringify(error)); + + if (!extension || extension.indexOf('/') > -1 || extension.indexOf('\\') > -1) { + // Extension not found. + error = Translate.instance.instant('core.erroropenfilenoextension'); + } else { + error = Translate.instance.instant('core.erroropenfilenoapp'); + } + + throw error; + } + } + + /** + * Open a URL using InAppBrowser. + * Do not use for files, refer to {@link openFile}. + * + * @param url The URL to open. + * @param options Override default options passed to InAppBrowser. + * @return The opened window. + */ + openInApp(url: string, options?: any): InAppBrowserObject { + if (!url) { + return; + } + + options = options || {}; + options.usewkwebview = 'yes'; // Force WKWebView in iOS. + + if (!options.enableViewPortScale) { + options.enableViewPortScale = 'yes'; // Enable zoom on iOS. + } + + if (!options.allowInlineMediaPlayback) { + options.allowInlineMediaPlayback = 'yes'; // Allow playing inline videos in iOS. + } + + if (!options.location && CoreApp.instance.isIOS() && url.indexOf('file://') === 0) { + // The URL uses file protocol, don't show it on iOS. + // In Android we keep it because otherwise we lose the whole toolbar. + options.location = 'no'; + } + + this.iabInstance = InAppBrowser.instance.create(url, '_blank', options); + + if (CoreApp.instance.isDesktop() || CoreApp.instance.isMobile()) { + let loadStopSubscription; + const loadStartUrls = []; + + // Trigger global events when a url is loaded or the window is closed. This is to make it work like in Ionic 1. + const loadStartSubscription = this.iabInstance.on('loadstart').subscribe((event) => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + this.zone.run(() => { + // Store the last loaded URLs (max 10). + loadStartUrls.push(event.url); + if (loadStartUrls.length > 10) { + loadStartUrls.shift(); + } + + CoreEvents.instance.trigger(CoreEventsProvider.IAB_LOAD_START, event); + }); + }); + + if (CoreApp.instance.isAndroid()) { + // Load stop is needed with InAppBrowser v3. Custom URL schemes no longer trigger load start, simulate it. + loadStopSubscription = this.iabInstance.on('loadstop').subscribe((event) => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + this.zone.run(() => { + if (loadStartUrls.indexOf(event.url) == -1) { + // The URL was stopped but not started, probably a custom URL scheme. + CoreEvents.instance.trigger(CoreEventsProvider.IAB_LOAD_START, event); + } + }); + }); + } + + const exitSubscription = this.iabInstance.on('exit').subscribe((event) => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + this.zone.run(() => { + loadStartSubscription.unsubscribe(); + loadStopSubscription && loadStopSubscription.unsubscribe(); + exitSubscription.unsubscribe(); + CoreEvents.instance.trigger(CoreEventsProvider.IAB_EXIT, event); + }); + }); + } + + return this.iabInstance; + } + + /** + * Open a URL using a browser. + * Do not use for files, refer to {@link openFile}. + * + * @param url The URL to open. + */ + openInBrowser(url: string): void { + if (CoreApp.instance.isDesktop()) { + // @todo + } else { + window.open(url, '_system'); + } + } + + /** + * Open an online file using platform specific method. + * Specially useful for audio and video since they can be streamed. + * + * @param url The URL of the file. + * @return Promise resolved when opened. + */ + openOnlineFile(url: string): Promise { + if (CoreApp.instance.isAndroid()) { + // In Android we need the mimetype to open it. + return this.getMimeTypeFromUrl(url).catch(() => { + // Error getting mimetype, return undefined. + }).then((mimetype) => { + if (!mimetype) { + // Couldn't retrieve mimetype. Return error. + return Promise.reject(Translate.instance.instant('core.erroropenfilenoextension')); + } + + const options = { + action: WebIntent.instance.ACTION_VIEW, + url, + type: mimetype + }; + + return WebIntent.instance.startActivity(options).catch((error) => { + this.logger.error('Error opening online file ' + url + ' with mimetype ' + mimetype); + this.logger.error('Error: ', JSON.stringify(error)); + + return Promise.reject(Translate.instance.instant('core.erroropenfilenoapp')); + }); + }); + } + + // In the rest of platforms we need to open them in InAppBrowser. + this.openInApp(url); + + return Promise.resolve(); + } + + /** + * Converts an object into an array, losing the keys. + * + * @param obj Object to convert. + * @return Array with the values of the object but losing the keys. + */ + objectToArray(obj: object): any[] { + return Object.keys(obj).map((key) => { + return obj[key]; + }); + } + + /** + * Converts an object into an array of objects, where each entry is an object containing + * the key and value of the original object. + * For example, it can convert {size: 2} into [{name: 'size', value: 2}]. + * + * @param obj Object to convert. + * @param keyName Name of the properties where to store the keys. + * @param valueName Name of the properties where to store the values. + * @param sortByKey True to sort keys alphabetically, false otherwise. Has priority over sortByValue. + * @param sortByValue True to sort values alphabetically, false otherwise. + * @return Array of objects with the name & value of each property. + */ + objectToArrayOfObjects(obj: object, keyName: string, valueName: string, sortByKey?: boolean, sortByValue?: boolean): any[] { + // Get the entries from an object or primitive value. + const getEntries = (elKey, value): any[] | any => { + if (typeof value == 'undefined' || value == null) { + // Filter undefined and null values. + return; + } else if (typeof value == 'object') { + // It's an object, return at least an entry for each property. + const keys = Object.keys(value); + let entries = []; + + keys.forEach((key) => { + const newElKey = elKey ? elKey + '[' + key + ']' : key; + const subEntries = getEntries(newElKey, value[key]); + + if (subEntries) { + entries = entries.concat(subEntries); + } + }); + + return entries; + } else { + // Not an object, return a single entry. + const entry = {}; + entry[keyName] = elKey; + entry[valueName] = value; + + return entry; + } + }; + + if (!obj) { + return []; + } + + // "obj" will always be an object, so "entries" will always be an array. + const entries = getEntries('', obj); + if (sortByKey || sortByValue) { + return entries.sort((a, b) => { + if (sortByKey) { + return a[keyName] >= b[keyName] ? 1 : -1; + } else { + return a[valueName] >= b[valueName] ? 1 : -1; + } + }); + } + + return entries; + } + + /** + * Converts an array of objects into an object with key and value. The opposite of objectToArrayOfObjects. + * For example, it can convert [{name: 'size', value: 2}] into {size: 2}. + * + * @param objects List of objects to convert. + * @param keyName Name of the properties where the keys are stored. + * @param valueName Name of the properties where the values are stored. + * @param keyPrefix Key prefix if neededs to delete it. + * @return Object. + */ + objectToKeyValueMap(objects: object[], keyName: string, valueName: string, keyPrefix?: string): {[name: string]: any} { + if (!objects) { + return; + } + + const prefixSubstr = keyPrefix ? keyPrefix.length : 0; + const mapped = {}; + objects.forEach((item) => { + const key = prefixSubstr > 0 ? item[keyName].substr(prefixSubstr) : item[keyName]; + mapped[key] = item[valueName]; + }); + + return mapped; + } + + /** + * Convert an object to a format of GET param. E.g.: {a: 1, b: 2} -> a=1&b=2 + * + * @param object Object to convert. + * @param removeEmpty Whether to remove params whose value is null/undefined. + * @return GET params. + */ + objectToGetParams(object: any, removeEmpty: boolean = true): string { + // First of all, flatten the object so all properties are in the first level. + const flattened = this.flattenObject(object); + let result = ''; + let joinChar = ''; + + for (const name in flattened) { + let value = flattened[name]; + + if (removeEmpty && (value === null || typeof value == 'undefined')) { + continue; + } + + if (typeof value == 'boolean') { + value = value ? 1 : 0; + } + + result += joinChar + name + '=' + value; + joinChar = '&'; + } + + return result; + } + + /** + * Add a prefix to all the keys in an object. + * + * @param data Object. + * @param prefix Prefix to add. + * @return Prefixed object. + */ + prefixKeys(data: any, prefix: string): any { + const newObj = {}; + const keys = Object.keys(data); + + keys.forEach((key) => { + newObj[prefix + key] = data[key]; + }); + + return newObj; + } + /** * Similar to AngularJS $q.defer(). * @@ -107,7 +1186,6 @@ export class CoreUtilsProvider { */ promiseDefer(): PromiseDefer { const deferred: PromiseDefer = {}; - deferred.promise = new Promise((resolve, reject): void => { deferred.resolve = resolve; deferred.reject = reject; @@ -115,25 +1193,374 @@ export class CoreUtilsProvider { return deferred; } + + /** + * Given a promise, returns true if it's rejected or false if it's resolved. + * + * @param promise Promise to check + * @return Promise resolved with boolean: true if the promise is rejected or false if it's resolved. + */ + promiseFails(promise: Promise): Promise { + return promise.then(() => { + return false; + }).catch(() => { + return true; + }); + } + + /** + * Given a promise, returns true if it's resolved or false if it's rejected. + * + * @param promise Promise to check + * @return Promise resolved with boolean: true if the promise it's resolved or false if it's rejected. + */ + promiseWorks(promise: Promise): Promise { + return promise.then(() => { + return true; + }).catch(() => { + return false; + }); + } + + /** + * Tests to see whether two arrays or objects have the same value at a particular key. + * Missing values are replaced by '', and the values are compared with ===. + * Booleans and numbers are cast to string before comparing. + * + * @param obj1 The first object or array. + * @param obj2 The second object or array. + * @param key Key to check. + * @return Whether the two objects/arrays have the same value (or lack of one) for a given key. + */ + sameAtKeyMissingIsBlank(obj1: any, obj2: any, key: string): boolean { + let value1 = typeof obj1[key] != 'undefined' ? obj1[key] : ''; + let value2 = typeof obj2[key] != 'undefined' ? obj2[key] : ''; + + if (typeof value1 == 'number' || typeof value1 == 'boolean') { + value1 = '' + value1; + } + if (typeof value2 == 'number' || typeof value2 == 'boolean') { + value2 = '' + value2; + } + + return value1 === value2; + } + + /** + * Stringify an object, sorting the properties. It doesn't sort arrays, only object properties. E.g.: + * {b: 2, a: 1} -> '{"a":1,"b":2}' + * + * @param obj Object to stringify. + * @return Stringified object. + */ + sortAndStringify(obj: object): string { + return JSON.stringify(this.sortProperties(obj)); + } + + /** + * Given an object, sort its properties and the properties of all the nested objects. + * + * @param obj The object to sort. If it isn't an object, the original value will be returned. + * @return Sorted object. + */ + sortProperties(obj: object): object { + if (obj != null && typeof obj == 'object' && !Array.isArray(obj)) { + // It's an object, sort it. + return Object.keys(obj).sort().reduce((accumulator, key) => { + // Always call sort with the value. If it isn't an object, the original value will be returned. + accumulator[key] = this.sortProperties(obj[key]); + + return accumulator; + }, {}); + } else { + return obj; + } + } + + /** + * Given an object, sort its values. Values need to be primitive values, it cannot have subobjects. + * + * @param obj The object to sort. If it isn't an object, the original value will be returned. + * @return Sorted object. + */ + sortValues(obj: object): object { + if (typeof obj == 'object' && !Array.isArray(obj)) { + // It's an object, sort it. Convert it to an array to be able to sort it and then convert it back to object. + const array = this.objectToArrayOfObjects(obj, 'name', 'value', false, true); + + return this.objectToKeyValueMap(array, 'name', 'value'); + } else { + return obj; + } + } + + /** + * Sum the filesizes from a list of files checking if the size will be partial or totally calculated. + * + * @param files List of files to sum its filesize. + * @return File size and a boolean to indicate if it is the total size or only partial. + * @deprecated since 3.8.0. Use CorePluginFileDelegate.getFilesSize instead. + */ + sumFileSizes(files: any[]): { size: number, total: boolean } { + const result = { + size: 0, + total: true + }; + + files.forEach((file) => { + if (typeof file.filesize == 'undefined') { + // We don't have the file size, cannot calculate its total size. + result.total = false; + } else { + result.size += file.filesize; + } + }); + + return result; + } + + /** + * Set a timeout to a Promise. If the time passes before the Promise is resolved or rejected, it will be automatically + * rejected. + * + * @param promise The promise to timeout. + * @param time Number of milliseconds of the timeout. + * @return Promise with the timeout. + */ + timeoutPromise(promise: Promise, time: number): Promise { + return new Promise((resolve, reject): void => { + const timeout = setTimeout(() => { + reject({timeout: true}); + }, time); + + promise.then(resolve).catch(reject).finally(() => { + clearTimeout(timeout); + }); + }); + } + + /** + * Converts locale specific floating point/comma number back to standard PHP float value. + * Do NOT try to do any math operations before this conversion on any user submitted floats! + * Based on Moodle's unformat_float function. + * + * @param localeFloat Locale aware float representation. + * @param strict If true, then check the input and return false if it is not a valid number. + * @return False if bad format, empty string if empty value or the parsed float if not. + */ + unformatFloat(localeFloat: any, strict?: boolean): any { + // Bad format on input type number. + if (typeof localeFloat == 'undefined') { + return false; + } + + // Empty (but not zero). + if (localeFloat == null) { + return ''; + } + + // Convert float to string. + localeFloat += ''; + localeFloat = localeFloat.trim(); + + if (localeFloat == '') { + return ''; + } + + localeFloat = localeFloat.replace(' ', ''); // No spaces - those might be used as thousand separators. + localeFloat = localeFloat.replace(Translate.instance.instant('core.decsep'), '.'); + + const parsedFloat = parseFloat(localeFloat); + + // Bad format. + if (strict && (!isFinite(localeFloat) || isNaN(parsedFloat))) { + return false; + } + + return parsedFloat; + } + + /** + * Return an array without duplicate values. + * + * @param array The array to treat. + * @param [key] Key of the property that must be unique. If not specified, the whole entry. + * @return Array without duplicate values. + */ + uniqueArray(array: any[], key?: string): any[] { + const filtered = []; + const unique = {}; // Use an object to make it faster to check if it's duplicate. + + array.forEach((entry) => { + const value = key ? entry[key] : entry; + + if (!unique[value]) { + unique[value] = true; + filtered.push(entry); + } + }); + + return filtered; + } + + /** + * Debounce a function so consecutive calls are ignored until a certain time has passed since the last call. + * + * @param context The context to apply to the function. + * @param fn Function to debounce. + * @param delay Time that must pass until the function is called. + * @return Debounced function. + */ + debounce(fn: (...args: any[]) => any, delay: number): (...args: any[]) => void { + + let timeoutID: number; + + const debounced = (...args: any[]): void => { + clearTimeout(timeoutID); + + timeoutID = window.setTimeout(() => { + fn.apply(null, args); + }, delay); + }; + + return debounced; + } + + /** + * Check whether the app can scan QR codes. + * + * @return Whether the app can scan QR codes. + */ + canScanQR(): boolean { + return CoreApp.instance.isMobile(); + } + + /** + * Open a modal to scan a QR code. + * + * @param title Title of the modal. Defaults to "QR reader". + * @return Promise resolved with the captured text or undefined if cancelled or error. + */ + scanQR(title?: string): Promise { + return new Promise((resolve, reject): void => { + // @todo + }); + } + + /** + * Start scanning for a QR code. + * + * @return Promise resolved with the QR string, rejected if error or cancelled. + */ + startScanQR(): Promise { + if (!CoreApp.instance.isMobile()) { + return Promise.reject('QRScanner isn\'t available in desktop apps.'); + } + + // Ask the user for permission to use the camera. + // The scan method also does this, but since it returns an Observable we wouldn't be able to detect if the user denied. + return QRScanner.instance.prepare().then((status) => { + + if (!status.authorized) { + // No access to the camera, reject. In android this shouldn't happen, denying access passes through catch. + return Promise.reject('The user denied camera access.'); + } + + if (this.qrScanData && this.qrScanData.deferred) { + // Already scanning. + return this.qrScanData.deferred.promise; + } + + // Start scanning. + this.qrScanData = { + deferred: this.promiseDefer(), + observable: QRScanner.instance.scan().subscribe((text) => { + + // Text received, stop scanning and return the text. + this.stopScanQR(text, false); + }) + }; + + // Show the camera. + return QRScanner.instance.show().then(() => { + document.body.classList.add('core-scanning-qr'); + + return this.qrScanData.deferred.promise; + }, (err) => { + this.stopScanQR(err, true); + + return Promise.reject(err); + }); + + }).catch((err) => { + err.message = err.message || err._message; + + return Promise.reject(err); + }); + } + + /** + * Stop scanning for QR code. If no param is provided, the app will consider the user cancelled. + * + * @param data If success, the text of the QR code. If error, the error object or message. Undefined for cancelled. + * @param error True if the data belongs to an error, false otherwise. + */ + stopScanQR(data?: any, error?: boolean): void { + + if (!this.qrScanData) { + // Not scanning. + return; + } + + // Hide camera preview. + document.body.classList.remove('core-scanning-qr'); + QRScanner.instance.hide(); + QRScanner.instance.destroy(); + + this.qrScanData.observable.unsubscribe(); // Stop scanning. + + if (error) { + this.qrScanData.deferred.reject(data); + } else if (typeof data != 'undefined') { + this.qrScanData.deferred.resolve(data); + } else { + this.qrScanData.deferred.reject(CoreDomUtils.instance.createCanceledError()); + } + + delete this.qrScanData; + } + + /** + * Ignore errors from a promise. + * + * @param promise Promise to ignore errors. + * @return Promise with ignored errors. + */ + async ignoreErrors(promise: Promise): Promise { + try { + const result = await promise; + + return result; + } catch (error) { + // Ignore errors. + } + } + + /** + * Wait some time. + * + * @param milliseconds Number of milliseconds to wait. + * @return Promise resolved after the time has passed. + */ + wait(milliseconds: number): Promise { + return new Promise((resolve, reject): void => { + setTimeout(resolve, milliseconds); + }); + } } export class CoreUtils extends makeSingleton(CoreUtilsProvider) {} -/** - * Data for each entry of executeOrderedPromises. - */ -export type OrderedPromiseData = { - /** - * Function to execute. - */ - function: () => Promise; - - /** - * Whether the promise should block the following one. - */ - blocking?: boolean; -}; - /** * Deferred promise. It's similar to the result of $q.defer() in AngularJS. */ @@ -148,7 +1575,7 @@ export type PromiseDefer = { * * @param value The resolve value. */ - resolve?: (value?: T) => void; + resolve?: (value?: T) => void; // Function to resolve the promise. /** * Function to reject the promise. diff --git a/src/app/services/ws.ts b/src/app/services/ws.ts new file mode 100644 index 000000000..61e3b3c27 --- /dev/null +++ b/src/app/services/ws.ts @@ -0,0 +1,1168 @@ +// (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 { HttpResponse } from '@angular/common/http'; + +import { FileUploadOptions } from '@ionic-native/file-transfer/ngx'; +import { Md5 } from 'ts-md5/dist/md5'; +import { Observable } from 'rxjs'; +import { timeout } from 'rxjs/operators'; + +import { CoreNativeToAngularHttpResponse } from '@classes/native-to-angular-http'; +import { CoreApp } from '@services/app'; +import { CoreFile, CoreFileProvider } from '@services/file'; +import { CoreMimetypeUtils } from '@services/utils/mimetype'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreConstants } from '@core/constants'; +import { CoreInterceptor } from '@classes/interceptor'; +import { makeSingleton, Translate, FileTransfer, Http, Platform } from '@singletons/core.singletons'; +import { CoreLogger } from '@singletons/logger'; + +/** + * This service allows performing WS calls and download/upload files. + */ +@Injectable() +export class CoreWSProvider { + protected logger: CoreLogger; + protected mimeTypeCache = {}; // A "cache" to store file mimetypes to prevent performing too many HEAD requests. + protected ongoingCalls = {}; + protected retryCalls = []; + protected retryTimeout = 0; + + constructor() { + this.logger = CoreLogger.getInstance('CoreWSProvider'); + + Platform.instance.ready().then(() => { + if (CoreApp.instance.isIOS()) { + ( cordova).plugin.http.setHeader('User-Agent', navigator.userAgent); + } + }); + } + + /** + * Adds the call data to an special queue to be processed when retrying. + * + * @param method The WebService method to be called. + * @param siteUrl Complete site url to perform the call. + * @param ajaxData Arguments to pass to the method. + * @param preSets Extra settings and information. + * @return Deferred promise resolved with the response data in success and rejected with the error message + * if it fails. + */ + protected addToRetryQueue(method: string, siteUrl: string, ajaxData: any, preSets: CoreWSPreSets): Promise { + const call: any = { + method, + siteUrl, + ajaxData, + preSets, + deferred: {} + }; + + call.deferred.promise = new Promise((resolve, reject): void => { + call.deferred.resolve = resolve; + call.deferred.reject = reject; + }); + + this.retryCalls.push(call); + + return call.deferred.promise; + } + + /** + * A wrapper function for a moodle WebService call. + * + * @param method The WebService method to be called. + * @param data Arguments to pass to the method. It's recommended to call convertValuesToString before passing the data. + * @param preSets Extra settings and information. + * @return Promise resolved with the response data in success and rejected if it fails. + */ + call(method: string, data: any, preSets: CoreWSPreSets): Promise { + + let siteUrl; + + if (!preSets) { + return Promise.reject(this.createFakeWSError('core.unexpectederror', true)); + } else if (!CoreApp.instance.isOnline()) { + return Promise.reject(this.createFakeWSError('core.networkerrormsg', true)); + } + + preSets.typeExpected = preSets.typeExpected || 'object'; + if (typeof preSets.responseExpected == 'undefined') { + preSets.responseExpected = true; + } + + data = Object.assign({}, data); // Create a new object so the changes don't affect the original data. + data.wsfunction = method; + data.wstoken = preSets.wsToken; + siteUrl = preSets.siteUrl + '/webservice/rest/server.php?moodlewsrestformat=json'; + + // There are some ongoing retry calls, wait for timeout. + if (this.retryCalls.length > 0) { + this.logger.warn('Calls locked, trying later...'); + + return this.addToRetryQueue(method, siteUrl, data, preSets); + } else { + return this.performPost(method, siteUrl, data, preSets); + } + } + + /** + * Call a Moodle WS using the AJAX API. Please use it if the WS layer is not an option. + * It uses a cache to prevent duplicate requests. + * + * @param method The WebService method to be called. + * @param data Arguments to pass to the method. + * @param preSets Extra settings and information. Only some + * @return Promise resolved with the response data in success and rejected with an object containing: + * - error: Error message. + * - errorcode: Error code returned by the site (if any). + * - available: 0 if unknown, 1 if available, -1 if not available. + */ + callAjax(method: string, data: any, preSets: CoreWSAjaxPreSets): Promise { + const cacheParams = { + methodname: method, + args: data, + }; + + let promise = this.getPromiseHttp('ajax', preSets.siteUrl, cacheParams); + + if (!promise) { + promise = this.performAjax(method, data, preSets); + promise = this.setPromiseHttp(promise, 'ajax', preSets.siteUrl, cacheParams); + } + + return promise; + } + + /** + * Converts an objects values to strings where appropriate. + * Arrays (associative or otherwise) will be maintained, null values will be removed. + * + * @param data The data that needs all the non-object values set to strings. + * @param stripUnicode If Unicode long chars need to be stripped. + * @return The cleaned object or null if some strings becomes empty after stripping Unicode. + */ + convertValuesToString(data: any, stripUnicode?: boolean): any { + const result: any = Array.isArray(data) ? [] : {}; + + for (const key in data) { + let value = data[key]; + + if (value == null) { + // Skip null or undefined value. + continue; + } else if (typeof value == 'object') { + // Object or array. + value = this.convertValuesToString(value, stripUnicode); + if (value == null) { + return null; + } + } else if (typeof value == 'string') { + if (stripUnicode) { + const stripped = CoreTextUtils.instance.stripUnicode(value); + if (stripped != value && stripped.trim().length == 0) { + return null; + } + value = stripped; + } + } else if (typeof value == 'boolean') { + /* Moodle does not allow "true" or "false" in WS parameters, only in POST parameters. + We've been using "true" and "false" for WS settings "filter" and "fileurl", + we keep it this way to avoid changing cache keys. */ + if (key == 'moodlewssettingfilter' || key == 'moodlewssettingfileurl') { + value = value ? 'true' : 'false'; + } else { + value = value ? '1' : '0'; + } + } else if (typeof value == 'number') { + value = String(value); + } else { + // Unknown type. + continue; + } + + if (Array.isArray(result)) { + result.push(value); + } else { + result[key] = value; + } + } + + return result; + } + + /** + * Create a "fake" WS error for local errors. + * + * @param message The message to include in the error. + * @param needsTranslate If the message needs to be translated. + * @param translateParams Translation params, if needed. + * @return Fake WS error. + */ + createFakeWSError(message: string, needsTranslate?: boolean, translateParams?: {}): CoreWSError { + if (needsTranslate) { + message = Translate.instance.instant(message, translateParams); + } + + return { + message, + }; + } + + /** + * Downloads a file from Moodle using Cordova File API. + * + * @param url Download url. + * @param path Local path to store the file. + * @param addExtension True if extension need to be added to the final path. + * @param onProgress Function to call on progress. + * @return Promise resolved with the downloaded file. + */ + downloadFile(url: string, path: string, addExtension?: boolean, onProgress?: (event: ProgressEvent) => any): Promise { + this.logger.debug('Downloading file', url, path, addExtension); + + if (!CoreApp.instance.isOnline()) { + return Promise.reject(Translate.instance.instant('core.networkerrormsg')); + } + + // Use a tmp path to download the file and then move it to final location. + // This is because if the download fails, the local file is deleted. + const tmpPath = path + '.tmp'; + + // Create the tmp file as an empty file. + return CoreFile.instance.createFile(tmpPath).then((fileEntry) => { + const transfer = FileTransfer.instance.create(); + transfer.onProgress(onProgress); + + return transfer.download(url, fileEntry.toURL(), true).then(() => { + let promise; + + if (addExtension) { + const ext = CoreMimetypeUtils.instance.getFileExtension(path); + + // Google Drive extensions will be considered invalid since Moodle usually converts them. + if (!ext || ext == 'gdoc' || ext == 'gsheet' || ext == 'gslides' || ext == 'gdraw' || ext == 'php') { + // Not valid, get the file's mimetype. + promise = this.getRemoteFileMimeType(url).then((mime) => { + if (mime) { + const remoteExt = CoreMimetypeUtils.instance.getExtension(mime, url); + // If the file is from Google Drive, ignore mimetype application/json. + if (remoteExt && (!ext || mime != 'application/json')) { + if (ext) { + // Remove existing extension since we will use another one. + path = CoreMimetypeUtils.instance.removeExtension(path); + } + path += '.' + remoteExt; + + return remoteExt; + } + } + + return ext; + }); + } else { + promise = Promise.resolve(ext); + } + } else { + promise = Promise.resolve(''); + } + + return promise.then((extension) => { + return CoreFile.instance.moveFile(tmpPath, path).then((movedEntry) => { + // Save the extension. + movedEntry.extension = extension; + movedEntry.path = path; + this.logger.debug(`Success downloading file ${url} to ${path} with extension ${extension}`); + + return movedEntry; + }); + }); + }); + }).catch((err) => { + this.logger.error(`Error downloading ${url} to ${path}`, err); + + return Promise.reject(err); + }); + } + + /** + * Get a promise from the cache. + * + * @param method Method of the HTTP request. + * @param url Base URL of the HTTP request. + * @param params Params of the HTTP request. + */ + protected getPromiseHttp(method: string, url: string, params?: any): any { + const queueItemId = this.getQueueItemId(method, url, params); + if (typeof this.ongoingCalls[queueItemId] != 'undefined') { + return this.ongoingCalls[queueItemId]; + } + + return false; + } + + /** + * Perform a HEAD request to get the mimetype of a remote file. + * + * @param url File URL. + * @param ignoreCache True to ignore cache, false otherwise. + * @return Promise resolved with the mimetype or '' if failure. + */ + getRemoteFileMimeType(url: string, ignoreCache?: boolean): Promise { + if (this.mimeTypeCache[url] && !ignoreCache) { + return Promise.resolve(this.mimeTypeCache[url]); + } + + return this.performHead(url).then((response) => { + let mimeType = response.headers.get('Content-Type'); + if (mimeType) { + // Remove "parameters" like charset. + mimeType = mimeType.split(';')[0]; + } + this.mimeTypeCache[url] = mimeType; + + return mimeType || ''; + }).catch(() => { + // Error, resolve with empty mimetype. + return ''; + }); + } + + /** + * Perform a HEAD request to get the size of a remote file. + * + * @param url File URL. + * @return Promise resolved with the size or -1 if failure. + */ + getRemoteFileSize(url: string): Promise { + return this.performHead(url).then((response) => { + const size = parseInt(response.headers.get('Content-Length'), 10); + + if (size) { + return size; + } + + return -1; + }).catch(() => { + // Error, return -1. + return -1; + }); + } + + /** + * Get a request timeout based on the network connection. + * + * @return Timeout in ms. + */ + getRequestTimeout(): number { + return CoreApp.instance.isNetworkAccessLimited() ? CoreConstants.WS_TIMEOUT : CoreConstants.WS_TIMEOUT_WIFI; + } + + /** + * Get the unique queue item id of the cache for a HTTP request. + * + * @param method Method of the HTTP request. + * @param url Base URL of the HTTP request. + * @param params Params of the HTTP request. + * @return Queue item ID. + */ + protected getQueueItemId(method: string, url: string, params?: any): string { + if (params) { + url += '###' + CoreInterceptor.serialize(params); + } + + return method + '#' + Md5.hashAsciiStr(url); + } + + /** + * Call a Moodle WS using the AJAX API. + * + * @param method The WebService method to be called. + * @param data Arguments to pass to the method. + * @param preSets Extra settings and information. Only some + * @return Promise resolved with the response data in success and rejected with an object containing: + * - error: Error message. + * - errorcode: Error code returned by the site (if any). + * - available: 0 if unknown, 1 if available, -1 if not available. + */ + protected performAjax(method: string, data: any, preSets: CoreWSAjaxPreSets): Promise { + + let promise; + + if (typeof preSets.siteUrl == 'undefined') { + return rejectWithError(this.createFakeWSError('core.unexpectederror', true)); + } else if (!CoreApp.instance.isOnline()) { + return rejectWithError(this.createFakeWSError('core.networkerrormsg', true)); + } + + if (typeof preSets.responseExpected == 'undefined') { + preSets.responseExpected = true; + } + + const script = preSets.noLogin ? 'service-nologin.php' : 'service.php'; + const ajaxData = [{ + index: 0, + methodname: method, + args: this.convertValuesToString(data) + }]; + + // The info= parameter has no function. It is just to help with debugging. + // We call it info to match the parameter name use by Moodle's AMD ajax module. + let siteUrl = preSets.siteUrl + '/lib/ajax/' + script + '?info=' + method; + + if (preSets.noLogin && preSets.useGet) { + // Send params using GET. + siteUrl += '&args=' + encodeURIComponent(JSON.stringify(ajaxData)); + + promise = this.sendHTTPRequest(siteUrl, { + method: 'get', + }); + } else { + promise = this.sendHTTPRequest(siteUrl, { + method: 'post', + data: ajaxData, + serializer: 'json', + }); + } + + return promise.then((response: HttpResponse) => { + let data = response.body; + + // Some moodle web services return null. + // If the responseExpected value is set then so long as no data is returned, we create a blank object. + if (!data && !preSets.responseExpected) { + data = [{}]; + } + + // Check if error. Ajax layer should always return an object (if error) or an array (if success). + if (!data || typeof data != 'object') { + return rejectWithError(this.createFakeWSError('core.serverconnection', true)); + } else if (data.error) { + return rejectWithError(data); + } + + // Get the first response since only one request was done. + data = data[0]; + + if (data.error) { + return rejectWithError(data.exception); + } + + return data.data; + }, (data) => { + const available = data.status == 404 ? -1 : 0; + + return rejectWithError(this.createFakeWSError('core.serverconnection', true), available); + }); + + // Convenience function to return an error. + function rejectWithError(exception: any, available?: number): Promise { + if (typeof available == 'undefined') { + if (exception.errorcode) { + available = exception.errorcode == 'invalidrecord' ? -1 : 1; + } else { + available = 0; + } + } + + exception.available = available; + + return Promise.reject(exception); + } + } + + /** + * Perform a HEAD request and save the promise while waiting to be resolved. + * + * @param url URL to perform the request. + * @return Promise resolved with the response. + */ + performHead(url: string): Promise> { + let promise = this.getPromiseHttp('head', url); + + if (!promise) { + promise = this.sendHTTPRequest(url, { + method: 'head', + responseType: 'text', + }); + + promise = this.setPromiseHttp(promise, 'head', url); + } + + return promise; + } + + /** + * Perform the post call and save the promise while waiting to be resolved. + * + * @param method The WebService method to be called. + * @param siteUrl Complete site url to perform the call. + * @param ajaxData Arguments to pass to the method. + * @param preSets Extra settings and information. + * @return Promise resolved with the response data in success and rejected with CoreWSError if it fails. + */ + performPost(method: string, siteUrl: string, ajaxData: any, preSets: CoreWSPreSets): Promise { + const options: any = {}; + + // This is done because some returned values like 0 are treated as null if responseType is json. + if (preSets.typeExpected == 'number' || preSets.typeExpected == 'boolean' || preSets.typeExpected == 'string') { + // Avalaible values are: https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/responseType + options.responseType = 'text'; + } + + // We add the method name to the URL purely to help with debugging. + // This duplicates what is in the ajaxData, but that does no harm. + // POST variables take precedence over GET. + const requestUrl = siteUrl + '&wsfunction=' + method; + + // Perform the post request. + const promise = Http.instance.post(requestUrl, ajaxData, options).pipe(timeout(this.getRequestTimeout())).toPromise(); + + return promise.then((data: any) => { + + // Some moodle web services return null. + // If the responseExpected value is set to false, we create a blank object if the response is null. + if (!data && !preSets.responseExpected) { + data = {}; + } + + if (!data) { + return Promise.reject(this.createFakeWSError('core.serverconnection', true)); + } else if (typeof data != preSets.typeExpected) { + // If responseType is text an string will be returned, parse before returning. + if (typeof data == 'string') { + if (preSets.typeExpected == 'number') { + data = Number(data); + if (isNaN(data)) { + this.logger.warn(`Response expected type "${preSets.typeExpected}" cannot be parsed to number`); + + return Promise.reject(this.createFakeWSError('core.errorinvalidresponse', true)); + } + } else if (preSets.typeExpected == 'boolean') { + if (data === 'true') { + data = true; + } else if (data === 'false') { + data = false; + } else { + this.logger.warn(`Response expected type "${preSets.typeExpected}" is not true or false`); + + return Promise.reject(this.createFakeWSError('core.errorinvalidresponse', true)); + } + } else { + this.logger.warn('Response of type "' + typeof data + `" received, expecting "${preSets.typeExpected}"`); + + return Promise.reject(this.createFakeWSError('core.errorinvalidresponse', true)); + } + } else { + this.logger.warn('Response of type "' + typeof data + `" received, expecting "${preSets.typeExpected}"`); + + return Promise.reject(this.createFakeWSError('core.errorinvalidresponse', true)); + } + } + + if (typeof data.exception !== 'undefined') { + // Special debugging for site plugins, otherwise it's hard to debug errors if the data is cached. + if (method == 'tool_mobile_get_content') { + this.logger.error('Error calling WS', method, data); + } + + return Promise.reject(data); + } + + if (typeof data.debuginfo != 'undefined') { + return Promise.reject(this.createFakeWSError('Error. ' + data.message)); + } + + return data; + }, (error) => { + // If server has heavy load, retry after some seconds. + if (error.status == 429) { + const retryPromise = this.addToRetryQueue(method, siteUrl, ajaxData, preSets); + + // Only process the queue one time. + if (this.retryTimeout == 0) { + this.retryTimeout = parseInt(error.headers.get('Retry-After'), 10) || 5; + this.logger.warn(`${error.statusText}. Retrying in ${this.retryTimeout} seconds. ` + + `${this.retryCalls.length} calls left.`); + + setTimeout(() => { + this.logger.warn(`Retrying now with ${this.retryCalls.length} calls to process.`); + // Finish timeout. + this.retryTimeout = 0; + this.processRetryQueue(); + }, this.retryTimeout * 1000); + } else { + this.logger.warn('Calls locked, trying later...'); + } + + return retryPromise; + } + + return Promise.reject(this.createFakeWSError('core.serverconnection', true)); + }); + } + + /** + * Retry all requests in the queue. + * This function uses recursion in order to add a delay between requests to reduce stress. + */ + protected processRetryQueue(): void { + if (this.retryCalls.length > 0 && this.retryTimeout == 0) { + const call = this.retryCalls.shift(); + // Add a delay between calls. + setTimeout(() => { + call.deferred.resolve(this.performPost(call.method, call.siteUrl, call.ajaxData, call.preSets)); + this.processRetryQueue(); + }, 200); + } else { + this.logger.warn(`Retry queue has stopped with ${this.retryCalls.length} calls and ${this.retryTimeout} timeout secs.`); + } + } + + /** + * Save promise on the cache. + * + * @param promise Promise to be saved. + * @param method Method of the HTTP request. + * @param url Base URL of the HTTP request. + * @param params Params of the HTTP request. + * @return The promise saved. + */ + protected setPromiseHttp(promise: Promise, method: string, url: string, params?: any): Promise { + const queueItemId = this.getQueueItemId(method, url, params); + let timeout; + + this.ongoingCalls[queueItemId] = promise; + + // HTTP not finished, but we should delete the promise after timeout. + timeout = setTimeout(() => { + delete this.ongoingCalls[queueItemId]; + }, this.getRequestTimeout()); + + // HTTP finished, delete from ongoing. + return promise.finally(() => { + delete this.ongoingCalls[queueItemId]; + + clearTimeout(timeout); + }); + } + + /** + * A wrapper function for a synchronous Moodle WebService call. + * Warning: This function should only be used if synchronous is a must. It's recommended to use call. + * + * @param method The WebService method to be called. + * @param data Arguments to pass to the method. + * @param preSets Extra settings and information. + * @return Promise resolved with the response data in success and rejected with the error message if it fails. + * @return Request response. If the request fails, returns an object with 'error'=true and 'message' properties. + */ + syncCall(method: string, data: any, preSets: CoreWSPreSets): any { + const errorResponse = { + error: true, + message: '', + }; + + if (!preSets) { + errorResponse.message = Translate.instance.instant('core.unexpectederror'); + + return errorResponse; + } else if (!CoreApp.instance.isOnline()) { + errorResponse.message = Translate.instance.instant('core.networkerrormsg'); + + return errorResponse; + } + + preSets.typeExpected = preSets.typeExpected || 'object'; + if (typeof preSets.responseExpected == 'undefined') { + preSets.responseExpected = true; + } + + data = this.convertValuesToString(data || {}, preSets.cleanUnicode); + if (data == null) { + // Empty cleaned text found. + errorResponse.message = Translate.instance.instant('core.unicodenotsupportedcleanerror'); + + return errorResponse; + } + + data.wsfunction = method; + data.wstoken = preSets.wsToken; + const siteUrl = preSets.siteUrl + '/webservice/rest/server.php?moodlewsrestformat=json'; + + // Serialize data. + data = CoreInterceptor.serialize(data); + + // Perform sync request using XMLHttpRequest. + const xhr = new ( window).XMLHttpRequest(); + xhr.open('post', siteUrl, false); + xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded;charset=utf-8'); + + xhr.send(data); + + // Get response. + data = ('response' in xhr) ? xhr.response : xhr.responseText; + + // Check status. + const status = Math.max(xhr.status === 1223 ? 204 : xhr.status, 0); + if (status < 200 || status >= 300) { + // Request failed. + errorResponse.message = data; + + return errorResponse; + } + + // Treat response. + data = CoreTextUtils.instance.parseJSON(data); + + // Some moodle web services return null. + // If the responseExpected value is set then so long as no data is returned, we create a blank object. + if ((!data || !data.data) && !preSets.responseExpected) { + data = {}; + } + + if (!data) { + errorResponse.message = Translate.instance.instant('core.serverconnection'); + } else if (typeof data != preSets.typeExpected) { + this.logger.warn('Response of type "' + typeof data + '" received, expecting "' + preSets.typeExpected + '"'); + errorResponse.message = Translate.instance.instant('core.errorinvalidresponse'); + } + + if (typeof data.exception != 'undefined' || typeof data.debuginfo != 'undefined') { + errorResponse.message = data.message; + } + + if (errorResponse.message !== '') { + return errorResponse; + } + + return data; + } + + /* + * Uploads a file. + * + * @param filePath File path. + * @param options File upload options. + * @param preSets Must contain siteUrl and wsToken. + * @param onProgress Function to call on progress. + * @return Promise resolved when uploaded. + */ + uploadFile(filePath: string, options: CoreWSFileUploadOptions, preSets: CoreWSPreSets, + onProgress?: (event: ProgressEvent) => any): Promise { + this.logger.debug(`Trying to upload file: ${filePath}`); + + if (!filePath || !options || !preSets) { + return Promise.reject(null); + } + + if (!CoreApp.instance.isOnline()) { + return Promise.reject(Translate.instance.instant('core.networkerrormsg')); + } + + const uploadUrl = preSets.siteUrl + '/webservice/upload.php'; + const transfer = FileTransfer.instance.create(); + + transfer.onProgress(onProgress); + + options.httpMethod = 'POST'; + options.params = { + token: preSets.wsToken, + filearea: options.fileArea || 'draft', + itemid: options.itemId || 0 + }; + options.chunkedMode = false; + options.headers = { + Connection: 'close' + }; + + return transfer.upload(filePath, uploadUrl, options, true).then((success) => { + const data = CoreTextUtils.instance.parseJSON(success.response, null, + this.logger.error.bind(this.logger, 'Error parsing response from upload', success.response)); + if (data === null) { + return Promise.reject(Translate.instance.instant('core.errorinvalidresponse')); + } + + if (!data) { + return Promise.reject(Translate.instance.instant('core.serverconnection')); + } else if (typeof data != 'object') { + this.logger.warn('Upload file: Response of type "' + typeof data + '" received, expecting "object"'); + + return Promise.reject(Translate.instance.instant('core.errorinvalidresponse')); + } + + if (typeof data.exception !== 'undefined') { + return Promise.reject(data.message); + } else if (data && typeof data.error !== 'undefined') { + return Promise.reject(data.error); + } else if (data[0] && typeof data[0].error !== 'undefined') { + return Promise.reject(data[0].error); + } + + // We uploaded only 1 file, so we only return the first file returned. + this.logger.debug('Successfully uploaded file', filePath); + + return data[0]; + }).catch((error) => { + this.logger.error('Error while uploading file', filePath, error); + + return Promise.reject(Translate.instance.instant('core.errorinvalidresponse')); + }); + } + + /** + * Perform an HTTP request requesting for a text response. + * + * @param url Url to get. + * @return Resolved with the text when done. + */ + async getText(url: string): Promise { + // Fetch the URL content. + const options: HttpRequestOptions = { + method: 'get', + responseType: 'text', + }; + + const response = await this.sendHTTPRequest(url, options); + + const content = response.body; + + if (typeof content !== 'string') { + throw new Error('Error reading content'); + } + + return content; + } + + /** + * Send an HTTP request. In mobile devices it will use the cordova plugin. + * + * @param url URL of the request. + * @param options Options for the request. + * @return Promise resolved with the response. + */ + async sendHTTPRequest(url: string, options: HttpRequestOptions): Promise> { + + // Set default values. + options.responseType = options.responseType || 'json'; + options.timeout = typeof options.timeout == 'undefined' ? this.getRequestTimeout() : options.timeout; + + if (CoreApp.instance.isIOS()) { + // Use the cordova plugin. + if (url.indexOf('file://') === 0) { + // We cannot load local files using the http native plugin. Use file provider instead. + const format = options.responseType == 'json' ? CoreFileProvider.FORMATJSON : CoreFileProvider.FORMATTEXT; + + const content = await CoreFile.instance.readFile(url, format); + + return new HttpResponse({ + body: content, + headers: null, + status: 200, + statusText: 'OK', + url, + }); + } + + return new Promise>((resolve, reject): void => { + // We cannot use Ionic Native plugin because it doesn't have the sendRequest method. + ( cordova).plugin.http.sendRequest(url, options, (response) => { + resolve(new CoreNativeToAngularHttpResponse(response)); + }, reject); + }); + } else { + let observable: Observable; + + // Use Angular's library. + switch (options.method) { + case 'get': + observable = Http.instance.get(url, { + headers: options.headers, + params: options.params, + observe: 'response', + responseType: options.responseType, + }); + break; + + case 'post': + if (options.serializer == 'json') { + options.data = JSON.stringify(options.data); + } + + observable = Http.instance.post(url, options.data, { + headers: options.headers, + observe: 'response', + responseType: options.responseType, + }); + break; + + case 'head': + observable = Http.instance.head(url, { + headers: options.headers, + observe: 'response', + responseType: options.responseType + }); + break; + + default: + return Promise.reject('Method not implemented yet.'); + } + + if (options.timeout) { + observable = observable.pipe(timeout(options.timeout)); + } + + return observable.toPromise(); + } + } +} + +export class CoreWS extends makeSingleton(CoreWSProvider) {} + +/** + * Error returned by a WS call. + */ +export interface CoreWSError { + /** + * The error message. + */ + message: string; + + /** + * Name of the exception. Undefined for local errors (fake WS errors). + */ + exception?: string; + + /** + * The error code. Undefined for local errors (fake WS errors). + */ + errorcode?: string; +} + +/** + * File upload options. + */ +export interface CoreWSFileUploadOptions extends FileUploadOptions { + /** + * The file area where to put the file. By default, 'draft'. + */ + fileArea?: string; + + /** + * Item ID of the area where to put the file. By default, 0. + */ + itemId?: number; +} + +/** + * Structure of warnings returned by WS. + */ +export type CoreWSExternalWarning = { + /** + * Item. + */ + item?: string; + + /** + * Item id. + */ + itemid?: number; + + /** + * The warning code can be used by the client app to implement specific behaviour. + */ + warningcode: string; + + /** + * Untranslated english message to explain the warning. + */ + message: string; + +}; + +/** + * Structure of files returned by WS. + */ +export type CoreWSExternalFile = { + /** + * File name. + */ + filename?: string; + + /** + * File path. + */ + filepath?: string; + + /** + * File size. + */ + filesize?: number; + + /** + * Downloadable file url. + */ + fileurl?: string; + + /** + * Time modified. + */ + timemodified?: number; + + /** + * File mime type. + */ + mimetype?: string; + + /** + * Whether is an external file. + */ + isexternalfile?: number; + + /** + * The repository type for external files. + */ + repositorytype?: string; + +}; + +/** + * Data returned by date_exporter. + */ +export type CoreWSDate = { + seconds: number; // Seconds. + minutes: number; // Minutes. + hours: number; // Hours. + mday: number; // Mday. + wday: number; // Wday. + mon: number; // Mon. + year: number; // Year. + yday: number; // Yday. + weekday: string; // Weekday. + month: string; // Month. + timestamp: number; // Timestamp. +}; + + +/** + * PreSets accepted by the WS call. + */ +export type CoreWSPreSets = { + /** + * The site URL. + */ + siteUrl: string; + + /** + * The Webservice token. + */ + wsToken: string; + + /** + * 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; + + /** + * Defaults to false. Clean multibyte Unicode chars from data. + */ + cleanUnicode?: boolean; +} + +/** + * PreSets accepted by AJAX WS calls. + */ +export type CoreWSAjaxPreSets = { + /** + * The site URL. + */ + siteUrl: string; + + /** + * Defaults to true. Set to false when the expected response is null. + */ + responseExpected?: boolean; + + /** + * Whether to use the no-login endpoint instead of the normal one. Use it for requests that don't require authentication. + */ + noLogin?: boolean; + + /** + * Whether to send the parameters via GET. Only if noLogin is true. + */ + useGet?: boolean; +} + +/** + * Options for HTTP requests. + */ +export type HttpRequestOptions = { + /** + * The HTTP method. + */ + method: string; + + /** + * Payload to send to the server. Only applicable on post, put or patch methods. + */ + data?: any; + + /** + * Query params to be appended to the URL (only applicable on get, head, delete, upload or download methods). + */ + params?: any; + + /** + * Response type. Defaults to json. + */ + responseType?: 'json' | 'text' | 'arraybuffer' | 'blob'; + + /** + * Timeout for the request in seconds. If undefined, the default value will be used. If null, no timeout. + */ + timeout?: number | null; + + /** + * Serializer to use. Defaults to 'urlencoded'. Only for mobile environments. + */ + serializer?: string; + + /** + * Whether to follow redirects. Defaults to true. Only for mobile environments. + */ + followRedirect?: boolean; + + /** + * Headers. Only for mobile environments. + */ + headers?: {[name: string]: string}; + + /** + * File paths to use for upload or download. Only for mobile environments. + */ + filePath?: string; + + /** + * Name to use during upload. Only for mobile environments. + */ + name?: string; +}; + diff --git a/src/app/singletons/array.ts b/src/app/singletons/array.ts new file mode 100644 index 000000000..a35ca1450 --- /dev/null +++ b/src/app/singletons/array.ts @@ -0,0 +1,65 @@ +// (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. + +/** + * Singleton with helper functions for arrays. + */ +export class CoreArray { + + /** + * Check whether an array contains an item. + * + * @param arr Array. + * @param item Item. + * @return Whether item is within the array. + */ + static contains(arr: T[], item: T): boolean { + return arr.indexOf(item) !== -1; + } + + /** + * Flatten the first dimension of a multi-dimensional array. + * + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/flat#reduce_and_concat + * + * @param arr Original array. + * @return Flattened array. + */ + static flatten(arr: T[][]): T[] { + if ('flat' in arr) { + return (arr as any).flat(); + } + + return [].concat(...arr); + } + + /** + * Obtain a new array without the specified item. + * + * @param arr Array. + * @param item Item to remove. + * @return Array without the specified item. + */ + static withoutItem(arr: T[], item: T): T[] { + const newArray = [...arr]; + const index = arr.indexOf(item); + + if (index !== -1) { + newArray.splice(index, 1); + } + + return newArray; + } + +} diff --git a/src/app/singletons/url.ts b/src/app/singletons/url.ts new file mode 100644 index 000000000..36a2d9acb --- /dev/null +++ b/src/app/singletons/url.ts @@ -0,0 +1,200 @@ +// (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 { CoreTextUtils } from '@services/utils/text'; + +/** + * Parts contained within a url. + */ +interface UrlParts { + + /** + * Url protocol. + */ + protocol?: string; + + /** + * Url domain. + */ + domain?: string; + + /** + * Url port. + */ + port?: string; + + /** + * Url credentials: username and password (if any). + */ + credentials?: string; + + /** + * Url's username. + */ + username?: string; + + /** + * Url's password. + */ + password?: string; + + /** + * Url path. + */ + path?: string; + + /** + * Url query. + */ + query?: string; + + /** + * Url fragment. + */ + fragment?: string; + +} + +/** + * Singleton with helper functions for urls. + */ +export class CoreUrl { + + // Avoid creating singleton instances. + private constructor() {} + + /** + * Parse parts of a url, using an implicit protocol if it is missing from the url. + * + * @param url Url. + * @return Url parts. + */ + static parse(url: string): UrlParts | null { + // Parse url with regular expression taken from RFC 3986: https://tools.ietf.org/html/rfc3986#appendix-B. + const match = url.trim().match(/^(([^:/?#]+):)?(\/\/([^/?#]*))?([^?#]*)(\?([^#]*))?(#(.*))?/); + + if (!match) { + return null; + } + + const host = match[4] || ''; + + // Get the credentials and the port from the host. + const [domainAndPort, credentials]: string[] = host.split('@').reverse(); + const [domain, port]: string[] = domainAndPort.split(':'); + const [username, password]: string[] = credentials ? credentials.split(':') : []; + + // Prepare parts replacing empty strings with undefined. + return { + protocol: match[2] || undefined, + domain: domain || undefined, + port: port || undefined, + credentials: credentials || undefined, + username: username || undefined, + password: password || undefined, + path: match[5] || undefined, + query: match[7] || undefined, + fragment: match[9] || undefined, + }; + } + + /** + * Guess the Moodle domain from a site url. + * + * @param url Site url. + * @return Guessed Moodle domain. + */ + static guessMoodleDomain(url: string): string | null { + // Add protocol if it was missing. Moodle can only be served through http or https, so this is a fair assumption to make. + if (!url.match(/^https?:\/\//)) { + url = `https://${url}`; + } + + // Match using common suffixes. + const knownSuffixes = [ + '\/my\/?', + '\/\\\?redirect=0', + '\/index\\\.php', + '\/course\/view\\\.php', + '\/login\/index\\\.php', + '\/mod\/page\/view\\\.php', + ]; + const match = url.match(new RegExp(`^https?:\/\/(.*?)(${knownSuffixes.join('|')})`)); + + if (match) { + return match[1]; + } + + // If nothing else worked, parse the domain. + const urlParts = CoreUrl.parse(url); + + return urlParts && urlParts.domain ? urlParts.domain : null; + } + + /** + * Returns the pattern to check if the URL is a valid Moodle Url. + * + * @return Desired RegExp. + */ + static getValidMoodleUrlPattern(): RegExp { + // Regular expression based on RFC 3986: https://tools.ietf.org/html/rfc3986#appendix-B. + // Improved to not admit spaces. + return new RegExp(/^(([^:/?# ]+):)?(\/\/([^/?# ]*))?([^?# ]*)(\?([^#]*))?(#(.*))?$/); + } + + /** + * Check if the given url is valid for the app to connect. + * + * @param url Url to check. + * @return True if valid, false otherwise. + */ + static isValidMoodleUrl(url: string): boolean { + const patt = CoreUrl.getValidMoodleUrlPattern(); + + return patt.test(url.trim()); + } + + /** + * Removes protocol from the url. + * + * @param url Site url. + * @return Url without protocol. + */ + static removeProtocol(url: string): string { + return url.replace(/^[a-zA-Z]+:\/\//i, ''); + } + + /** + * Check if two URLs have the same domain and path. + * + * @param urlA First URL. + * @param urlB Second URL. + * @return Whether they have same domain and path. + */ + static sameDomainAndPath(urlA: string, urlB: string): boolean { + // Add protocol if missing, the parse function requires it. + if (!urlA.match(/^[^\/:\.\?]*:\/\//)) { + urlA = `https://${urlA}`; + } + if (!urlB.match(/^[^\/:\.\?]*:\/\//)) { + urlB = `https://${urlB}`; + } + + const partsA = CoreUrl.parse(urlA); + const partsB = CoreUrl.parse(urlB); + + return partsA.domain == partsB.domain && + CoreTextUtils.instance.removeEndingSlash(partsA.path) == CoreTextUtils.instance.removeEndingSlash(partsB.path); + } +} diff --git a/src/app/singletons/window.ts b/src/app/singletons/window.ts new file mode 100644 index 000000000..4ea9f6d46 --- /dev/null +++ b/src/app/singletons/window.ts @@ -0,0 +1,76 @@ +// (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 { CoreFileHelper } from '@services/file-helper'; +import { CoreSites } from '@services/sites'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreUtils } from '@services/utils/utils'; + +/** + * Options for the open function. + */ +export type CoreWindowOpenOptions = { + /** + * NavController to use when opening the link in the app. + */ + navCtrl?: any; // @todo NavController; +}; + +/** + * Singleton with helper functions for windows. + */ +export class CoreWindow { + + /** + * "Safe" implementation of window.open. It will open the URL without overriding the app. + * + * @param url URL to open. + * @param name Name of the browsing context into which to load the URL. + * @param options Other options. + * @return Promise resolved when done. + */ + static async open(url: string, name?: string, options?: CoreWindowOpenOptions): Promise { + if (CoreUrlUtils.instance.isLocalFileUrl(url)) { + const filename = url.substr(url.lastIndexOf('/') + 1); + + if (!CoreFileHelper.instance.isOpenableInApp({ filename })) { + try { + await CoreFileHelper.instance.showConfirmOpenUnsupportedFile(); + } catch (error) { + return; // Cancelled, stop. + } + } + + await CoreUtils.instance.openFile(url); + } else { + let treated: boolean; + options = options || {}; + + if (name != '_system') { + // Check if it can be opened in the app. + treated = false; // @todo await CoreContentLinksHelper.instance.handleLink(url, undefined, options.navCtrl, true, true); + } + + if (!treated) { + // Not opened in the app, open with browser. Check if we need to auto-login + if (!CoreSites.instance.isLoggedIn()) { + // Not logged in, cannot auto-login. + CoreUtils.instance.openInBrowser(url); + } else { + await CoreSites.instance.getCurrentSite().openInBrowserWithAutoLoginIfSameSite(url); + } + } + } + } +}