From fe2c4cce742179693cd8ecae87d7a42658dfe255 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 17 Jan 2018 12:12:23 +0100 Subject: [PATCH] MOBILE-2310 course: Apply prefetch to section page --- .../context-menu/context-menu-item.ts | 2 +- .../context-menu/context-menu-popover.ts | 10 +- src/core/course/components/format/format.html | 41 +- src/core/course/components/format/format.ts | 119 ++- src/core/course/pages/section/section.html | 5 +- src/core/course/pages/section/section.ts | 76 +- src/core/course/providers/course.ts | 5 +- src/core/course/providers/helper.ts | 683 +++++++++++++++++- .../providers/module-prefetch-delegate.ts | 147 +++- src/providers/utils/dom.ts | 7 +- 10 files changed, 1031 insertions(+), 64 deletions(-) diff --git a/src/components/context-menu/context-menu-item.ts b/src/components/context-menu/context-menu-item.ts index 61ab66d29..7527c3953 100644 --- a/src/components/context-menu/context-menu-item.ts +++ b/src/components/context-menu/context-menu-item.ts @@ -38,7 +38,7 @@ export class CoreContextMenuItemComponent implements OnInit, OnDestroy, OnChange @Input() iconDescription?: string; // Name of the icon to be shown on the left side of the item. @Input() iconAction?: string; // Name of the icon to be shown on the right side of the item. It represents the action to do on // click. If is "spinner" an spinner will be shown. If no icon or spinner is selected, no action - // or link will work. If href but no iconAction is provided ion-arrow-right-c will be used. + // or link will work. If href but no iconAction is provided arrow-right will be used. @Input() ariaDescription?: string; // Aria label to add to iconDescription. @Input() ariaAction?: string; // Aria label to add to iconAction. If not set, it will be equal to content. @Input() href?: string; // Link to go if no action provided. diff --git a/src/components/context-menu/context-menu-popover.ts b/src/components/context-menu/context-menu-popover.ts index fa144228e..19ad6f0d2 100644 --- a/src/components/context-menu/context-menu-popover.ts +++ b/src/components/context-menu/context-menu-popover.ts @@ -15,6 +15,7 @@ import { Component } from '@angular/core'; import { NavParams, ViewController } from 'ionic-angular'; import { CoreContextMenuItemComponent } from './context-menu-item'; +import { CoreLoggerProvider } from '../../providers/logger'; /** * Component to display a list of items received by param in a popover. @@ -26,10 +27,12 @@ import { CoreContextMenuItemComponent } from './context-menu-item'; export class CoreContextMenuPopoverComponent { title: string; items: CoreContextMenuItemComponent[]; + protected logger: any; - constructor(navParams: NavParams, private viewCtrl: ViewController) { + constructor(navParams: NavParams, private viewCtrl: ViewController, logger: CoreLoggerProvider) { this.title = navParams.get('title'); this.items = navParams.get('items') || []; + this.logger = logger.getInstance('CoreContextMenuPopoverComponent'); } /** @@ -51,7 +54,10 @@ export class CoreContextMenuPopoverComponent { event.preventDefault(); event.stopPropagation(); - if (!item.iconAction || item.iconAction == 'spinner') { + if (!item.iconAction) { + this.logger.warn('Items with action must have an icon action to work', item); + return false; + } else if (item.iconAction == 'spinner') { return false; } diff --git a/src/core/course/components/format/format.html b/src/core/course/components/format/format.html index f95f21e6f..cd368e00f 100644 --- a/src/core/course/components/format/format.html +++ b/src/core/course/components/format/format.html @@ -10,18 +10,14 @@ - - - - - {{section.formattedName || section.name}} - - - - - - - +
+ + + {{section.formattedName || section.name}} + + + +
@@ -45,11 +41,14 @@
+
+ + @@ -62,5 +61,23 @@
+ + +
+ + + + + + + + {{section.count}} / {{section.total}} +
+
+ \ No newline at end of file diff --git a/src/core/course/components/format/format.ts b/src/core/course/components/format/format.ts index 7b93ef378..62ab64cba 100644 --- a/src/core/course/components/format/format.ts +++ b/src/core/course/components/format/format.ts @@ -12,12 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, OnInit, OnChanges, ViewContainerRef, ComponentFactoryResolver, ViewChild, ChangeDetectorRef, +import { Component, Input, OnInit, OnChanges, OnDestroy, ViewContainerRef, ComponentFactoryResolver, ViewChild, ChangeDetectorRef, SimpleChange, Output, EventEmitter } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; +import { CoreEventsProvider } from '../../../../providers/events'; import { CoreLoggerProvider } from '../../../../providers/logger'; +import { CoreSitesProvider } from '../../../../providers/sites'; +import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; import { CoreCourseProvider } from '../../../course/providers/course'; +import { CoreCourseHelperProvider } from '../../../course/providers/helper'; import { CoreCourseFormatDelegate } from '../../../course/providers/format-delegate'; +import { CoreCourseModulePrefetchDelegate } from '../../../course/providers/module-prefetch-delegate'; /** * Component to display course contents using a certain format. If the format isn't found, use default one. @@ -33,9 +38,10 @@ import { CoreCourseFormatDelegate } from '../../../course/providers/format-deleg selector: 'core-course-format', templateUrl: 'format.html' }) -export class CoreCourseFormatComponent implements OnInit, OnChanges { +export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { @Input() course: any; // The course to render. @Input() sections: any[]; // List of course sections. + @Input() downloadEnabled?: boolean; // Whether the download of sections and modules is enabled. @Output() completionChanged?: EventEmitter; // Will emit an event when any module completion changes. // Get the containers where to inject dynamic components. We use a setter because they might be inside a *ngIf. @@ -71,12 +77,53 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges { loaded: boolean; protected logger; + protected sectionStatusObserver; constructor(logger: CoreLoggerProvider, private cfDelegate: CoreCourseFormatDelegate, translate: TranslateService, - private factoryResolver: ComponentFactoryResolver, private cdr: ChangeDetectorRef) { + private factoryResolver: ComponentFactoryResolver, private cdr: ChangeDetectorRef, + private courseHelper: CoreCourseHelperProvider, private domUtils: CoreDomUtilsProvider, + eventsProvider: CoreEventsProvider, private sitesProvider: CoreSitesProvider, + prefetchDelegate: CoreCourseModulePrefetchDelegate) { + this.logger = logger.getInstance('CoreCourseFormatComponent'); this.selectOptions.title = translate.instant('core.course.sections'); this.completionChanged = new EventEmitter(); + + // Listen for section status changes. + this.sectionStatusObserver = eventsProvider.on(CoreEventsProvider.SECTION_STATUS_CHANGED, (data) => { + if (this.downloadEnabled && this.sections && this.sections.length && this.course && data.sectionId && + data.courseId == this.course.id) { + // Check if the affected section is being downloaded. If so, we don't update section status + // because it'll already be updated when the download finishes. + let downloadId = this.courseHelper.getSectionDownloadId({id: data.sectionId}); + if (prefetchDelegate.isBeingDownloaded(downloadId)) { + return; + } + + // Get the affected section. + let section; + for (let i = 0; i < this.sections.length; i++) { + let s = this.sections[i]; + if (s.id === data.sectionId) { + section = s; + break; + } + } + + if (!section) { + // Section not found, stop. + return; + } + + // Recalculate the status. + this.courseHelper.calculateSectionStatus(section, this.course.id, false).then(() => { + if (section.isDownloading && !prefetchDelegate.isBeingDownloaded(downloadId)) { + // All the modules are now downloading, set a download all promise. + this.prefetch(section, false); + } + }); + } + }, this.sitesProvider.getCurrentSiteId()); } /** @@ -119,6 +166,10 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges { } } + if (changes.downloadEnabled && this.downloadEnabled) { + this.calculateSectionsStatus(false); + } + // Apply the changes to the components and call ngOnChanges if it exists. for (let type in this.componentInstances) { let instance = this.componentInstances[type]; @@ -164,6 +215,7 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges { // Set the Input data. this.componentInstances[type].course = this.course; this.componentInstances[type].sections = this.sections; + this.componentInstances[type].downloadEnabled = this.downloadEnabled; return true; } catch(ex) { @@ -202,4 +254,65 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges { compareSections(s1: any, s2: any) : boolean { return s1 && s2 ? s1.id === s2.id : s1 === s2; } + + /** + * Calculate the status of sections. + * + * @param {boolean} refresh [description] + */ + protected calculateSectionsStatus(refresh?: boolean) : void { + this.courseHelper.calculateSectionsStatus(this.sections, this.course.id, refresh).catch(() => { + // Ignore errors (shouldn't happen). + }); + } + + /** + * Confirm and prefetch a section. If the section is "all sections", prefetch all the sections. + * + * @param {Event} e Click event. + * @param {any} section Section to download. + */ + prefetch(e: Event, section: any) : void { + e.preventDefault(); + e.stopPropagation(); + + section.isCalculating = true; + this.courseHelper.confirmDownloadSizeSection(this.course.id, section, this.sections).then(() => { + this.prefetchSection(section, true); + }, (error) => { + // User cancelled or there was an error calculating the size. + if (error) { + this.domUtils.showErrorModal(error); + } + }).finally(() => { + section.isCalculating = false; + }); + } + + /** + * Prefetch a section. + * + * @param {any} section The section to download. + * @param {boolean} [manual] Whether the prefetch was started manually or it was automatically started because all modules + * are being downloaded. + */ + protected prefetchSection(section: any, manual?: boolean) { + this.courseHelper.prefetchSection(section, this.course.id, this.sections).catch((error) => { + // Don't show error message if it's an automatic download. + if (!manual) { + return; + } + + this.domUtils.showErrorModalDefault(error, 'core.course.errordownloadingsection', true); + }); + } + + /** + * Component destroyed. + */ + ngOnDestroy() { + if (this.sectionStatusObserver) { + this.sectionStatusObserver.off(); + } + } } diff --git a/src/core/course/pages/section/section.html b/src/core/course/pages/section/section.html index cfc1c9752..db0d61b7c 100644 --- a/src/core/course/pages/section/section.html +++ b/src/core/course/pages/section/section.html @@ -4,7 +4,8 @@ - + + @@ -19,6 +20,6 @@ {{ 'core.course.contents' || translate }} {{ handler.data.title || translate }} - + diff --git a/src/core/course/pages/section/section.ts b/src/core/course/pages/section/section.ts index cafeef9f1..5326dfe1b 100644 --- a/src/core/course/pages/section/section.ts +++ b/src/core/course/pages/section/section.ts @@ -16,12 +16,13 @@ import { Component, ViewChild, OnDestroy } from '@angular/core'; import { IonicPage, NavParams, Content } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; import { CoreEventsProvider } from '../../../../providers/events'; +import { CoreSitesProvider } from '../../../../providers/sites'; import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; import { CoreTextUtilsProvider } from '../../../../providers/utils/text'; import { CoreCourseProvider } from '../../providers/course'; import { CoreCourseHelperProvider } from '../../providers/helper'; import { CoreCourseFormatDelegate } from '../../providers/format-delegate'; -import { CoreCoursesDelegate } from '../../../courses/providers/delegate'; +import { CoreCoursesDelegate, CoreCoursesHandlerToDisplay } from '../../../courses/providers/delegate'; import { CoreCoursesProvider } from '../../../courses/providers/courses'; /** @@ -38,16 +39,24 @@ export class CoreCourseSectionPage implements OnDestroy { title: string; course: any; sections: any[]; - courseHandlers: any[]; + courseHandlers: CoreCoursesHandlerToDisplay[]; dataLoaded: boolean; + downloadEnabled: boolean; + downloadEnabledIcon: string = 'square-outline'; // Disabled by default. + prefetchCourseData = { + prefetchCourseIcon: 'spinner' + }; protected moduleId; protected completionObserver; + protected courseStatusObserver; + protected isDestroyed = false; constructor(navParams: NavParams, private courseProvider: CoreCourseProvider, private domUtils: CoreDomUtilsProvider, private courseFormatDelegate: CoreCourseFormatDelegate, private coursesDelegate: CoreCoursesDelegate, private translate: TranslateService, private courseHelper: CoreCourseHelperProvider, eventsProvider: CoreEventsProvider, - private textUtils: CoreTextUtilsProvider, private coursesProvider: CoreCoursesProvider) { + private textUtils: CoreTextUtilsProvider, private coursesProvider: CoreCoursesProvider, + sitesProvider: CoreSitesProvider) { this.course = navParams.get('course'); this.title = courseFormatDelegate.getCourseTitle(this.course); this.moduleId = navParams.get('moduleId'); @@ -57,6 +66,13 @@ export class CoreCourseSectionPage implements OnDestroy { this.refreshAfterCompletionChange(); } }); + + // Listen for changes in course status. + this.courseStatusObserver = eventsProvider.on(CoreEventsProvider.COURSE_STATUS_CHANGED, (data) => { + if (data.courseId == this.course.id) { + this.prefetchCourseData.prefetchCourseIcon = this.courseHelper.getCourseStatusIconFromStatus(data.status); + } + }, sitesProvider.getCurrentSiteId()); } /** @@ -66,6 +82,27 @@ export class CoreCourseSectionPage implements OnDestroy { this.loadData().finally(() => { this.dataLoaded = true; delete this.moduleId; // Only load module automatically the first time. + + // Determine the course prefetch status. + this.determineCoursePrefetchIcon().then(() => { + if (this.prefetchCourseData.prefetchCourseIcon == 'spinner') { + // Course is being downloaded. Get the download promise. + const promise = this.courseHelper.getCourseDownloadPromise(this.course.id); + if (promise) { + // There is a download promise. Show an error if it fails. + promise.catch((error) => { + if (!this.isDestroyed) { + this.domUtils.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true); + } + }); + } else { + // No download, this probably means that the app was closed while downloading. Set previous status. + this.courseProvider.setCoursePreviousStatus(this.course.id).then((status) => { + this.prefetchCourseData.prefetchCourseIcon = this.courseHelper.getCourseStatusIconFromStatus(status); + }); + } + } + }); }); } @@ -184,10 +221,43 @@ export class CoreCourseSectionPage implements OnDestroy { }); } + /** + * Determines the prefetch icon of the course. + */ + protected determineCoursePrefetchIcon() { + return this.courseHelper.getCourseStatusIcon(this.course.id).then((icon) => { + this.prefetchCourseData.prefetchCourseIcon = icon; + }); + } + + /** + * Prefetch the whole course. + */ + prefetchCourse() { + this.courseHelper.confirmAndPrefetchCourse(this.prefetchCourseData, this.course, this.sections, this.courseHandlers) + .then((downloaded) => { + if (downloaded && this.downloadEnabled) { + // Recalculate the status. + this.courseHelper.calculateSectionsStatus(this.sections, this.course.id).catch(() => { + // Ignore errors (shouldn't happen). + }); + } + }); + } + + /** + * Toggle download enabled. + */ + toggleDownload() { + this.downloadEnabled = !this.downloadEnabled; + this.downloadEnabledIcon = this.downloadEnabled ? 'checkbox-outline' : 'square-outline'; + } + /** * Page destroyed. */ ngOnDestroy() { + this.isDestroyed = true; if (this.completionObserver) { this.completionObserver.off(); } diff --git a/src/core/course/providers/course.ts b/src/core/course/providers/course.ts index 177a84dc0..5aced5a1a 100644 --- a/src/core/course/providers/course.ts +++ b/src/core/course/providers/course.ts @@ -712,7 +712,7 @@ export class CoreCourseProvider { } /** - * Trigger mmCoreEventCourseStatusChanged with the right data. + * Trigger COURSE_STATUS_CHANGED with the right data. * * @param {number} courseId Course ID. * @param {string} status New course status. @@ -720,9 +720,8 @@ export class CoreCourseProvider { */ protected triggerCourseStatusChanged(courseId: number, status: string, siteId?: string) : void { this.eventsProvider.trigger(CoreEventsProvider.COURSE_STATUS_CHANGED, { - siteId: siteId, courseId: courseId, status: status - }); + }, siteId); } } diff --git a/src/core/course/providers/helper.ts b/src/core/course/providers/helper.ts index 537785155..961afbea8 100644 --- a/src/core/course/providers/helper.ts +++ b/src/core/course/providers/helper.ts @@ -13,9 +13,89 @@ // limitations under the License. import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreFilepoolProvider } from '../../../providers/filepool'; +import { CoreSitesProvider } from '../../../providers/sites'; import { CoreDomUtilsProvider } from '../../../providers/utils/dom'; +import { CoreTextUtilsProvider } from '../../../providers/utils/text'; +import { CoreTimeUtilsProvider } from '../../../providers/utils/time'; +import { CoreUtilsProvider } from '../../../providers/utils/utils'; +import { CoreCoursesDelegate, CoreCoursesHandlerToDisplay } from '../../courses/providers/delegate'; import { CoreCourseProvider } from './course'; import { CoreCourseModuleDelegate } from './module-delegate'; +import { CoreCourseModulePrefetchDelegate, CoreCourseModulePrefetchHandler } from './module-prefetch-delegate'; +import { CoreConstants } from '../../constants'; +import * as moment from 'moment'; + +/** + * Prefetch info of a module. + */ +export type CoreCourseModulePrefetchInfo = { + /** + * Downloaded size. + * @type {number} + */ + size?: number; + + /** + * Downloadable size in a readable format. + * @type {string} + */ + sizeReadable?: string; + + /** + * Module status. + * @type {string} + */ + status?: string; + + /** + * Icon's name of the module status. + * @type {string} + */ + statusIcon?: string; + + /** + * Time when the module was last downloaded. + * @type {number} + */ + downloadTime?: number; + + /** + * Download time in a readable format. + * @type {string} + */ + downloadTimeReadable?: string; +}; + +/** + * Progress of downloading a list of courses. + */ +export type CoreCourseCoursesProgress = { + /** + * Number of courses downloaded so far. + * @type {number} + */ + count: number; + + /** + * Toal of courses to download. + * @type {number} + */ + total: number; + + /** + * Whether the download has been successful so far. + * @type {boolean} + */ + success: boolean; + + /** + * Last downloaded course. + * @type {number} + */ + courseId?: number; +}; /** * Helper to gather some common course functions. @@ -23,8 +103,13 @@ import { CoreCourseModuleDelegate } from './module-delegate'; @Injectable() export class CoreCourseHelperProvider { + protected courseDwnPromises: {[s: string]: {[id: number]: Promise}} = {}; + constructor(private courseProvider: CoreCourseProvider, private domUtils: CoreDomUtilsProvider, - private moduleDelegate: CoreCourseModuleDelegate) {} + private moduleDelegate: CoreCourseModuleDelegate, private prefetchDelegate: CoreCourseModulePrefetchDelegate, + private filepoolProvider: CoreFilepoolProvider, private sitesProvider: CoreSitesProvider, + private textUtils: CoreTextUtilsProvider, private timeUtils: CoreTimeUtilsProvider, + private utils: CoreUtilsProvider, private translate: TranslateService, private coursesDelegate: CoreCoursesDelegate) {} /** * This function treats every module on the sections provided to load the handler data, treat completion @@ -65,6 +150,329 @@ export class CoreCourseHelperProvider { return hasContent; } + /** + * Calculate the status of a section. + * + * @param {any} section Section to calculate its status. It can't be "All sections". + * @param {number} courseId Course ID the section belongs to. + * @param {boolean} [refresh] True if it shouldn't use module status cache (slower). + * @return {Promise} Promise resolved when the status is calculated. + */ + calculateSectionStatus(section: any, courseId: number, refresh?: boolean) : Promise { + + if (section.id == CoreCourseProvider.ALL_SECTIONS_ID) { + return Promise.reject(null); + } + + // Get the status of this section. + return this.prefetchDelegate.getModulesStatus(section.modules, courseId, section.id, refresh).then((result) => { + // Check if it's being downloaded. + const downloadId = this.getSectionDownloadId(section); + if (this.prefetchDelegate.isBeingDownloaded(downloadId)) { + result.status = CoreConstants.DOWNLOADING; + } + + // Set this section data. + section.showDownload = result.status === CoreConstants.NOT_DOWNLOADED; + section.showRefresh = result.status === CoreConstants.OUTDATED; + + if (result.status !== CoreConstants.DOWNLOADING || !this.prefetchDelegate.isBeingDownloaded(section.id)) { + section.isDownloading = false; + section.total = 0; + } else { + // Section is being downloaded. + section.isDownloading = true; + this.prefetchDelegate.setOnProgress(downloadId, (data) => { + section.count = data.count; + section.total = data.total; + }); + } + + return result; + }); + } + + /** + * Calculate the status of a list of sections, setting attributes to determine the icons/data to be shown. + * + * @param {any[]} sections Sections to calculate their status. + * @param {number} courseId Course ID the sections belong to. + * @param {boolean} [refresh] True if it shouldn't use module status cache (slower). + * @return {Promise} Promise resolved when the states are calculated. + */ + calculateSectionsStatus(sections: any[], courseId: number, refresh?: boolean) : Promise { + let allSectionsSection, + allSectionsStatus, + promises = []; + + sections.forEach((section) => { + if (section.id === CoreCourseProvider.ALL_SECTIONS_ID) { + // "All sections" section status is calculated using the status of the rest of sections. + allSectionsSection = section; + section.isCalculating = true; + } else { + section.isCalculating = true; + promises.push(this.calculateSectionStatus(section, courseId, refresh).then((result) => { + // Calculate "All sections" status. + allSectionsStatus = this.filepoolProvider.determinePackagesStatus(allSectionsStatus, result.status); + }).finally(() => { + section.isCalculating = false; + })); + } + }); + + return Promise.all(promises).then(() => { + if (allSectionsSection) { + // Set "All sections" data. + allSectionsSection.showDownload = allSectionsStatus === CoreConstants.NOT_DOWNLOADED; + allSectionsSection.showRefresh = allSectionsStatus === CoreConstants.OUTDATED; + allSectionsSection.isDownloading = allSectionsStatus === CoreConstants.DOWNLOADING; + } + }).finally(() => { + if (allSectionsSection) { + allSectionsSection.isCalculating = false; + } + }); + } + + /** + * Show a confirm and prefetch a course. It will retrieve the sections and the course options if not provided. + * This function will set the icon to "spinner" when starting and it will also set it back to the initial icon if the + * user cancels. All the other updates of the icon should be made when CoreEventsProvider.COURSE_STATUS_CHANGED is received. + * + * @param {any} iconData An object where to store the course icon. It will be stored with the name "prefetchCourseIcon". + * @param {any} course Course to prefetch. + * @param {any[]} [sections] List of course sections. + * @param {CoreCoursesHandlerToDisplay[]} courseHandlers List of course handlers. + * @return {Promise} Promise resolved with true when the download finishes, resolved with false if user doesn't + * confirm, rejected if an error occurs. + */ + confirmAndPrefetchCourse(iconData: any, course: any, sections?: any[], courseHandlers?: CoreCoursesHandlerToDisplay[]) + : Promise { + let initialIcon = iconData.prefetchCourseIcon, + promise, + siteId = this.sitesProvider.getCurrentSiteId(); + + iconData.prefetchCourseIcon = 'spinner'; + + // Get the sections first if needed. + if (sections) { + promise = Promise.resolve(sections); + } else { + promise = this.courseProvider.getSections(course.id, false, true); + } + + return promise.then((sections) => { + // Confirm the download. + return this.confirmDownloadSizeSection(course.id, undefined, sections, true).then(() => { + // User confirmed, get the course handlers if needed. + if (courseHandlers) { + promise = Promise.resolve(courseHandlers); + } else { + promise = this.coursesDelegate.getHandlersToDisplay(course); + } + + return promise.then((handlers: CoreCoursesHandlerToDisplay[]) => { + // Now we have all the data, download the course. + return this.prefetchCourse(course, sections, handlers, siteId); + }).then(() => { + // Download successful. + return true; + }); + }, (error) : any => { + // User cancelled or there was an error calculating the size. + iconData.prefetchCourseIcon = initialIcon; + if (error) { + return Promise.reject(error); + } + return false; + }); + }); + }; + + /** + * Confirm and prefetches a list of courses. + * + * @param {Object[]} courses List of courses to download. + * @return {Promise} Promise resolved with true when downloaded, resolved with false if user cancels, rejected if error. + * It will send a "progress" everytime a course is downloaded or fails to download. + */ + confirmAndPrefetchCourses(courses: any[], onProgress?: (data: CoreCourseCoursesProgress) => void) : Promise { + const siteId = this.sitesProvider.getCurrentSiteId(); + + // Confirm the download without checking size because it could take a while. + return this.domUtils.showConfirm(this.translate.instant('core.areyousure')).then(() => { + let promises = [], + total = courses.length, + count = 0; + + courses.forEach((course) => { + let subPromises = [], + sections, + handlers, + success = true; + + // Get the sections and the handlers. + subPromises.push(this.courseProvider.getSections(course.id, false, true).then((courseSections) => { + sections = courseSections; + })); + subPromises.push(this.coursesDelegate.getHandlersToDisplay(course).then((cHandlers) => { + handlers = cHandlers; + })); + + promises.push(Promise.all(subPromises).then(() => { + return this.prefetchCourse(course, sections, handlers, siteId); + }).catch((error) => { + success = false; + return Promise.reject(error); + }).finally(() => { + // Course downloaded or failed, notify the progress. + count++; + if (onProgress) { + onProgress({count: count, total: total, courseId: course.id, success: success}); + } + })); + }); + + if (onProgress) { + // Notify the start of the download. + onProgress({count: 0, total: total, success: true}); + } + + return this.utils.allPromises(promises).then(() => { + return true; + }); + }, () => { + // User cancelled. + return false; + }); + }; + + /** + * Show confirmation dialog and then remove a module files. + * + * @param {any} module Module to remove the files. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved when done. + */ + confirmAndRemoveFiles(module: any, courseId: number) : Promise { + return this.domUtils.showConfirm(this.translate.instant('course.confirmdeletemodulefiles')).then(() => { + return this.prefetchDelegate.removeModuleFiles(module, courseId); + }); + } + + /** + * Calculate the size to download a section and show a confirm modal if needed. + * + * @param {number} courseId Course ID the section belongs to. + * @param {any} [section] Section. If not provided, all sections. + * @param {any[]} [sections] List of sections. Used when downloading all the sections. + * @param {boolean} [alwaysConfirm] True to show a confirm even if the size isn't high, false otherwise. + * @return {Promise} Promise resolved if the user confirms or there's no need to confirm. + */ + confirmDownloadSizeSection(courseId: number, section?: any, sections?: any[], alwaysConfirm?: boolean) : Promise { + let sizePromise; + + // Calculate the size of the download. + if (section && section.id != CoreCourseProvider.ALL_SECTIONS_ID) { + sizePromise = this.prefetchDelegate.getDownloadSize(section.modules, courseId); + } else { + let promises = [], + results = { + size: 0, + total: true + }; + + sections.forEach((s) => { + if (s.id != CoreCourseProvider.ALL_SECTIONS_ID) { + promises.push(this.prefetchDelegate.getDownloadSize(s.modules, courseId).then((sectionSize) => { + results.total = results.total && sectionSize.total; + results.size += sectionSize.size; + })); + } + }); + + sizePromise = Promise.all(promises).then(() => { + return results; + }); + } + + return sizePromise.then((size) => { + // Show confirm modal if needed. + return this.domUtils.confirmDownloadSize(size, undefined, undefined, undefined, undefined, alwaysConfirm); + }); + } + + /** + * Determine the status of a list of courses. + * + * @param {any[]} courses Courses + * @return {Promise} Promise resolved with the status. + */ + determineCoursesStatus(courses: any[]) : Promise { + // Get the status of each course. + const promises = [], + siteId = this.sitesProvider.getCurrentSiteId(); + + courses.forEach((course) => { + promises.push(this.courseProvider.getCourseStatus(course.id, siteId)); + }); + + return Promise.all(promises).then((statuses) => { + // Now determine the status of the whole list. + let status = statuses[0]; + for (let i = 1; i < statuses.length; i++) { + status = this.filepoolProvider.determinePackagesStatus(status, statuses[i]); + } + return status; + }); + } + + /** + * Get a course download promise (if any). + * + * @param {number} courseId Course ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Download promise, undefined if not found. + */ + getCourseDownloadPromise(courseId: number, siteId?: string) : Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + return this.courseDwnPromises[siteId] && this.courseDwnPromises[siteId][courseId]; + } + + /** + * Get a course status icon. + * + * @param {number} courseId Course ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the icon name. + */ + getCourseStatusIcon(courseId: number, siteId?: string) : Promise { + return this.courseProvider.getCourseStatus(courseId, siteId).then((status) => { + return this.getCourseStatusIconFromStatus(status); + }); + } + + /** + * Get a course status icon from status. + * + * @module mm.core.course + * @ngdoc method + * @name $mmCourseHelper#getCourseStatusIconFromStatus + * @param {String} status Course status. + * @return {String} Icon name. + */ + getCourseStatusIconFromStatus(status: string) : string { + if (status == CoreConstants.DOWNLOADED) { + // Always show refresh icon, we cannot knew if there's anything new in course options. + return 'refresh'; + } else if (status == CoreConstants.DOWNLOADING) { + return 'spinner'; + } else { + return 'cloud-download'; + } + } + /** * Get the course ID from a module instance ID, showing an error message if it can't be retrieved. * @@ -82,6 +490,279 @@ export class CoreCourseHelperProvider { }); } + /** + * Get prefetch info for a module. + * + * @param {any} module Module to get the info from. + * @param {number} courseId Course ID the section belongs to. + * @param {boolean} [invalidateCache] Invalidates the cache first. + * @param {string} [component] Component of the module. + * @return {Promise} Promise resolved with the info. + */ + getModulePrefetchInfo(module: any, courseId: number, invalidateCache?: boolean, component?: string) + : Promise { + let moduleInfo: CoreCourseModulePrefetchInfo = {}, + siteId = this.sitesProvider.getCurrentSiteId(), + promises = []; + + if (invalidateCache) { + this.prefetchDelegate.invalidateModuleStatusCache(module); + } + + promises.push(this.prefetchDelegate.getModuleDownloadedSize(module, courseId).then((moduleSize) => { + moduleInfo.size = moduleSize; + moduleInfo.sizeReadable = this.textUtils.bytesToSize(moduleSize, 2); + })); + + // @todo: Decide what to display instead of timemodified. Last check_updates? + // promises.push(this.prefetchDelegate.getModuleTimemodified(module, courseId).then(function(moduleModified) { + // moduleInfo.timemodified = moduleModified; + // if (moduleModified > 0) { + // var now = $mmUtil.timestamp(); + // if (now - moduleModified < 7 * 86400) { + // moduleInfo.timemodifiedReadable = moment(moduleModified * 1000).fromNow(); + // } else { + // moduleInfo.timemodifiedReadable = moment(moduleModified * 1000).calendar(); + // } + // } else { + // moduleInfo.timemodifiedReadable = ""; + // } + // })); + + promises.push(this.prefetchDelegate.getModuleStatus(module, courseId).then((moduleStatus) => { + moduleInfo.status = moduleStatus; + switch (moduleStatus) { + case CoreConstants.NOT_DOWNLOADED: + moduleInfo.statusIcon = 'cloud-download'; + break; + case CoreConstants.DOWNLOADING: + moduleInfo.statusIcon = 'spinner'; + break; + case CoreConstants.OUTDATED: + moduleInfo.statusIcon = 'ion-android-refresh'; + break; + default: + moduleInfo.statusIcon = ''; + break; + } + })); + + // Get the time it was downloaded (if it was downloaded). + promises.push(this.filepoolProvider.getPackageData(siteId, component, module.id).then((data) => { + if (data && data.downloadTime && (data.status == CoreConstants.OUTDATED || data.status == CoreConstants.DOWNLOADED)) { + const now = this.timeUtils.timestamp(); + moduleInfo.downloadTime = data.downloadTime; + if (now - data.downloadTime < 7 * 86400) { + moduleInfo.downloadTimeReadable = moment(data.downloadTime * 1000).fromNow(); + } else { + moduleInfo.downloadTimeReadable = moment(data.downloadTime * 1000).calendar(); + } + } + }).catch(() => { + // Not downloaded. + moduleInfo.downloadTime = 0; + })); + + return Promise.all(promises).then(() => { + return moduleInfo; + }); + } + + /** + * Get the download ID of a section. It's used to interact with CoreCourseModulePrefetchDelegate. + * + * @param {any} section Section. + * @return {string} Section download ID. + */ + getSectionDownloadId(section: any) : string { + return 'Section-' + section.id; + } + + /** + * Prefetch all the activities in a course and also the course addons. + * + * @param {any} course The course to prefetch. + * @param {any[]} sections List of course sections. + * @param {CoreCoursesHandlerToDisplay[]} courseHandlers List of course handlers. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the download finishes. + */ + prefetchCourse(course: any, sections: any[], courseHandlers: CoreCoursesHandlerToDisplay[], siteId?: string) : Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + if (this.courseDwnPromises[siteId] && this.courseDwnPromises[siteId][course.id]) { + // There's already a download ongoing for this course, return the promise. + return this.courseDwnPromises[siteId][course.id]; + } else if (!this.courseDwnPromises[siteId]) { + this.courseDwnPromises[siteId] = {}; + } + + // First of all, mark the course as being downloaded. + this.courseDwnPromises[siteId][course.id] = this.courseProvider.setCourseStatus(course.id, CoreConstants.DOWNLOADING, + siteId).then(() => { + let promises = [], + allSectionsSection = sections[0]; + + // Prefetch all the sections. If the first section is "All sections", use it. Otherwise, use a fake "All sections". + if (sections[0].id != CoreCourseProvider.ALL_SECTIONS_ID) { + allSectionsSection = {id: CoreCourseProvider.ALL_SECTIONS_ID}; + } + promises.push(this.prefetchSection(allSectionsSection, course.id, sections)); + + // Prefetch course options. + courseHandlers.forEach((handler) => { + if (handler.prefetch) { + promises.push(handler.prefetch(course)); + } + }); + + return this.utils.allPromises(promises); + }).then(() => { + // Download success, mark the course as downloaded. + return this.courseProvider.setCourseStatus(course.id, CoreConstants.DOWNLOADED, siteId); + }).catch((error) => { + // Error, restore previous status. + return this.courseProvider.setCoursePreviousStatus(course.id, siteId).then(() => { + return Promise.reject(error); + }); + }).finally(() => { + delete this.courseDwnPromises[siteId][course.id]; + }); + + return this.courseDwnPromises[siteId][course.id]; + } + + /** + * Helper function to prefetch a module, showing a confirmation modal if the size is big + * and invalidating contents if refreshing. + * + * @param {handler} handler Prefetch handler to use. Must implement 'prefetch' and 'invalidateContent'. + * @param {any} module Module to download. + * @param {any} size Object containing size to download (in bytes) and a boolean to indicate if its totally calculated. + * @param {number} courseId Course ID of the module. + * @param {boolean} [refresh] True if refreshing, false otherwise. + * @return {Promise} Promise resolved when downloaded. + */ + prefetchModule(handler: any, module: any, size: any, courseId: number, refresh?: boolean) : Promise { + // Show confirmation if needed. + return this.domUtils.confirmDownloadSize(size).then(() => { + // Invalidate content if refreshing and download the data. + let promise = refresh ? handler.invalidateContent(module.id, courseId) : Promise.resolve(); + return promise.catch(() => { + // Ignore errors. + }).then(() => { + return handler.prefetch(module, courseId, true); + }); + }); + } + + /** + * Prefetch one section or all the sections. + * If the section is "All sections" it will prefetch all the sections. + * + * @param {any} section Section. + * @param {number} courseId Course ID the section belongs to. + * @param {any[]} [sections] List of sections. Used when downloading all the sections. + * @return {Promise} Promise resolved when the prefetch is finished. + */ + prefetchSection(section: any, courseId: number, sections?: any[]) : Promise { + if (section.id != CoreCourseProvider.ALL_SECTIONS_ID) { + // Download only this section. + return this.prefetchSingleSectionIfNeeded(section, courseId).then(() => { + // Calculate the status of the section that finished. + return this.calculateSectionStatus(section, courseId); + }); + } else { + // Download all the sections except "All sections". + let promises = [], + allSectionsStatus; + + section.isDownloading = true; + sections.forEach((section) => { + if (section.id != CoreCourseProvider.ALL_SECTIONS_ID) { + promises.push(this.prefetchSingleSectionIfNeeded(section, courseId).then(() => { + // Calculate the status of the section that finished. + return this.calculateSectionStatus(section, courseId).then((result) => { + // Calculate "All sections" status. + allSectionsStatus = this.filepoolProvider.determinePackagesStatus(allSectionsStatus, result.status); + }); + })); + } + }); + + return this.utils.allPromises(promises).then(() => { + // Set "All sections" data. + section.showDownload = allSectionsStatus === CoreConstants.NOT_DOWNLOADED; + section.showRefresh = allSectionsStatus === CoreConstants.OUTDATED; + section.isDownloading = allSectionsStatus === CoreConstants.DOWNLOADING; + }).finally(() => { + section.isDownloading = false; + }); + } + } + + /** + * Prefetch a certain section if it needs to be prefetched. + * If the section is "All sections" it will be ignored. + * + * @param {any} section Section to prefetch. + * @param {number} courseId Course ID the section belongs to. + * @return {Promise} Promise resolved when the section is prefetched. + */ + protected prefetchSingleSectionIfNeeded(section: any, courseId: number) : Promise { + if (section.id == CoreCourseProvider.ALL_SECTIONS_ID) { + return Promise.resolve(); + } + + section.isDownloading = true; + + // Validate the section needs to be downloaded and calculate amount of modules that need to be downloaded. + return this.prefetchDelegate.getModulesStatus(section.modules, courseId, section.id).then((result) => { + if (result.status == CoreConstants.DOWNLOADED || result.status == CoreConstants.NOT_DOWNLOADABLE) { + // Section is downloaded or not downloadable, nothing to do. + return; + } + return this.prefetchSingleSection(section, result, courseId); + }, (error) => { + section.isDownloading = false; + return Promise.reject(error); + }); + } + + /** + * Start or restore the prefetch of a section. + * If the section is "All sections" it will be ignored. + * + * @param {any} section Section to download. + * @param {any} result Result of CoreCourseModulePrefetchDelegate.getModulesStatus for this section. + * @param {number} courseId Course ID the section belongs to. + * @return {Promise} Promise resolved when the section has been prefetched. + */ + protected prefetchSingleSection(section: any, result: any, courseId: number) { + if (section.id == CoreCourseProvider.ALL_SECTIONS_ID) { + return Promise.resolve(); + } + + if (section.total > 0) { + // Already being downloaded. + return Promise.resolve(); + } + + // We only download modules with status notdownloaded, downloading or outdated. + let modules = result[CoreConstants.OUTDATED].concat(result[CoreConstants.NOT_DOWNLOADED]) + .concat(result[CoreConstants.DOWNLOADING]), + downloadId = this.getSectionDownloadId(section); + + section.isDownloading = true; + + // We prefetch all the modules to prevent incoeherences in the download count + // and also to download stale data that might not be marked as outdated. + return this.prefetchDelegate.prefetchModules(downloadId, modules, courseId, (data) => { + section.count = data.count; + section.total = data.total; + }); + } + /** * 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 index ab8f49c4c..f8506fad9 100644 --- a/src/core/course/providers/module-prefetch-delegate.ts +++ b/src/core/course/providers/module-prefetch-delegate.ts @@ -26,6 +26,31 @@ import { CoreCache } from '../../../classes/cache'; import { CoreSiteWSPreSets } from '../../../classes/site'; import { CoreConstants } from '../../constants'; import { Md5 } from 'ts-md5/dist/md5'; +import { Subject, BehaviorSubject, Subscription } from 'rxjs'; + +/** + * Progress of downloading a list of modules. + */ +export type CoreCourseModulesProgress = { + /** + * Number of modules downloaded so far. + * @type {number} + */ + count: number; + + /** + * Toal of modules to download. + * @type {number} + */ + total: number; +}; + +/** + * Progress function for downloading a list of modules. + * + * @param {CoreCourseModulesProgress} data Progress data. + */ +export type CoreCourseModulesProgressFunction = (data: CoreCourseModulesProgress) => void; /** * Interface that all course prefetch handlers must implement. @@ -203,9 +228,16 @@ export class CoreCourseModulePrefetchDelegate { protected statusCache = new CoreCache(); protected lastUpdateHandlersStart: number; - // Promises for check updates and for prefetch. + // Promises for check updates, to prevent performing the same request twice at the same time. protected courseUpdatesPromises: {[s: string]: {[s: string]: Promise}} = {}; - protected prefetchPromises: {[s: string]: {[s: string]: Promise}} = {}; + + // Promises and observables for prefetching, to prevent downloading the same section twice at the same time + // and notify the progress of the download. + protected prefetchData: {[s: string]: {[s: string]: { + promise: Promise, + observable: Subject, + subscriptions: Subscription[] + }}} = {}; constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private eventsProvider: CoreEventsProvider, private courseProvider: CoreCourseProvider, private filepoolProvider: CoreFilepoolProvider, @@ -306,16 +338,15 @@ export class CoreCourseModulePrefetchDelegate { * * @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 { + determineModuleStatus(module: any, status: string, canCheck?: boolean) : string { const handler = this.getPrefetchHandlerFor(module), siteId = this.sitesProvider.getCurrentSiteId(); if (handler) { - if (status == CoreConstants.DOWNLOADING && restoreDownloads) { + if (status == CoreConstants.DOWNLOADING) { // 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. @@ -624,13 +655,10 @@ export class CoreCourseModulePrefetchDelegate { * @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 { + getModuleStatus(module: any, courseId: number, updates?: any, refresh?: boolean, sectionId?: number) : Promise { let handler = this.getPrefetchHandlerFor(module), siteId = this.sitesProvider.getCurrentSiteId(), canCheck = this.canCheckUpdates(); @@ -644,7 +672,7 @@ export class CoreCourseModulePrefetchDelegate { promise; if (!refresh && typeof status != 'undefined') { - return Promise.resolve(this.determineModuleStatus(module, status, restoreDownloads, canCheck)); + return Promise.resolve(this.determineModuleStatus(module, status, canCheck)); } // Check if the module is downloadable. @@ -694,7 +722,7 @@ export class CoreCourseModulePrefetchDelegate { }).catch(() => { // Error checking if module has updates. const status = this.statusCache.getValue(packageId, 'status', true); - return this.determineModuleStatus(module, status, restoreDownloads, canCheck); + return this.determineModuleStatus(module, status, canCheck); }); }, () => { // Error getting updates, show the stored status. @@ -704,9 +732,9 @@ export class CoreCourseModulePrefetchDelegate { }); }).then((status) => { if (updateStatus) { - this.updateStatusCache(component, module.id, status, sectionId); + this.updateStatusCache(status, courseId, component, module.id, sectionId); } - return this.determineModuleStatus(module, status, restoreDownloads, canCheck); + return this.determineModuleStatus(module, status, canCheck); }); } @@ -722,8 +750,6 @@ export class CoreCourseModulePrefetchDelegate { * @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. @@ -732,7 +758,7 @@ export class CoreCourseModulePrefetchDelegate { * - 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 { + getModulesStatus(modules: any[], courseId: number, sectionId?: number, refresh?: boolean) : any { let promises = [], status = CoreConstants.NOT_DOWNLOADABLE, result: any = { @@ -757,7 +783,7 @@ export class CoreCourseModulePrefetchDelegate { if (handler) { let packageId = this.filepoolProvider.getPackageId(handler.component, module.id); - promises.push(this.getModuleStatus(module, courseId, updates, refresh, restoreDownloads).then((modStatus) => { + promises.push(this.getModuleStatus(module, courseId, updates, refresh).then((modStatus) => { if (modStatus != CoreConstants.NOT_DOWNLOADABLE) { if (sectionId && sectionId > 0) { // Store the section ID. @@ -906,7 +932,7 @@ export class CoreCourseModulePrefetchDelegate { */ isBeingDownloaded(id: string) : boolean { const siteId = this.sitesProvider.getCurrentSiteId(); - return !!(this.prefetchPromises[siteId] && this.prefetchPromises[siteId][id]); + return !!(this.prefetchData[siteId] && this.prefetchData[siteId][id]); } /** @@ -1017,19 +1043,39 @@ export class CoreCourseModulePrefetchDelegate { * @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. + * @param {CoreCourseModulesProgressFunction} [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 { + prefetchModules(id: string, modules: any[], courseId: number, onProgress?: CoreCourseModulesProgressFunction) : Promise { - const siteId = this.sitesProvider.getCurrentSiteId(); + const siteId = this.sitesProvider.getCurrentSiteId(), + currentData = this.prefetchData[siteId] && this.prefetchData[siteId][id]; - if (this.prefetchPromises[siteId] && this.prefetchPromises[siteId][id]) { + if (currentData) { // There's a prefetch ongoing, return the current promise. - return this.prefetchPromises[siteId][id]; + if (onProgress) { + currentData.subscriptions.push(currentData.observable.subscribe(onProgress)); + } + return currentData.promise; } - let promises = []; + let promises = [], + count = 0, + total = modules.length, + moduleIds = modules.map((module) => { + return module.id; + }); + + // Initialize the prefetch data. + const prefetchData = { + observable: new BehaviorSubject({count: count, total: total}), + promise: undefined, + subscriptions: [] + }; + + if (onProgress) { + prefetchData.observable.subscribe(onProgress); + } modules.forEach((module) => { // Check if the module has a prefetch handler. @@ -1041,23 +1087,34 @@ export class CoreCourseModulePrefetchDelegate { } return handler.prefetch(module, courseId).then(() => { - if (onProgress) { - onProgress(module.id); + let index = moduleIds.indexOf(id); + if (index > -1) { + // It's one of the modules we were expecting to download. + moduleIds.splice(index, 1); + count++; + prefetchData.observable.next({count: count, total: total}); } }); })); } }); - if (!this.prefetchPromises[siteId]) { - this.prefetchPromises[siteId] = {}; - } - - this.prefetchPromises[siteId][id] = Promise.all(promises).finally(() => { - delete this.prefetchPromises[siteId][id]; + // Set the promise. + prefetchData.promise = Promise.all(promises).finally(() => { + // Unsubscribe all observers. + prefetchData.subscriptions.forEach((subscription: Subscription) => { + subscription.unsubscribe(); + }); + delete this.prefetchData[siteId][id]; }); - return this.prefetchPromises[siteId][id]; + // Store the prefetch data in the list. + if (!this.prefetchData[siteId]) { + this.prefetchData[siteId] = {}; + } + this.prefetchData[siteId][id] = prefetchData; + + return prefetchData.promise; } /** @@ -1115,6 +1172,22 @@ export class CoreCourseModulePrefetchDelegate { }); } + /** + * Set an on progress function for the download of a list of modules. + * + * @param {string} id An ID to identify the download. + * @param {CoreCourseModulesProgressFunction} onProgress Function to call everytime a module is downloaded. + */ + setOnProgress(id: string, onProgress: CoreCourseModulesProgressFunction) : void { + const siteId = this.sitesProvider.getCurrentSiteId(), + currentData = this.prefetchData[siteId] && this.prefetchData[siteId][id]; + + if (currentData) { + // There's a prefetch ongoing, return the current promise. + currentData.subscriptions.push(currentData.observable.subscribe(onProgress)); + } + } + /** * Treat the result of the check updates WS call. * @@ -1206,10 +1279,13 @@ export class CoreCourseModulePrefetchDelegate { /** * Update the status of a module in the "cache". * + * @param {string} status New status. + * @param {number} courseId Course ID of the module. * @param {string} component Package's component. * @param {string|number} [componentId] An ID to use in conjunction with the component. + * @param {number} [sectionId] Section ID of the module. */ - updateStatusCache(component: string, componentId: string|number, status: string, sectionId?: number) : void { + updateStatusCache(status: string, courseId: number, component: string, componentId?: string|number, sectionId?: number) : void { let notify, packageId = this.filepoolProvider.getPackageId(component, componentId), cachedStatus = this.statusCache.getValue(packageId, 'status', true); @@ -1230,7 +1306,8 @@ export class CoreCourseModulePrefetchDelegate { this.statusCache.setValue(packageId, 'sectionId', sectionId); this.eventsProvider.trigger(CoreEventsProvider.SECTION_STATUS_CHANGED, { - sectionId: sectionId + sectionId: sectionId, + courseId: courseId }, this.sitesProvider.getCurrentSiteId()); } } else { diff --git a/src/providers/utils/dom.ts b/src/providers/utils/dom.ts index 73e377e93..3d91989fc 100644 --- a/src/providers/utils/dom.ts +++ b/src/providers/utils/dom.ts @@ -101,10 +101,11 @@ export class CoreDomUtilsProvider { * @param {string} [unknownMessage] ID of the message to show if size is unknown. * @param {number} [wifiThreshold] Threshold to show confirm in WiFi connection. Default: CoreWifiDownloadThreshold. * @param {number} [limitedThreshold] Threshold to show confirm in limited connection. Default: CoreDownloadThreshold. + * @param {boolean} [alwaysConfirm] True to show a confirm even if the size isn't high, false otherwise. * @return {Promise} Promise resolved when the user confirms or if no confirm needed. */ - confirmDownloadSize(size: any, message?: string, unknownMessage?: string, wifiThreshold?: number, limitedThreshold?: number) - : Promise { + confirmDownloadSize(size: any, message?: string, unknownMessage?: string, wifiThreshold?: number, limitedThreshold?: number, + alwaysConfirm?: boolean) : Promise { wifiThreshold = typeof wifiThreshold == 'undefined' ? CoreConstants.WIFI_DOWNLOAD_THRESHOLD : wifiThreshold; limitedThreshold = typeof limitedThreshold == 'undefined' ? CoreConstants.DOWNLOAD_THRESHOLD : limitedThreshold; @@ -120,6 +121,8 @@ export class CoreDomUtilsProvider { message = message || 'core.course.confirmdownload'; let readableSize = this.textUtils.bytesToSize(size.size, 2); return this.showConfirm(this.translate.instant(message, {size: readableSize})); + } else if (alwaysConfirm) { + return this.showConfirm(this.translate.instant('core.areyousure')); } return Promise.resolve(); }