From 64f1551d56b330aa4297e89d0527ef16ccfd5af7 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 16 Jan 2018 12:35:16 +0100 Subject: [PATCH] MOBILE-2310 course: Implement prefetch delegate and base handler --- src/classes/cache.ts | 99 ++ .../course/classes/module-prefetch-handler.ts | 514 +++++++ src/core/course/course.module.ts | 2 + src/core/course/providers/course.ts | 22 +- src/core/course/providers/helper.ts | 20 - .../providers/module-prefetch-delegate.ts | 1240 +++++++++++++++++ src/providers/utils/utils.ts | 9 +- 7 files changed, 1881 insertions(+), 25 deletions(-) create mode 100644 src/classes/cache.ts create mode 100644 src/core/course/classes/module-prefetch-handler.ts create mode 100644 src/core/course/providers/module-prefetch-delegate.ts diff --git a/src/classes/cache.ts b/src/classes/cache.ts new file mode 100644 index 000000000..c117dc71b --- /dev/null +++ b/src/classes/cache.ts @@ -0,0 +1,99 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/** + * A cache to store values in memory to speed up processes. + * + * The data is organized by "entries" that are identified by an ID. Each entry can have multiple values stored, + * and each value has its own timemodified. + * + * Values expire after a certain time. + */ +export class CoreCache { + protected cacheStore = {}; + + constructor() {} + + /** + * Clear the cache. + */ + clear() { + this.cacheStore = {}; + } + + /** + * Get all the data stored in the cache for a certain id. + * + * @param {any} id The ID to identify the entry. + * @return {any} The data from the cache. Undefined if not found. + */ + getEntry(id: any) : any { + if (!this.cacheStore[id]) { + this.cacheStore[id] = {}; + } + + return this.cacheStore[id]; + } + + /** + * Get the status of a module from the "cache". + * + * @param {any} id The ID to identify the entry. + * @param {string} name Name of the value to get. + * @param {boolean} [ignoreInvalidate] Whether it should always return the cached data, even if it's expired. + * @return {any} Cached value. Undefined if not cached or expired. + */ + getValue(id: any, name: string, ignoreInvalidate?: boolean) : any { + const entry = this.getEntry(id); + + if (entry[name] && typeof entry[name].value != 'undefined') { + const now = Date.now(); + // Invalidate after 5 minutes. + if (ignoreInvalidate || entry[name].timemodified + 300000 >= now) { + return entry[name].value; + } + } + + return undefined; + } + + /** + * Invalidate all the cached data for a certain entry. + * + * @param {any} id The ID to identify the entry. + */ + invalidate(id: any) : void { + const entry = this.getEntry(id); + for (let name in entry) { + entry[name].timemodified = 0; + } + } + + /** + * Update the status of a module in the "cache". + * + * @param {any} id The ID to identify the entry. + * @param {string} name Name of the value to set. + * @param {any} value Value to set. + * @return {any} The set value. + */ + setValue(id: any, name: string, value: any) : any { + const entry = this.getEntry(id); + entry[name] = { + value: value, + timemodified: Date.now() + }; + return value; + } +} diff --git a/src/core/course/classes/module-prefetch-handler.ts b/src/core/course/classes/module-prefetch-handler.ts new file mode 100644 index 000000000..ea263df1c --- /dev/null +++ b/src/core/course/classes/module-prefetch-handler.ts @@ -0,0 +1,514 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injector } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreAppProvider } from '../../../providers/app'; +import { CoreFilepoolProvider } from '../../../providers/filepool'; +import { CoreSitesProvider } from '../../../providers/sites'; +import { CoreDomUtilsProvider } from '../../../providers/utils/dom'; +import { CoreUtilsProvider } from '../../../providers/utils/utils'; +import { CoreCourseProvider } from '../providers/course'; +import { CoreCourseModulePrefetchHandler } from '../providers/module-prefetch-delegate'; +import { CoreConstants } from '../../constants'; + +/** + * A prefetch function to be passed to prefetchPackage. + * This function should NOT call storePackageStatus, downloadPackage or prefetchPakage from filepool. + * It receives the same params as prefetchPackage except the function itself. This includes all extra parameters sent after siteId. + * The string returned by this function will be stored as "extra" data in the filepool package. If you don't need to store + * extra data, don't return anything. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @param {boolean} single True if we're downloading a single module, false if we're downloading a whole section. + * @param {string} siteId Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the prefetch finishes. The string returned will be stored as "extra" data in the + * filepool package. If you don't need to store extra data, don't return anything. + */ +export type prefetchFunction = (module: any, courseId: number, single: boolean, siteId: string, ...args) => Promise; + +/** + * Base prefetch handler to be registered in CoreCourseModulePrefetchDelegate. It is useful to minimize the amount of + * functions that handlers need to implement. It also provides some helper features like preventing a module to be + * downloaded twice at the same time. + * + * If your handler inherits from this service, you just need to override the functions that you want to change. + * + * The implementation of this default handler is aimed for resources that only need to prefetch files, not WebService calls. + * + * By default, prefetching a module will only download its files (downloadOrPrefetch). This might be enough for resources. + * If you need to prefetch WebServices, then you need to override the "download" and "prefetch" functions. In this case, it's + * recommended to call the prefetchPackage function since it'll handle changing the status of the module. + */ +export class CoreCourseModulePrefetchHandlerBase implements CoreCourseModulePrefetchHandler { + /** + * A name to identify the addon. + * @type {string} + */ + name = 'CoreCourseModulePrefetchHandlerBase'; + + /** + * Name of the module. It should match the "modname" of the module returned in core_course_get_contents. + * @type {string} + */ + modname = ''; + + /** + * The handler's component. + * @type {string} + */ + component = 'core_module'; + + /** + * The RegExp to check updates. If a module has an update whose name matches this RegExp, the module will be marked + * as outdated. This RegExp is ignored if hasUpdates function is defined. + * @type {RegExp} + */ + updatesNames = /^.*files$/; + + /** + * Whether the module is a resource (true) or an activity (false). + * @type {boolean} + */ + isResource: boolean; + + /** + * List of download promises to prevent downloading the module twice at the same time. + * @type {Object} + */ + protected downloadPromises: {[s: string]: {[s: string]: Promise}} = {}; + + // List of services that will be injected using injector. It's done like this so subclasses don't have to send all the + // services to the parent in the constructor. + protected translate: TranslateService; + protected appProvider: CoreAppProvider; + protected courseProvider: CoreCourseProvider; + protected filepoolProvider: CoreFilepoolProvider; + protected sitesProvider: CoreSitesProvider; + protected domUtils: CoreDomUtilsProvider; + protected utils: CoreUtilsProvider; + + constructor(injector: Injector) { + this.translate = injector.get(TranslateService); + this.appProvider = injector.get(CoreAppProvider); + this.courseProvider = injector.get(CoreCourseProvider); + this.filepoolProvider = injector.get(CoreFilepoolProvider); + this.sitesProvider = injector.get(CoreSitesProvider); + this.domUtils = injector.get(CoreDomUtilsProvider); + this.utils = injector.get(CoreUtilsProvider); + } + + /** + * Add an ongoing download to the downloadPromises list. When the promise finishes it will be removed. + * + * @param {number} id Unique identifier per component. + * @param {Promise} promise Promise to add. + * @param {String} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise of the current download. + */ + addOngoingDownload(id: number, promise: Promise, siteId?: string) : Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const uniqueId = this.getUniqueId(id); + + if (!this.downloadPromises[siteId]) { + this.downloadPromises[siteId] = {}; + } + + this.downloadPromises[siteId][uniqueId] = promise.finally(() => { + delete this.downloadPromises[siteId][uniqueId]; + }); + + return this.downloadPromises[siteId][uniqueId]; + } + + /** + * Download the module. + * + * @param {any} module The module object returned by WS. + * @param {number} courseId Course ID. + * @return {Promise} Promise resolved when all content is downloaded. + */ + download(module: any, courseId: number) : Promise { + return this.downloadOrPrefetch(module, courseId, false); + } + + /** + * Download or prefetch the content. + * + * @param {any} module The module object returned by WS. + * @param {number} courseId Course ID. + * @param {boolean} [prefetch] True to prefetch, false to download right away. + * @param {string} [dirPath] Path of the directory where to store all the content files. This is to keep the files + * relative paths and make the package work in an iframe. Undefined to download the files + * in the filepool root folder. + * @return {Promise} Promise resolved when all content is downloaded. Data returned is not reliable. + */ + downloadOrPrefetch(module: any, courseId: number, prefetch?: boolean, dirPath?: string) : Promise { + if (!this.appProvider.isOnline()) { + // Cannot download in offline. + return Promise.reject(this.translate.instant('core.networkerrormsg')); + } + + const siteId = this.sitesProvider.getCurrentSiteId(); + + // Load module contents (ignore cache so we always have the latest data). + return this.loadContents(module, courseId, true).then(() => { + // Get the intro files. + return this.getIntroFiles(module, courseId); + }).then((introFiles) => { + let downloadFn = prefetch ? this.filepoolProvider.prefetchPackage.bind(this.filepoolProvider) : + this.filepoolProvider.downloadPackage.bind(this.filepoolProvider), + contentFiles = this.getContentDownloadableFiles(module), + promises = []; + + if (dirPath) { + // Download intro files in filepool root folder. + promises.push(this.filepoolProvider.downloadOrPrefetchFiles(siteId, introFiles, prefetch, false, + this.component, module.id)); + + // Download content files inside dirPath. + promises.push(downloadFn(siteId, contentFiles, this.component, module.id, undefined, dirPath)); + } else { + // No dirPath, download everything in filepool root folder. + let files = introFiles.concat(contentFiles); + promises.push(downloadFn(siteId, files, this.component, module.id)); + } + + return Promise.all(promises); + }); + } + + /** + * Returns a list of content files that can be downloaded. + * + * @param {any} module The module object returned by WS. + * @return {any[]} List of files. + */ + getContentDownloadableFiles(module: any) { + let files = []; + + if (module.contents && module.contents.length) { + module.contents.forEach((content) => { + if (this.isFileDownloadable(content)) { + files.push(content); + } + }); + } + + return files; + } + + /** + * Get the download size of a module. + * + * @param {any} module Module. + * @param {Number} courseId Course ID the module belongs to. + * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @return {Promise<{size: number, total: boolean}>} Promise resolved with the size and a boolean indicating if it was able + * to calculate the total size. + */ + getDownloadSize(module: any, courseId: number, single?: boolean) : Promise<{size: number, total: boolean}> { + return this.getFiles(module, courseId).then((files) => { + return this.utils.sumFileSizes(files); + }).catch(() => { + return {size: -1, total: false}; + }); + } + + /** + * Get the downloaded size of a module. If not defined, we'll use getFiles to calculate it (it can be slow). + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {number|Promise} Size, or promise resolved with the size. + */ + getDownloadedSize?(module: any, courseId: number) : number|Promise { + const siteId = this.sitesProvider.getCurrentSiteId(); + return this.filepoolProvider.getFilesSizeByComponent(siteId, this.component, module.id); + } + + /** + * Get list of files. If not defined, we'll assume they're in module.contents. + * + * @param {any} module Module. + * @param {Number} courseId Course ID the module belongs to. + * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @return {Promise} Promise resolved with the list of files. + */ + getFiles(module: any, courseId: number, single?: boolean) : Promise { + // Load module contents if needed. + return this.loadContents(module, courseId).then(() => { + return this.getIntroFiles(module, courseId).then((files) => { + return files.concat(this.getContentDownloadableFiles(module)); + }); + }); + } + + /** + * Returns module intro files. + * + * @param {Object} module The module object returned by WS. + * @param {Number} courseId Course ID. + * @return {Promise} Promise resolved with list of intro files. + */ + getIntroFiles(module: any, courseId: number) : Promise { + return Promise.resolve(this.getIntroFilesFromInstance(module)); + } + + /** + * Returns module intro files from instance. + * + * @param {any} module The module object returned by WS. + * @param {any} [instance] The instance to get the intro files (book, assign, ...). If not defined, module will be used. + * @return {any[]} List of intro files. + */ + getIntroFilesFromInstance(module: any, instance?: any) { + if (instance) { + if (typeof instance.introfiles != 'undefined') { + return instance.introfiles; + } else if (instance.intro) { + return this.domUtils.extractDownloadableFilesFromHtmlAsFakeFileObjects(instance.intro); + } + } + + if (module.description) { + return this.domUtils.extractDownloadableFilesFromHtmlAsFakeFileObjects(module.description); + } + + return []; + } + + /** + * If there's an ongoing download for a certain identifier return it. + * + * @param {number} id Unique identifier per component. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise of the current download. + */ + getOngoingDownload(id: number, siteId?: string) : Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + if (this.isDownloading(id, siteId)) { + // There's already a download ongoing, return the promise. + return this.downloadPromises[siteId][this.getUniqueId(id)]; + } + return Promise.resolve(); + } + + /** + * Create unique identifier using component and id. + * + * @param {number} id Unique ID inside component. + * @return {string} Unique ID. + */ + getUniqueId(id: number) { + return this.component + '#' + id; + } + + /** + * Invalidate the prefetched content. + * + * @param {number} moduleId The module ID. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateContent(moduleId: number) : Promise { + const promises = [], + siteId = this.sitesProvider.getCurrentSiteId(); + + promises.push(this.courseProvider.invalidateModule(moduleId)); + promises.push(this.filepoolProvider.invalidateFilesByComponent(siteId, this.component, moduleId)); + + return Promise.all(promises); + } + + /** + * Invalidate WS calls needed to determine module status. It doesn't need to invalidate check updates. + * It should NOT invalidate files nor all the prefetched data. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved when invalidated. + */ + invalidateModule(module: any, courseId: number) : Promise { + return this.courseProvider.invalidateModule(module.id); + } + + /** + * Check if a module can be downloaded. If the function is not defined, we assume that all modules are downloadable. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {boolean|Promise} Whether the module can be downloaded. The promise should never be rejected. + */ + isDownloadable(module: any, courseId: number) : boolean|Promise { + // By default, mark all instances as downloadable. + return true; + } + + /** + * Check if a there's an ongoing download for the given identifier. + * + * @param {number} id Unique identifier per component. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Boolean} True if downloading, false otherwise. + */ + isDownloading(id: number, siteId?: string) : boolean { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + return !!(this.downloadPromises[siteId] && this.downloadPromises[siteId][this.getUniqueId(id)]); + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} A boolean, or a promise resolved with a boolean, indicating if the handler is enabled. + */ + isEnabled() : boolean|Promise { + return true; + } + + /** + * Check if a file is downloadable. + * + * @param {any} file File to check. + * @return {boolean} Whether the file is downloadable. + */ + isFileDownloadable(file: any) : boolean { + return file.type === 'file'; + } + + /** + * Load module contents into module.contents if they aren't loaded already. + * + * @param {any} module Module to load the contents. + * @param {number} [courseId] The course ID. Recommended to speed up the process and minimize data usage. + * @param {boolean} [ignoreCache] True if it should ignore cached data (it will always fail in offline or server down). + * @return {Promise} Promise resolved when loaded. + */ + loadContents(module: any, courseId: number, ignoreCache?: boolean) : Promise { + if (this.isResource) { + return this.courseProvider.loadModuleContents(module, courseId, undefined, false, ignoreCache); + } + return Promise.resolve(); + } + + /** + * Prefetch a module. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @return {Promise} Promise resolved when done. + */ + prefetch(module: any, courseId?: number, single?: boolean): Promise { + return this.downloadOrPrefetch(module, courseId, true); + } + + /** + * Prefetch the module, setting package status at start and finish. + * + * Example usage from a child instance: + * return this.prefetchPackage(module, courseId, single, this.prefetchModule.bind(this), siteId, someParam, anotherParam); + * + * Then the function "prefetchModule" will receive params: + * prefetchModule(module, courseId, single, siteId, someParam, anotherParam) + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @param {prefetchFunction} downloadFn Function to perform the prefetch. Please check the documentation of prefetchFunction. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the module has been downloaded. Data returned is not reliable. + */ + prefetchPackage(module: any, courseId: number, single: boolean, downloadFn: prefetchFunction, siteId?: string, ...args) : + Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + if (!this.appProvider.isOnline()) { + // Cannot prefetch in offline. + return Promise.reject(this.translate.instant('core.networkerrormsg')); + } + + if (this.isDownloading(module.id, siteId)) { + // There's already a download ongoing for this module, return the promise. + return this.getOngoingDownload(module.id, siteId); + } + + const prefetchPromise = this.setDownloading(module.id, siteId).then(() => { + // Package marked as downloading, call the download function. + // Send all the params except downloadFn. This includes all params passed after siteId. + return downloadFn.apply(downloadFn, [module, courseId, single, siteId].concat(args)); + }).then((extra: string) => { + // Prefetch finished, mark as downloaded. + return this.setDownloaded(module.id, siteId, extra); + }).catch((error) => { + // Error prefetching, go back to previous status and reject the promise. + return this.setPreviousStatusAndReject(module.id, error, siteId); + }); + + return this.addOngoingDownload(module.id, prefetchPromise, siteId); + } + + /** + * Mark the module as downloaded. + * + * @param {number} id Unique identifier per component. + * @param {string} [siteId] Site ID. If not defined, current site. + * @param {string} [extra] Extra data to store. + * @return {Promise} Promise resolved when done. + */ + setDownloaded(id: number, siteId?: string, extra?: string) : Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + return this.filepoolProvider.storePackageStatus(siteId, CoreConstants.DOWNLOADED, this.component, id, extra); + } + + /** + * Mark the module as downloading. + * + * @param {number} id Unique identifier per component. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + setDownloading(id: number, siteId?: string) : Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + return this.filepoolProvider.storePackageStatus(siteId, CoreConstants.DOWNLOADING, this.component, id); + } + + /** + * Set previous status and return a rejected promise. + * + * @param {number} id Unique identifier per component. + * @param {any} [error] Error to return. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Rejected promise. + */ + setPreviousStatusAndReject(id: number, error?: any, siteId?: string) : Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + return this.filepoolProvider.setPackagePreviousStatus(siteId, this.component, id).then(() => { + return Promise.reject(error); + }); + } + + /** + * Remove module downloaded files. If not defined, we'll use getFiles to remove them (slow). + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved when done. + */ + removeFiles(module: any, courseId: number) : Promise { + return this.filepoolProvider.removeFilesByComponent(this.sitesProvider.getCurrentSiteId(), this.component, module.id); + } +} diff --git a/src/core/course/course.module.ts b/src/core/course/course.module.ts index f94c17fa5..7faec0c97 100644 --- a/src/core/course/course.module.ts +++ b/src/core/course/course.module.ts @@ -17,6 +17,7 @@ import { CoreCourseProvider } from './providers/course'; import { CoreCourseHelperProvider } from './providers/helper'; import { CoreCourseFormatDelegate } from './providers/format-delegate'; import { CoreCourseModuleDelegate } from './providers/module-delegate'; +import { CoreCourseModulePrefetchDelegate } from './providers/module-prefetch-delegate'; import { CoreCourseFormatDefaultHandler } from './providers/default-format'; import { CoreCourseFormatTopicsModule} from './formats/topics/topics.module'; import { CoreCourseFormatWeeksModule } from './formats/weeks/weeks.module'; @@ -32,6 +33,7 @@ import { CoreCourseFormatWeeksModule } from './formats/weeks/weeks.module'; CoreCourseHelperProvider, CoreCourseFormatDelegate, CoreCourseModuleDelegate, + CoreCourseModulePrefetchDelegate, CoreCourseFormatDefaultHandler ], exports: [] diff --git a/src/core/course/providers/course.ts b/src/core/course/providers/course.ts index 4c4300cc9..177a84dc0 100644 --- a/src/core/course/providers/course.ts +++ b/src/core/course/providers/course.ts @@ -62,7 +62,7 @@ export class CoreCourseProvider { type: 'INTEGER' } ] - } + }; protected logger; protected CORE_MODULES = [ @@ -482,6 +482,26 @@ export class CoreCourseProvider { return this.ROOT_CACHE_KEY + 'sections:' + courseId; } + /** + * Given a list of sections, returns the list of modules in the sections. + * + * @param {any[]} sections Sections. + * @return {any[]} Modules. + */ + getSectionsModules(sections: any[]) : any[] { + if (!sections || !sections.length) { + return []; + } + + let modules = []; + sections.forEach((section) => { + if (section.modules) { + modules = modules.concat(section.modules); + } + }); + return modules; + } + /** * Invalidates module WS call. * diff --git a/src/core/course/providers/helper.ts b/src/core/course/providers/helper.ts index 0c30c00f4..537785155 100644 --- a/src/core/course/providers/helper.ts +++ b/src/core/course/providers/helper.ts @@ -82,26 +82,6 @@ export class CoreCourseHelperProvider { }); } - /** - * Given a list of sections, returns the list of modules in the sections. - * - * @param {any[]} sections Sections. - * @return {any[]} Modules. - */ - getSectionsModules(sections: any[]) : any[] { - if (!sections || !sections.length) { - return []; - } - - let modules = []; - sections.forEach((section) => { - if (section.modules) { - modules = modules.concat(section.modules); - } - }); - return modules; - } - /** * Check if a section has content. * diff --git a/src/core/course/providers/module-prefetch-delegate.ts b/src/core/course/providers/module-prefetch-delegate.ts new file mode 100644 index 000000000..ab8f49c4c --- /dev/null +++ b/src/core/course/providers/module-prefetch-delegate.ts @@ -0,0 +1,1240 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { NavController } from 'ionic-angular'; +import { CoreEventsProvider } from '../../../providers/events'; +import { CoreFileProvider } from '../../../providers/file'; +import { CoreFilepoolProvider } from '../../../providers/filepool'; +import { CoreLoggerProvider } from '../../../providers/logger'; +import { CoreSitesProvider } from '../../../providers/sites'; +import { CoreTimeUtilsProvider } from '../../../providers/utils/time'; +import { CoreUtilsProvider } from '../../../providers/utils/utils'; +import { CoreCourseProvider } from './course'; +import { CoreCache } from '../../../classes/cache'; +import { CoreSiteWSPreSets } from '../../../classes/site'; +import { CoreConstants } from '../../constants'; +import { Md5 } from 'ts-md5/dist/md5'; + +/** + * Interface that all course prefetch handlers must implement. + */ +export interface CoreCourseModulePrefetchHandler { + /** + * A name to identify the addon. + * @type {string} + */ + name: string; + + /** + * Name of the module. It should match the "modname" of the module returned in core_course_get_contents. + * @type {string} + */ + modname: string; + + /** + * The handler's component. + * @type {string} + */ + component: string; + + /** + * The RegExp to check updates. If a module has an update whose name matches this RegExp, the module will be marked + * as outdated. This RegExp is ignored if hasUpdates function is defined. + * @type {RegExp} + */ + updatesNames?: RegExp; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} A boolean, or a promise resolved with a boolean, indicating if the handler is enabled. + */ + isEnabled() : boolean|Promise; + + /** + * Get the download size of a module. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @return {Promise<{size: number, total: boolean}>} Promise resolved with the size and a boolean indicating if it was able + * to calculate the total size. + */ + getDownloadSize(module: any, courseId: number, single?: boolean) : Promise<{size: number, total: boolean}>; + + /** + * Prefetch a module. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @return {Promise} Promise resolved when done. + */ + prefetch(module: any, courseId?: number, single?: boolean): Promise; + + /** + * Check if a certain module can use core_course_check_updates to check if it has updates. + * If not defined, it will assume all modules can be checked. + * The modules that return false will always be shown as outdated when they're downloaded. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {boolean|Promise} Whether the module can use check_updates. The promise should never be rejected. + */ + canUseCheckUpdates?(module: any, courseId: number) : boolean|Promise; + + /** + * Return the status to show based on current status. E.g. a module might want to show outdated instead of downloaded. + * If not implemented, the original status will be returned. + * + * @param {any} module Module. + * @param {string} status The current status. + * @param {boolean} canCheck Whether the site allows checking for updates. + * @return {string} Status to display. + */ + determineStatus?(module: any, status: string, canCheck: boolean) : string; + + /** + * Get the downloaded size of a module. If not defined, we'll use getFiles to calculate it (it can be slow). + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {number|Promise} Size, or promise resolved with the size. + */ + getDownloadedSize?(module: any, courseId: number) : number|Promise; + + /** + * Get the list of files of the module. If not defined, we'll assume they are in module.contents. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {any[]|Promise} List of files, or promise resolved with the files. + */ + getFiles?(module: any, courseId: number) : any[]|Promise; + + /** + * Check if a certain module has updates based on the result of check updates. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @param {any[]} moduleUpdates List of updates for the module. + * @return {boolean|Promise} Whether the module has updates. The promise should never be rejected. + */ + hasUpdates?(module: any, courseId: number, moduleUpdates: any[]) : boolean|Promise; + + /** + * Invalidate WS calls needed to determine module status. It doesn't need to invalidate check updates. + * It should NOT invalidate files nor all the prefetched data. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved when invalidated. + */ + invalidateModule?(module: any, courseId: number) : Promise; + + /** + * Check if a module can be downloaded. If the function is not defined, we assume that all modules are downloadable. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {boolean|Promise} Whether the module can be downloaded. The promise should never be rejected. + */ + isDownloadable?(module: any, courseId: number) : boolean|Promise; + + /** + * Load module contents in module.contents if they aren't loaded already. This is meant for resources. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved when done. + */ + loadContents?(module: any, courseId: number) : Promise; + + /** + * Remove module downloaded files. If not defined, we'll use getFiles to remove them (slow). + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved when done. + */ + removeFiles?(module: any, courseId: number) : Promise; +}; + +/** + * Delegate to register module prefetch handlers. + */ +@Injectable() +export class CoreCourseModulePrefetchDelegate { + // Variables for database. + protected CHECK_UPDATES_TIMES_TABLE = 'check_updates_times'; + protected checkUpdatesTableSchema = { + name: this.CHECK_UPDATES_TIMES_TABLE, + columns: [ + { + name: 'courseId', + type: 'INTEGER', + primaryKey: true + }, + { + name: 'time', + type: 'INTEGER', + notNull: true + } + ] + } + + protected ROOT_CACHE_KEY = 'mmCourse:'; + + protected logger; + protected handlers: {[s: string]: CoreCourseModulePrefetchHandler} = {}; // All registered handlers. + protected enabledHandlers: {[s: string]: CoreCourseModulePrefetchHandler} = {}; // Handlers enabled for the current site. + protected statusCache = new CoreCache(); + protected lastUpdateHandlersStart: number; + + // Promises for check updates and for prefetch. + protected courseUpdatesPromises: {[s: string]: {[s: string]: Promise}} = {}; + protected prefetchPromises: {[s: string]: {[s: string]: Promise}} = {}; + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private eventsProvider: CoreEventsProvider, + private courseProvider: CoreCourseProvider, private filepoolProvider: CoreFilepoolProvider, + private timeUtils: CoreTimeUtilsProvider, private utils: CoreUtilsProvider, private fileProvider: CoreFileProvider) { + this.logger = logger.getInstance('CoreCourseModulePrefetchDelegate'); + + this.sitesProvider.createTableFromSchema(this.checkUpdatesTableSchema); + } + + /** + * Check if current site can check updates using core_course_check_updates. + * + * @return {boolean} True if can check updates, false otherwise. + */ + canCheckUpdates() : boolean { + return this.sitesProvider.getCurrentSite().wsAvailable('core_course_check_updates'); + } + + /** + * Check if a certain module can use core_course_check_updates. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved with boolean: whether the module can use check updates WS. + */ + canModuleUseCheckUpdates(module: any, courseId: number) : Promise { + const handler = this.getPrefetchHandlerFor(module); + + if (!handler) { + // Module not supported, cannot use check updates. + return Promise.resolve(false); + } + + if (handler.canUseCheckUpdates) { + return Promise.resolve(handler.canUseCheckUpdates(module, courseId)); + } + + // By default, modules can use check updates. + return Promise.resolve(true); + } + + /** + * Clear the status cache. + */ + clearStatusCache() : void { + this.statusCache.clear(); + } + + /** + * Creates the list of modules to check for get course updates. + * + * @param {any[]} modules List of modules. + * @param {number} courseId Course ID the modules belong to. + * @return {Promise<{toCheck: any[], cannotUse: any[]}>} Promise resolved with the lists. + */ + protected createToCheckList(modules: any[], courseId: number) : Promise<{toCheck: any[], cannotUse: any[]}> { + let result = { + toCheck: [], + cannotUse: [] + }, + promises = []; + + modules.forEach((module) => { + promises.push(this.getModuleStatusAndDownloadTime(module, courseId).then((data) => { + if (data.status == CoreConstants.DOWNLOADED) { + // Module is downloaded and not outdated. Check if it can check updates. + return this.canModuleUseCheckUpdates(module, courseId).then((canUse) => { + if (canUse) { + // Can use check updates, add it to the tocheck list. + result.toCheck.push({ + contextlevel: 'module', + id: module.id, + since: data.downloadTime || 0 + }); + } else { + // Cannot use check updates, add it to the cannotUse array. + result.cannotUse.push(module); + } + }); + } + }).catch(() => { + // Ignore errors. + })); + }); + + return Promise.all(promises).then(() => { + // Sort toCheck list. + result.toCheck.sort((a, b) => { + return a.id >= b.id ? 1 : -1; + }); + + return result; + }); + } + + /** + * Determines a module status based on current status, restoring downloads if needed. + * + * @param {any} module Module. + * @param {string} status Current status. + * @param {boolean} [restoreDownloads] True if it should restore downloads if needed. + * @param {boolean} [canCheck] True if updates can be checked using core_course_check_updates. + * @return {string} Module status. + */ + determineModuleStatus(module: any, status: string, restoreDownloads?: boolean, canCheck?: boolean) : string { + const handler = this.getPrefetchHandlerFor(module), + siteId = this.sitesProvider.getCurrentSiteId(); + + if (handler) { + if (status == CoreConstants.DOWNLOADING && restoreDownloads) { + // Check if the download is being handled. + if (!this.filepoolProvider.getPackageDownloadPromise(siteId, handler.component, module.id)) { + // Not handled, the app was probably restarted or something weird happened. + // Re-start download (files already on queue or already downloaded will be skipped). + handler.prefetch(module); + } + } else if (handler.determineStatus) { + // The handler implements a determineStatus function. Apply it. + return handler.determineStatus(module, status, canCheck); + } + } + return status; + } + + /** + * Check for updates in a course. + * + * @param {any[]} modules List of modules. + * @param {number} courseId Course ID the modules belong to. + * @return {Promise} Promise resolved with the updates. If a module is set to false, it means updates cannot be + * checked for that module in the current site. + */ + getCourseUpdates(modules: any[], courseId: number) : Promise { + if (!this.canCheckUpdates()) { + return Promise.reject(null); + } + + // Check if there's already a getCourseUpdates in progress. + let id = Md5.hashAsciiStr(courseId + '#' + JSON.stringify(modules)), + siteId = this.sitesProvider.getCurrentSiteId(); + + if (this.courseUpdatesPromises[siteId] && this.courseUpdatesPromises[siteId][id]) { + // There's already a get updates ongoing, return the promise. + return this.courseUpdatesPromises[siteId][id]; + } else if (!this.courseUpdatesPromises[siteId]) { + this.courseUpdatesPromises[siteId] = {}; + } + + this.courseUpdatesPromises[siteId][id] = this.createToCheckList(modules, courseId).then((data) => { + let result = {}; + + // Mark as false the modules that cannot use check updates WS. + data.cannotUse.forEach((module) => { + result[module.id] = false; + }); + + if (!data.toCheck.length) { + // Nothing to check, no need to call the WS. + return result; + } + + // Get the site, maybe the user changed site. + return this.sitesProvider.getSite(siteId).then((site) => { + let params = { + courseid: courseId, + tocheck: data.toCheck + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getCourseUpdatesCacheKey(courseId), + emergencyCache: false, // If downloaded data has changed and offline, just fail. See MOBILE-2085. + uniqueCacheKey: true + }; + + return site.read('core_course_check_updates', params, preSets).then((response) => { + if (!response || typeof response.instances == 'undefined') { + return Promise.reject(null); + } + + // Store the last execution of the check updates call. + let entry = { + courseId: courseId, + time: this.timeUtils.timestamp() + }; + site.getDb().insertOrUpdateRecord(this.CHECK_UPDATES_TIMES_TABLE, entry, {courseId: courseId}); + + return this.treatCheckUpdatesResult(data.toCheck, response, result); + }).catch((error) => { + // Cannot get updates. Get the cached entries but discard the modules with a download time higher + // than the last execution of check updates. + return site.getDb().getRecord(this.CHECK_UPDATES_TIMES_TABLE, {courseId: courseId}).then((entry) => { + preSets.getCacheUsingCacheKey = true; + preSets.omitExpires = true; + + return site.read('core_course_check_updates', params, preSets).then((response) => { + if (!response || typeof response.instances == 'undefined') { + return Promise.reject(error); + } + + return this.treatCheckUpdatesResult(data.toCheck, response, result, entry.time); + }); + }, () => { + // No previous executions, return result as it is. + return result; + }); + }); + }); + }).finally(() => { + // Get updates finished, delete the promise. + delete this.courseUpdatesPromises[siteId][id]; + }); + + return this.courseUpdatesPromises[siteId][id]; + } + + + /** + * Check for updates in a course. + * + * @param {number} courseId Course ID the modules belong to. + * @return {Promise} Promise resolved with the updates. + */ + getCourseUpdatesByCourseId(courseId: number) : Promise { + if (!this.canCheckUpdates()) { + return Promise.reject(null); + } + + // Get course sections and all their modules. + return this.courseProvider.getSections(courseId, false, true, {omitExpires: true}).then((sections) => { + return this.getCourseUpdates(this.courseProvider.getSectionsModules(sections), courseId); + }); + } + + /** + * Get cache key for course updates WS calls. + * + * @param {number} courseId Course ID. + * @return {string} Cache key. + */ + protected getCourseUpdatesCacheKey(courseId: number) : string { + return this.ROOT_CACHE_KEY + 'courseUpdates:' + courseId; + } + + /** + * Get modules download size. Only treat the modules with status not downloaded or outdated. + * + * @param {any[]} modules List of modules. + * @param {number} courseId Course ID the modules belong to. + * @return {Promise<{size: number, total: boolean}>} Promise resolved with the size and a boolean indicating if it was able + * to calculate the total size. + */ + getDownloadSize(modules: any[], courseId: number) : Promise<{size: number, total: boolean}> { + // Get the status of each module. + return this.getModulesStatus(modules, courseId).then((data) => { + const downloadableModules = data[CoreConstants.NOT_DOWNLOADED].concat(data[CoreConstants.OUTDATED]), + promises = [], + result = { + size: 0, + total: true + }; + + downloadableModules.forEach((module) => { + promises.push(this.getModuleDownloadSize(module, courseId).then((size) => { + result.total = result.total && size.total; + result.size += size.size; + })); + }); + + return Promise.all(promises).then(() => { + return result; + }); + }); + } + + /** + * Get the download size of a module. + * + * @param {any} module Module to get size. + * @param {Number} courseId Course ID the module belongs to. + * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @return {Promise<{size: number, total: boolean}>} Promise resolved with the size and a boolean indicating if it was able + * to calculate the total size. + */ + getModuleDownloadSize(module: any, courseId: number, single?: boolean) : Promise<{size: number, total: boolean}> { + let downloadSize, + packageId, + handler = this.getPrefetchHandlerFor(module); + + // Check if the module has a prefetch handler. + if (handler) { + return this.isModuleDownloadable(module, courseId).then((downloadable) => { + if (!downloadable) { + return {size: 0, total: true}; + } + + packageId = this.filepoolProvider.getPackageId(handler.component, module.id); + downloadSize = this.statusCache.getValue(packageId, 'downloadSize'); + if (typeof downloadSize != 'undefined') { + return downloadSize; + } + + return Promise.resolve(handler.getDownloadSize(module, courseId, single)).then((size) => { + return this.statusCache.setValue(packageId, 'downloadSize', size); + }).catch((error) => { + const cachedSize = this.statusCache.getValue(packageId, 'downloadSize', true); + if (cachedSize) { + return cachedSize; + } + return Promise.reject(error); + }); + }); + } + + return Promise.resolve({size: 0, total: false}); + } + + /** + * Get the download size of a module. + * + * @param {any} module Module to get size. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved with the size. + */ + getModuleDownloadedSize(module: any, courseId: number) : Promise { + let downloadedSize, + packageId, + promise, + handler = this.getPrefetchHandlerFor(module); + + // Check if the module has a prefetch handler. + if (handler) { + return this.isModuleDownloadable(module, courseId).then((downloadable) => { + if (!downloadable) { + return 0; + } + + packageId = this.filepoolProvider.getPackageId(handler.component, module.id); + downloadedSize = this.statusCache.getValue(packageId, 'downloadedSize'); + if (typeof downloadedSize != 'undefined') { + return downloadedSize; + } + + if (handler.getDownloadedSize) { + // Handler implements a method to calculate the downloaded size, use it. + promise = Promise.resolve(handler.getDownloadedSize(module, courseId)); + } else { + // Handler doesn't implement it, get the module files and check if they're downloaded. + promise = this.getModuleFiles(module, courseId).then((files) => { + let siteId = this.sitesProvider.getCurrentSiteId(), + promises = [], + size = 0; + + // Retrieve file size if it's downloaded. + files.forEach((file) => { + const fileUrl = file.url || file.fileurl; + promises.push(this.filepoolProvider.getFilePathByUrl(siteId, fileUrl).then((path) => { + return this.fileProvider.getFileSize(path).catch(() => { + // Error getting size. Check if the file is being downloaded. + return this.filepoolProvider.isFileDownloadingByUrl(siteId, fileUrl).then(() => { + // If downloading, count as downloaded. + return file.filesize; + }).catch(() => { + // Not downloading and not found in disk. + return 0; + }); + }).then((fs) => { + size += fs; + }); + })); + }); + + return Promise.all(promises).then(() => { + return size; + }); + }); + } + + return promise.then((size) => { + return this.statusCache.setValue(packageId, 'downloadedSize', size); + }).catch(() => { + return this.statusCache.getValue(packageId, 'downloadedSize', true); + }); + }); + } + + return Promise.resolve(0); + } + + /** + * Get module files. + * + * @param {any} module Module to get the files. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved with the list of files. + */ + getModuleFiles(module: any, courseId: number) : Promise { + const handler = this.getPrefetchHandlerFor(module); + + if (handler.getFiles) { + // The handler defines a function to get files, use it. + return Promise.resolve(handler.getFiles(module, courseId)); + } else if (handler.loadContents) { + // The handler defines a function to load contents, use it before returning module contents. + return handler.loadContents(module, courseId).then(() => { + return module.contents; + }); + } else { + return Promise.resolve(module.contents || []); + } + } + + /** + * Get the module status. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @param {any} [updates] Result of getCourseUpdates for all modules in the course. If not provided, it will be + * calculated (slower). If it's false it means the site doesn't support check updates. + * @param {boolean} [refresh] True if it should ignore the cache. + * @param {Boolean} [restoreDownloads] True if it should restore downloads. It's only used if refresh=false, + * if refresh=true then it always tries to restore downloads. + * @param {number} [sectionId] ID of the section the module belongs to. + * @return {Promise} Promise resolved with the status. + */ + getModuleStatus(module: any, courseId: number, updates?: any, refresh?: boolean, restoreDownloads?: boolean, sectionId?: number) + : Promise { + let handler = this.getPrefetchHandlerFor(module), + siteId = this.sitesProvider.getCurrentSiteId(), + canCheck = this.canCheckUpdates(); + + if (handler) { + // Check if the status is cached. + let component = handler.component, + packageId = this.filepoolProvider.getPackageId(component, module.id), + status = this.statusCache.getValue(packageId, 'status'), + updateStatus = true, + promise; + + if (!refresh && typeof status != 'undefined') { + return Promise.resolve(this.determineModuleStatus(module, status, restoreDownloads, canCheck)); + } + + // Check if the module is downloadable. + return this.isModuleDownloadable(module, courseId).then((downloadable) => { + if (!downloadable) { + return CoreConstants.NOT_DOWNLOADABLE; + } + + // Get the saved package status. + return this.filepoolProvider.getPackageStatus(siteId, component, module.id).then((currentStatus) => { + status = handler.determineStatus ? handler.determineStatus(module, status, canCheck) : status; + if (status != CoreConstants.DOWNLOADED) { + return status; + } + + // Module is downloaded. Determine if there are updated in the module to show them outdated. + if (typeof updates == 'undefined') { + // We don't have course updates, calculate them. + promise = this.getCourseUpdatesByCourseId(courseId); + } else if (updates === false) { + // Cannot check updates. + return Promise.resolve(); + } else { + promise = Promise.resolve(updates); + } + + return promise.then((updates) => { + if (!updates || updates[module.id] === false) { + // Cannot check updates, always show outdated. + return CoreConstants.OUTDATED; + } + + // Check if the module has any update. + return this.moduleHasUpdates(module, courseId, updates).then((hasUpdates) => { + if (!hasUpdates) { + // No updates, keep current status. + return status; + } + + // Has updates, mark the module as outdated. + status = CoreConstants.OUTDATED; + return this.filepoolProvider.storePackageStatus(siteId, component, module.id, status).catch(() => { + // Ignore errors. + }).then(() => { + return status; + }); + }).catch(() => { + // Error checking if module has updates. + const status = this.statusCache.getValue(packageId, 'status', true); + return this.determineModuleStatus(module, status, restoreDownloads, canCheck); + }); + }, () => { + // Error getting updates, show the stored status. + updateStatus = false; + return currentStatus; + }); + }); + }).then((status) => { + if (updateStatus) { + this.updateStatusCache(component, module.id, status, sectionId); + } + return this.determineModuleStatus(module, status, restoreDownloads, canCheck); + }); + } + + // No handler found, module not downloadable. + return Promise.resolve(CoreConstants.NOT_DOWNLOADABLE); + } + + /** + * Get the status of a list of modules, along with the lists of modules for each status. + * @see {@link CoreFilepoolProvider.determinePackagesStatus} + * + * @param {any[]} modules List of modules to prefetch. + * @param {number} courseId Course ID the modules belong to. + * @param {number} [sectionId] ID of the section the modules belong to. + * @param {boolean} [refresh] True if it should always check the DB (slower). + * @param {Boolean} [restoreDownloads] True if it should restore downloads. It's only used if refresh=false, + * if refresh=true then it always tries to restore downloads. + * @return {Promise} Promise resolved with an object with the following properties: + * - status (string) Status of the module. + * - total (number) Number of modules. + * - CoreConstants.NOT_DOWNLOADED (any[]) Modules with state NOT_DOWNLOADED. + * - CoreConstants.DOWNLOADED (any[]) Modules with state DOWNLOADED. + * - CoreConstants.DOWNLOADING (any[]) Modules with state DOWNLOADING. + * - CoreConstants.OUTDATED (any[]) Modules with state OUTDATED. + */ + getModulesStatus(modules: any[], courseId: number, sectionId?: number, refresh?: boolean, restoreDownloads?: boolean) : any { + let promises = [], + status = CoreConstants.NOT_DOWNLOADABLE, + result: any = { + total: 0 + }; + + // Init result. + result[CoreConstants.NOT_DOWNLOADED] = []; + result[CoreConstants.DOWNLOADED] = []; + result[CoreConstants.DOWNLOADING] = []; + result[CoreConstants.OUTDATED] = []; + + // Check updates in course. Don't use getCourseUpdates because the list of modules might not be the whole course list. + return this.getCourseUpdatesByCourseId(courseId).catch(() => { + // Cannot get updates. + return false; + }).then((updates) => { + + modules.forEach((module) => { + // Check if the module has a prefetch handler. + let handler = this.getPrefetchHandlerFor(module); + if (handler) { + let packageId = this.filepoolProvider.getPackageId(handler.component, module.id); + + promises.push(this.getModuleStatus(module, courseId, updates, refresh, restoreDownloads).then((modStatus) => { + if (modStatus != CoreConstants.NOT_DOWNLOADABLE) { + if (sectionId && sectionId > 0) { + // Store the section ID. + this.statusCache.setValue(packageId, 'sectionId', sectionId); + } + + status = this.filepoolProvider.determinePackagesStatus(status, modStatus); + result[modStatus].push(module); + result.total++; + } + }).catch((error) => { + let cacheStatus = this.statusCache.getValue(packageId, 'status', true); + if (typeof cacheStatus == 'undefined') { + return Promise.reject(error); + } + + if (cacheStatus != CoreConstants.NOT_DOWNLOADABLE) { + cacheStatus = this.filepoolProvider.determinePackagesStatus(status, cacheStatus); + result[cacheStatus].push(module); + result.total++; + } + })); + } + }); + + return Promise.all(promises).then(() => { + result.status = status; + return result; + }); + }); + } + + /** + * Get a module status and download time. It will only return the download time if the module is downloaded and not outdated. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise<{status: string, downloadTime?: number}>} Promise resolved with the data. + */ + protected getModuleStatusAndDownloadTime(module: any, courseId: number) : Promise<{status: string, downloadTime?: number}> { + let handler = this.getPrefetchHandlerFor(module), + siteId = this.sitesProvider.getCurrentSiteId(); + + if (handler) { + // Get the status from the cache. + let packageId = this.filepoolProvider.getPackageId(handler.component, module.id), + status = this.statusCache.getValue(packageId, 'status'); + + if (typeof status != 'undefined' && status != CoreConstants.DOWNLOADED) { + // Status is different than downloaded, just return the status. + return Promise.resolve({ + status: status + }); + } + + // Check if the module is downloadable. + return this.isModuleDownloadable(module, courseId).then((downloadable: boolean) : any => { + if (!downloadable) { + return { + status: CoreConstants.NOT_DOWNLOADABLE + }; + } + + // Get the stored data to get the status and downloadTime. + return this.filepoolProvider.getPackageData(siteId, handler.component, module.id).then((data) => { + return { + status: data.status, + downloadTime: data.downloadTime || 0 + }; + }); + }); + } + + // No handler found, module not downloadable. + return Promise.resolve({ + status: CoreConstants.NOT_DOWNLOADABLE + }); + } + + /** + * Get a prefetch handler. + * + * @param {any} module The module to work on. + * @return {CoreCourseModulePrefetchHandler} Prefetch handler. + */ + getPrefetchHandlerFor(module: any) : CoreCourseModulePrefetchHandler { + return this.enabledHandlers[module.modname]; + } + + /** + * Invalidate check updates WS call. + * + * @param {number} courseId Course ID. + * @return {Promise} Promise resolved when data is invalidated. + */ + invalidateCourseUpdates(courseId: number) : Promise { + return this.sitesProvider.getCurrentSite().invalidateWsCacheForKey(this.getCourseUpdatesCacheKey(courseId)); + } + + /** + * Invalidate a list of modules in a course. This should only invalidate WS calls, not downloaded files. + * + * @param {any[]} modules List of modules. + * @param {number} courseId Course ID. + * @return {Promise} Promise resolved when modules are invalidated. + */ + invalidateModules(modules: any[], courseId: number) : Promise { + let promises = []; + + modules.forEach((module) => { + const handler = this.getPrefetchHandlerFor(module); + if (handler) { + if (handler.invalidateModule) { + promises.push(handler.invalidateModule(module, courseId).catch(() => { + // Ignore errors. + })); + } + + // Invalidate cache. + this.invalidateModuleStatusCache(module); + } + }); + + promises.push(this.invalidateCourseUpdates(courseId)); + + return Promise.all(promises); + } + + /** + * Invalidates the cache for a given module. + * + * @param {any} module Module to be invalidated. + */ + invalidateModuleStatusCache(module: any) : void { + const handler = this.getPrefetchHandlerFor(module); + if (handler) { + this.statusCache.invalidate(this.filepoolProvider.getPackageId(handler.component, module.id)); + } + } + + /** + * Check if a list of modules is being downloaded. + * + * @param {string} id An ID to identify the download. + * @return {boolean} True if it's being downloaded, false otherwise. + */ + isBeingDownloaded(id: string) : boolean { + const siteId = this.sitesProvider.getCurrentSiteId(); + return !!(this.prefetchPromises[siteId] && this.prefetchPromises[siteId][id]); + } + + /** + * Check if a time belongs to the last update handlers call. + * This is to handle the cases where updateHandlers don't finish in the same order as they're called. + * + * @param {number} time Time to check. + * @return {boolean} Whether it's the last call. + */ + isLastUpdateCall(time: number) : boolean { + if (!this.lastUpdateHandlersStart) { + return true; + } + return time == this.lastUpdateHandlersStart; + } + + /** + * Check if a module is downloadable. + * + * @param {any} module Module. + * @param {Number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved with true if downloadable, false otherwise. + */ + isModuleDownloadable(module: any, courseId: number) : Promise { + let handler = this.getPrefetchHandlerFor(module), + promise; + + if (handler) { + if (typeof handler.isDownloadable == 'function') { + let packageId = this.filepoolProvider.getPackageId(handler.component, module.id), + downloadable = this.statusCache.getValue(packageId, 'downloadable'); + + if (typeof downloadable != 'undefined') { + return Promise.resolve(downloadable); + } else { + return Promise.resolve(handler.isDownloadable(module, courseId)).then((downloadable) => { + return this.statusCache.setValue(packageId, 'downloadable', downloadable); + }).catch(() => { + // Something went wrong, assume it's not downloadable. + return false; + }); + } + } else { + // Function not defined, assume it's not downloadable. + return Promise.resolve(true); + } + } else { + // No handler for module, so it's not downloadable. + return Promise.resolve(false); + } + } + + /** + * Check if a module has updates based on the result of getCourseUpdates. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @param {any} updates Result of getCourseUpdates. + * @return {Promise} Promise resolved with boolean: whether the module has updates. + */ + moduleHasUpdates(module: any, courseId: number, updates: any) : Promise { + let handler = this.getPrefetchHandlerFor(module), + moduleUpdates = updates[module.id]; + + if (handler && handler.hasUpdates) { + // Handler implements its own function to check the updates, use it. + return Promise.resolve(handler.hasUpdates(module, courseId, moduleUpdates)); + } else if (!moduleUpdates || !moduleUpdates.updates || !moduleUpdates.updates.length) { + // Module doesn't have any update. + return Promise.resolve(false); + } else if (handler && handler.updatesNames && handler.updatesNames.test) { + // Check the update names defined by the handler. + for (let i = 0, len = moduleUpdates.updates.length; i < len; i++) { + if (handler.updatesNames.test(moduleUpdates.updates[i].name)) { + return Promise.resolve(true); + } + } + + return Promise.resolve(false); + } + + // Handler doesn't define hasUpdates or updatesNames and there is at least 1 update. Assume it has updates. + return Promise.resolve(true); + } + + /** + * Prefetch a module. + * + * @param {any} module Module to prefetch. + * @param {number} courseId Course ID the module belongs to. + * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @return {Promise} Promise resolved when finished. + */ + prefetchModule(module: any, courseId: number, single?: boolean) : Promise { + const handler = this.getPrefetchHandlerFor(module); + + // Check if the module has a prefetch handler. + if (handler) { + return handler.prefetch(module, courseId, single); + } + return Promise.resolve(); + } + + /** + * Prefetches a list of modules using their prefetch handlers. + * If a prefetch already exists for this site and id, returns the current promise. + * + * @param {string} id An ID to identify the download. It can be used to retrieve the download promise. + * @param {any[]} modules List of modules to prefetch. + * @param {number} courseId Course ID the modules belong to. + * @param {Function} [onProgress] Function to call everytime a module is downloaded. + * @return {Promise} Promise resolved when all modules have been prefetched. + */ + prefetchModules(id: string, modules: any[], courseId: number, onProgress?: (moduleId: number) => any) : Promise { + + const siteId = this.sitesProvider.getCurrentSiteId(); + + if (this.prefetchPromises[siteId] && this.prefetchPromises[siteId][id]) { + // There's a prefetch ongoing, return the current promise. + return this.prefetchPromises[siteId][id]; + } + + let promises = []; + + modules.forEach((module) => { + // Check if the module has a prefetch handler. + const handler = this.getPrefetchHandlerFor(module); + if (handler) { + promises.push(this.isModuleDownloadable(module, courseId).then((downloadable) => { + if (!downloadable) { + return; + } + + return handler.prefetch(module, courseId).then(() => { + if (onProgress) { + onProgress(module.id); + } + }); + })); + } + }); + + if (!this.prefetchPromises[siteId]) { + this.prefetchPromises[siteId] = {}; + } + + this.prefetchPromises[siteId][id] = Promise.all(promises).finally(() => { + delete this.prefetchPromises[siteId][id]; + }); + + return this.prefetchPromises[siteId][id]; + } + + /** + * Register a handler. + * + * @param {CoreCourseModulePrefetchHandler} handler The handler to register. + * @return {boolean} True if registered successfully, false otherwise. + */ + registerHandler(handler: CoreCourseModulePrefetchHandler) : boolean { + if (typeof this.handlers[handler.modname] !== 'undefined') { + this.logger.log('There is an addon named \'' + this.handlers[handler.modname].name + + '\' already registered as a prefetch handler for ' + handler.modname); + return false; + } + this.logger.log(`Registered addon '${handler.name}' as a prefetch handler for '${handler.modname}'`); + this.handlers[handler.modname] = handler; + return true; + } + + /** + * Remove module Files from handler. + * + * @param {any} module Module to remove the files. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved when done. + */ + removeModuleFiles(module: any, courseId: number) : Promise { + let handler = this.getPrefetchHandlerFor(module), + siteId = this.sitesProvider.getCurrentSiteId(), + promise; + + if (handler && handler.removeFiles) { + // Handler implements a method to remove the files, use it. + promise = handler.removeFiles(module, courseId); + } else { + // No method to remove files, use get files to try to remove the files. + promise = this.getModuleFiles(module, courseId).then((files) => { + let promises = []; + files.forEach((file) => { + promises.push(this.filepoolProvider.removeFileByUrl(siteId, file.url || file.fileurl).catch(() => { + // Ignore errors. + })); + }); + return Promise.all(promises); + }); + } + + return promise.then(() => { + if (handler) { + // Update status of the module. + const packageId = this.filepoolProvider.getPackageId(handler.component, module.id); + this.statusCache.setValue(packageId, 'downloadedSize', 0); + this.filepoolProvider.storePackageStatus(siteId, handler.component, module.id, CoreConstants.NOT_DOWNLOADED); + } + }); + } + + /** + * Treat the result of the check updates WS call. + * + * @param {any[]} toCheckList List of modules to check (from createToCheckList). + * @param {any} response WS call response. + * @param {any} result Object where to store the result. + * @param {number} [previousTime] Time of the previous check updates execution. If set, modules downloaded + * after this time will be ignored. + * @return {any} Result. + */ + protected treatCheckUpdatesResult(toCheckList: any[], response: any, result: any, previousTime?: number) : any { + // Format the response to index it by module ID. + this.utils.arrayToObject(response.instances, 'id', result); + + // Treat warnings, adding the not supported modules. + response.warnings.forEach((warning) => { + if (warning.warningcode == 'missingcallback') { + result[warning.itemid] = false; + } + }); + + if (previousTime) { + // Remove from the list the modules downloaded after previousTime. + toCheckList.forEach((entry) => { + if (result[entry.id] && entry.since > previousTime) { + delete result[entry.id]; + } + }); + } + + return result; + } + + /** + * Update the enabled handlers for the current site. + * + * @param {string} handles The module this handler handles, e.g. forum, label. + * @param {Object} handlerInfo The handler details. + * @param {Number} time Time this update process started. + * @return {Promise} Resolved when enabled, rejected when not. + */ + updateHandler(handler: CoreCourseModulePrefetchHandler, time: number) : Promise { + let promise, + siteId = this.sitesProvider.getCurrentSiteId(); + + if (!siteId) { + promise = Promise.reject(null); + } else { + promise = Promise.resolve(handler.isEnabled()); + } + + // Checks if the prefetch is enabled. + return promise.catch(() => { + return false; + }).then((enabled: boolean) => { + // Verify that this call is the last one that was started. + // Check that site hasn't changed since the check started. + if (this.isLastUpdateCall(time) && this.sitesProvider.getCurrentSiteId() == siteId) { + if (enabled) { + this.enabledHandlers[handler.modname] = handler; + } else { + delete this.enabledHandlers[handler.modname]; + } + } + }); + } + + /** + * Update the handlers for the current site. + * + * @return {Promise} Resolved when done. + */ + updateHandlers() : Promise { + const promises = [], + now = Date.now(); + + this.lastUpdateHandlersStart = now; + + // Loop over all the handlers. + for (let name in this.handlers) { + promises.push(this.updateHandler(this.handlers[name], now)); + } + + return Promise.all(promises).catch(() => { + // Never reject. + }); + } + + /** + * Update the status of a module in the "cache". + * + * @param {string} component Package's component. + * @param {string|number} [componentId] An ID to use in conjunction with the component. + */ + updateStatusCache(component: string, componentId: string|number, status: string, sectionId?: number) : void { + let notify, + packageId = this.filepoolProvider.getPackageId(component, componentId), + cachedStatus = this.statusCache.getValue(packageId, 'status', true); + + // If the status has changed, notify that the section has changed. + notify = typeof cachedStatus != 'undefined' && cachedStatus !== status; + + if (notify) { + if (!sectionId) { + sectionId = this.statusCache.getValue(packageId, 'sectionId', true); + } + + // Invalidate and set again. + this.statusCache.invalidate(packageId); + this.statusCache.setValue(packageId, 'status', status); + + if (sectionId) { + this.statusCache.setValue(packageId, 'sectionId', sectionId); + + this.eventsProvider.trigger(CoreEventsProvider.SECTION_STATUS_CHANGED, { + sectionId: sectionId + }, this.sitesProvider.getCurrentSiteId()); + } + } else { + this.statusCache.setValue(packageId, 'status', status); + } + } +} diff --git a/src/providers/utils/utils.ts b/src/providers/utils/utils.ts index 2b9803134..0979a38d3 100644 --- a/src/providers/utils/utils.ts +++ b/src/providers/utils/utils.ts @@ -104,10 +104,11 @@ export class CoreUtilsProvider { * * @param {any[]} array The array to convert. * @param {string} propertyName The name of the property to use as the key. + * @param {any} [result] Object where to put the properties. If not defined, a new object will be created. * @return {any} The object. */ - arrayToObject(array: any[], propertyName: string) : any { - let result = {}; + arrayToObject(array: any[], propertyName: string, result?: any) : any { + result = result || {}; array.forEach((entry) => { result[entry[propertyName]] = entry; }); @@ -1154,9 +1155,9 @@ export class CoreUtilsProvider { * Sum the filesizes from a list of files checking if the size will be partial or totally calculated. * * @param {any[]} files List of files to sum its filesize. - * @return {object} Object with the file size and a boolean to indicate if it is the total size or only partial. + * @return {{size: number, total: boolean}} File size and a boolean to indicate if it is the total size or only partial. */ - sumFileSizes(files: any[]) : object { + sumFileSizes(files: any[]) : {size: number, total: boolean} { let result = { size: 0, total: true