diff --git a/.eslintrc.js b/.eslintrc.js index e0a5c3017..11a9fcf55 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -201,6 +201,7 @@ const appConfig = { 'no-duplicate-imports': 'error', 'no-empty': 'error', 'no-eval': 'error', + 'no-fallthrough': 'off', 'no-invalid-this': 'error', 'no-irregular-whitespace': 'error', 'no-multiple-empty-lines': 'error', diff --git a/src/addons/block/sitemainmenu/components/sitemainmenu/sitemainmenu.ts b/src/addons/block/sitemainmenu/components/sitemainmenu/sitemainmenu.ts index 5893d36cd..2c82d7576 100644 --- a/src/addons/block/sitemainmenu/components/sitemainmenu/sitemainmenu.ts +++ b/src/addons/block/sitemainmenu/components/sitemainmenu/sitemainmenu.ts @@ -17,7 +17,7 @@ import { CoreSites } from '@services/sites'; import { CoreCourse, CoreCourseSection } from '@features/course/services/course'; import { CoreCourseHelper } from '@features/course/services/course-helper'; import { CoreSiteHome, FrontPageItemNames } from '@features/sitehome/services/sitehome'; -// @todo import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; +import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; import { CoreBlockBaseComponent } from '@features/block/classes/base-block-component'; /** @@ -63,7 +63,7 @@ export class AddonBlockSiteMainMenuComponent extends CoreBlockBaseComponent impl if (this.mainMenuBlock && this.mainMenuBlock.modules) { // Invalidate modules prefetch data. - // @todo promises.push(this.prefetchDelegate.invalidateModules(this.mainMenuBlock.modules, this.siteHomeId)); + promises.push(CoreCourseModulePrefetchDelegate.instance.invalidateModules(this.mainMenuBlock.modules, this.siteHomeId)); } await Promise.all(promises); diff --git a/src/core/features/course/components/format/core-course-format.html b/src/core/features/course/components/format/core-course-format.html index 8f7fcbe87..54732cec4 100644 --- a/src/core/features/course/components/format/core-course-format.html +++ b/src/core/features/course/components/format/core-course-format.html @@ -144,7 +144,7 @@ + (statusChanged)="onModuleStatusChange()"> diff --git a/src/core/features/course/components/format/format.ts b/src/core/features/course/components/format/format.ts index 90b06f01f..83f073d9c 100644 --- a/src/core/features/course/components/format/format.ts +++ b/src/core/features/course/components/format/format.ts @@ -37,15 +37,13 @@ import { CoreCourseModuleData, CoreCourseProvider, } from '@features/course/services/course'; -// import { CoreCourseHelper } from '@features/course/services/course-helper'; +import { CoreCourseHelper, CoreCourseSectionFormatted, CoreCourseSectionWithStatus } from '@features/course/services/course-helper'; import { CoreCourseFormatDelegate } from '@features/course/services/format-delegate'; import { CoreEventObserver, CoreEvents, CoreEventSectionStatusChangedData, CoreEventSelectCourseTabData } from '@singletons/events'; import { IonContent, IonRefresher } from '@ionic/angular'; import { CoreUtils } from '@services/utils/utils'; -// import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; +import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; import { CoreBlockCourseBlocksComponent } from '@features/block/components/course-blocks/course-blocks'; -import { CoreCourseSectionFormatted } from '@features/course/services/course-helper'; -import { CoreCourseModuleStatusChangedData } from '../module/module'; import { ModalController } from '@singletons'; import { CoreCourseSectionSelectorComponent } from '../section-selector/section-selector'; @@ -62,13 +60,14 @@ import { CoreCourseSectionSelectorComponent } from '../section-selector/section- @Component({ selector: 'core-course-format', templateUrl: 'core-course-format.html', + styleUrls: ['format.scss'], }) export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { static readonly LOAD_MORE_ACTIVITIES = 20; // How many activities should load each time showMoreActivities is called. @Input() course?: CoreCourseAnyCourseData; // The course to render. - @Input() sections?: CoreCourseSectionFormatted[]; // List of course sections. + @Input() sections?: CoreCourseSectionWithStatus[]; // List of course sections. The status will be calculated in this component. @Input() downloadEnabled?: boolean; // Whether the download of sections and modules is enabled. @Input() initialSectionId?: number; // The section to load first (by ID). @Input() initialSectionNumber?: number; // The section to load first (by number). @@ -125,26 +124,26 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { return; } - // @todo Check if the affected section is being downloaded. + // 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. - // const downloadId = CoreCourseHelper.instance.getSectionDownloadId({ id: data.sectionId }); - // if (prefetchDelegate.isBeingDownloaded(downloadId)) { - // return; - // } + const downloadId = CoreCourseHelper.instance.getSectionDownloadId({ id: data.sectionId }); + if (CoreCourseModulePrefetchDelegate.instance.isBeingDownloaded(downloadId)) { + return; + } // Get the affected section. - // const section = this.sections.find(section => section.id == data.sectionId); - // if (!section) { - // return; - // } + const section = this.sections.find(section => section.id == data.sectionId); + if (!section) { + return; + } // Recalculate the status. - // await CoreCourseHelper.instance.calculateSectionStatus(section, this.course.id, false); + await CoreCourseHelper.instance.calculateSectionStatus(section, this.course.id, false); - // if (section.isDownloading && !prefetchDelegate.isBeingDownloaded(downloadId)) { - // // All the modules are now downloading, set a download all promise. - // this.prefetch(section); - // } + if (section.isDownloading && !CoreCourseModulePrefetchDelegate.instance.isBeingDownloaded(downloadId)) { + // All the modules are now downloading, set a download all promise. + this.prefetch(section); + } }, CoreSites.instance.getCurrentSiteId(), ); @@ -392,7 +391,9 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { this.canLoadMore = false; this.showSectionId = 0; this.showMoreActivities(); - // @todo CoreCourseHelper.instance.calculateSectionsStatus(this.sections, this.course.id, false, false); + if (this.downloadEnabled) { + this.calculateSectionsStatus(false); + } } if (this.moduleId && typeof previousValue == 'undefined') { @@ -427,11 +428,12 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { * * @param refresh If refresh or not. */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars protected calculateSectionsStatus(refresh?: boolean): void { - // @todo CoreCourseHelper.instance.calculateSectionsStatus(this.sections, this.course.id, refresh).catch(() => { - // // Ignore errors (shouldn't happen). - // }); + if (!this.sections || !this.course) { + return; + } + + CoreUtils.instance.ignoreErrors(CoreCourseHelper.instance.calculateSectionsStatus(this.sections, this.course.id, refresh)); } /** @@ -440,38 +442,42 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { * @param section Section to download. * @param refresh Refresh clicked (not used). */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - prefetch(section: CoreCourseSectionFormatted): void { - // section.isCalculating = true; - // @todo CoreCourseHelper.instance.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) { - // CoreDomUtils.instance.showErrorModal(error); - // } - // }).finally(() => { - // section.isCalculating = false; - // }); + async prefetch(section: CoreCourseSectionWithStatus): Promise { + section.isCalculating = true; + + try { + await CoreCourseHelper.instance.confirmDownloadSizeSection(this.course!.id, section, this.sections); + + await this.prefetchSection(section, true); + } catch (error) { + // User cancelled or there was an error calculating the size. + if (error) { + CoreDomUtils.instance.showErrorModal(error); + } + } finally { + section.isCalculating = false; + } } /** - * Prefetch a section. @todo + * Prefetch a section. * * @param section The section to download. * @param manual Whether the prefetch was started manually or it was automatically started because all modules * are being downloaded. */ - // protected prefetchSection(section: Section, manual?: boolean): void { - // CoreCourseHelper.instance.prefetchSection(section, this.course.id, this.sections).catch((error) => { - // // Don't show error message if it's an automatic download. - // if (!manual) { - // return; - // } + protected async prefetchSection(section: CoreCourseSectionWithStatus, manual?: boolean): Promise { + try { + await CoreCourseHelper.instance.prefetchSection(section, this.course!.id, this.sections); + } catch (error) { + // Don't show error message if it's an automatic download. + if (!manual) { + return; + } - // CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.errordownloadingsection', true); - // }); - // } + CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.errordownloadingsection', true); + } + } /** * Refresh the data. @@ -550,14 +556,16 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { component.callComponentFunction('ionViewDidEnter'); }); - // @todo if (this.downloadEnabled) { - // // The download status of a section might have been changed from within a module page. - // if (this.selectedSection && this.selectedSection.id !== CoreCourseProvider.ALL_SECTIONS_ID) { - // CoreCourseHelper.instance.calculateSectionStatus(this.selectedSection, this.course.id, false, false); - // } else { - // CoreCourseHelper.instance.calculateSectionsStatus(this.sections, this.course.id, false, false); - // } - // } + if (!this.downloadEnabled || !this.course || !this.sections) { + return; + } + + // The download status of a section might have been changed from within a module page. + if (this.selectedSection && this.selectedSection.id !== CoreCourseProvider.ALL_SECTIONS_ID) { + CoreCourseHelper.instance.calculateSectionStatus(this.selectedSection, this.course.id, false, false); + } else { + CoreCourseHelper.instance.calculateSectionsStatus(this.sections, this.course.id, false, false); + } } /** @@ -609,12 +617,13 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { /** * Recalculate the download status of each section, in response to a module being downloaded. - * - * @param eventData */ - // eslint-disable-next-line @typescript-eslint/no-unused-vars - onModuleStatusChange(eventData: CoreCourseModuleStatusChangedData): void { - // @todo CoreCourseHelper.instance.calculateSectionsStatus(this.sections, this.course.id, false, false); + onModuleStatusChange(): void { + if (!this.downloadEnabled || !this.sections || !this.course) { + return; + } + + CoreCourseHelper.instance.calculateSectionsStatus(this.sections, this.course.id, false, false); } } diff --git a/src/core/features/course/components/module/module.ts b/src/core/features/course/components/module/module.ts index f0ac61af0..3f42a7930 100644 --- a/src/core/features/course/components/module/module.ts +++ b/src/core/features/course/components/module/module.ts @@ -14,13 +14,20 @@ import { Component, Input, Output, EventEmitter, OnInit, OnDestroy } from '@angular/core'; -// import { CoreSites } from '@services/sites'; -// import { CoreDomUtils } from '@services/utils/dom'; -// import { CoreEventObserver, CoreEvents } from '@singletons/events'; -import { CoreCourseModuleDataFormatted, CoreCourseSectionFormatted } from '@features/course/services/course-helper'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreEventObserver, CoreEventPackageStatusChanged, CoreEvents } from '@singletons/events'; +import { + CoreCourseHelper, + CoreCourseModuleDataFormatted, + CoreCourseSectionFormatted, +} from '@features/course/services/course-helper'; import { CoreCourse, CoreCourseModuleCompletionData } from '@features/course/services/course'; import { CoreCourseModuleHandlerButton } from '@features/course/services/module-delegate'; -// import { CoreCourseModulePrefetchDelegate, CoreCourseModulePrefetchHandler } from '../../providers/module-prefetch-delegate'; +import { + CoreCourseModulePrefetchDelegate, + CoreCourseModulePrefetchHandler, +} from '@features/course/services/module-prefetch-delegate'; /** * Component to display a module entry in a list of modules. @@ -32,6 +39,7 @@ import { CoreCourseModuleHandlerButton } from '@features/course/services/module- @Component({ selector: 'core-course-module', templateUrl: 'core-course-module.html', + styleUrls: ['module.scss'], }) export class CoreCourseModuleComponent implements OnInit, OnDestroy { @@ -51,7 +59,7 @@ export class CoreCourseModuleComponent implements OnInit, OnDestroy { this.spinner = true; // Show spinner while calculating the status. // Get current status to decide which icon should be shown. - // @todo this.prefetchDelegate.getModuleStatus(this.module, this.courseId).then(this.showStatus.bind(this)); + this.calculateAndShowStatus(); }; @Output() completionChanged = new EventEmitter(); // Notify when module completion changes. @@ -63,8 +71,8 @@ export class CoreCourseModuleComponent implements OnInit, OnDestroy { downloadEnabled?: boolean; // Whether the download of sections and modules is enabled. modNameTranslated = ''; - // protected prefetchHandler: CoreCourseModulePrefetchHandler; - // protected statusObserver?: CoreEventObserver; + protected prefetchHandler?: CoreCourseModulePrefetchHandler; + protected statusObserver?: CoreEventObserver; protected statusCalculated = false; protected isDestroyed = false; @@ -86,26 +94,27 @@ export class CoreCourseModuleComponent implements OnInit, OnDestroy { this.module.handlerData.a11yTitle = this.module.handlerData.a11yTitle ?? this.module.handlerData.title; if (this.module.handlerData.showDownloadButton) { - // @todo Listen for changes on this module status, even if download isn't enabled. - // this.prefetchHandler = this.prefetchDelegate.getPrefetchHandlerFor(this.module); - // this.canCheckUpdates = this.prefetchDelegate.canCheckUpdates(); + // Listen for changes on this module status, even if download isn't enabled. + this.prefetchHandler = CoreCourseModulePrefetchDelegate.instance.getPrefetchHandlerFor(this.module); + this.canCheckUpdates = CoreCourseModulePrefetchDelegate.instance.canCheckUpdates(); - // this.statusObserver = this.eventsProvider.on(CoreEvents.PACKAGE_STATUS_CHANGED, (data) => { - // if (data.componentId === this.module.id && this.prefetchHandler && - // data.component === this.prefetchHandler.component) { + this.statusObserver = CoreEvents.on(CoreEvents.PACKAGE_STATUS_CHANGED, (data) => { + if (!this.module || data.componentId != this.module.id || !this.prefetchHandler || + data.component != this.prefetchHandler.component) { + return; + } - // // Call determineModuleStatus to get the right status to display. - // const status = this.prefetchDelegate.determineModuleStatus(this.module, data.status); + // Call determineModuleStatus to get the right status to display. + const status = CoreCourseModulePrefetchDelegate.instance.determineModuleStatus(this.module, data.status); - // if (this.downloadEnabled) { - // // Download is enabled, show the status. - // this.showStatus(status); - // } else if (this.module.handlerData.updateStatus) { - // // Download isn't enabled but the handler defines a updateStatus function, call it anyway. - // this.module.handlerData.updateStatus(status); - // } - // } - // }, this.sitesProvider.getCurrentSiteId()); + if (this.downloadEnabled) { + // Download is enabled, show the status. + this.showStatus(status); + } else if (this.module.handlerData?.updateStatus) { + // Download isn't enabled but the handler defines a updateStatus function, call it anyway. + this.module.handlerData.updateStatus(status); + } + }, CoreSites.instance.getCurrentSiteId()); } } @@ -138,36 +147,39 @@ export class CoreCourseModuleComponent implements OnInit, OnDestroy { } /** - * @todo Download the module. + * Download the module. * * @param refresh Whether it's refreshing. + * @return Promise resolved when done. */ - // download(refresh: boolean): void { - // if (!this.prefetchHandler) { - // return; - // } + async download(refresh: boolean): Promise { + if (!this.prefetchHandler || !this.module) { + return; + } - // // Show spinner since this operation might take a while. - // this.spinner = true; + // Show spinner since this operation might take a while. + this.spinner = true; - // // Get download size to ask for confirm if it's high. - // this.prefetchHandler.getDownloadSize(this.module, this.courseId, true).then((size) => { - // return this.courseHelper.prefetchModule(this.prefetchHandler, this.module, size, this.courseId, refresh); - // }).then(() => { - // const eventData = { - // sectionId: this.section.id, - // moduleId: this.module.id, - // courseId: this.courseId - // }; - // this.statusChanged.emit(eventData); - // }).catch((error) => { - // // Error, hide spinner. - // this.spinner = false; - // if (!this.isDestroyed) { - // this.domUtils.showErrorModalDefault(error, 'core.errordownloading', true); - // } - // }); - // } + try { + // Get download size to ask for confirm if it's high. + const size = await this.prefetchHandler.getDownloadSize(this.module, this.courseId!, true); + + await CoreCourseHelper.instance.prefetchModule(this.prefetchHandler, this.module, size, this.courseId!, refresh); + + const eventData = { + sectionId: this.section?.id, + moduleId: this.module.id, + courseId: this.courseId!, + }; + this.statusChanged.emit(eventData); + } catch (error) { + // Error, hide spinner. + this.spinner = false; + if (!this.isDestroyed) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.errordownloading', true); + } + } + } /** * Show download buttons according to module status. @@ -185,6 +197,21 @@ export class CoreCourseModuleComponent implements OnInit, OnDestroy { this.module?.handlerData?.updateStatus?.(status); } + /** + * Calculate and show module status. + * + * @return Promise resolved when done. + */ + protected async calculateAndShowStatus(): Promise { + if (!this.module || !this.courseId) { + return; + } + + const status = await CoreCourseModulePrefetchDelegate.instance.getModuleStatus(this.module, this.courseId); + + this.showStatus(status); + } + /** * Component destroyed. */ diff --git a/src/core/features/course/course.module.ts b/src/core/features/course/course.module.ts index 09a5ede90..e8bf37e7d 100644 --- a/src/core/features/course/course.module.ts +++ b/src/core/features/course/course.module.ts @@ -12,16 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { NgModule } from '@angular/core'; +import { APP_INITIALIZER, NgModule } from '@angular/core'; import { Routes } from '@angular/router'; import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module'; import { CORE_SITE_SCHEMAS } from '@services/sites'; import { CoreCourseComponentsModule } from './components/components.module'; +import { CoreCourseDirectivesModule } from './directives/directives.module'; import { CoreCourseFormatModule } from './format/formats.module'; import { SITE_SCHEMA, OFFLINE_SITE_SCHEMA } from './services/database/course'; import { SITE_SCHEMA as LOG_SITE_SCHEMA } from './services/database/log'; +import { SITE_SCHEMA as PREFETCH_SITE_SCHEMA } from './services/database/module-prefetch'; import { CoreCourseIndexRoutingModule } from './pages/index/index-routing.module'; +import { CoreCourseModulePrefetchDelegate } from './services/module-prefetch-delegate'; const routes: Routes = [ { @@ -43,14 +46,23 @@ const courseIndexRoutes: Routes = [ CoreMainMenuTabRoutingModule.forChild(routes), CoreCourseFormatModule, CoreCourseComponentsModule, + CoreCourseDirectivesModule, ], exports: [CoreCourseIndexRoutingModule], providers: [ { provide: CORE_SITE_SCHEMAS, - useValue: [SITE_SCHEMA, OFFLINE_SITE_SCHEMA, LOG_SITE_SCHEMA], + useValue: [SITE_SCHEMA, OFFLINE_SITE_SCHEMA, LOG_SITE_SCHEMA, PREFETCH_SITE_SCHEMA], multi: true, }, + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreCourseModulePrefetchDelegate.instance.initialize(); + }, + }, ], }) export class CoreCourseModule {} diff --git a/src/core/features/course/directives/directives.module.ts b/src/core/features/course/directives/directives.module.ts new file mode 100644 index 000000000..41dfe1b61 --- /dev/null +++ b/src/core/features/course/directives/directives.module.ts @@ -0,0 +1,28 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; + +import { CoreCourseDownloadModuleMainFileDirective } from './download-module-main-file'; + +@NgModule({ + declarations: [ + CoreCourseDownloadModuleMainFileDirective, + ], + imports: [], + exports: [ + CoreCourseDownloadModuleMainFileDirective, + ], +}) +export class CoreCourseDirectivesModule {} diff --git a/src/core/features/course/directives/download-module-main-file.ts b/src/core/features/course/directives/download-module-main-file.ts new file mode 100644 index 000000000..3d99485de --- /dev/null +++ b/src/core/features/course/directives/download-module-main-file.ts @@ -0,0 +1,85 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Directive, Input, OnInit, ElementRef } from '@angular/core'; + +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreCourse, CoreCourseModuleContentFile, CoreCourseWSModule } from '@features/course/services/course'; +import { CoreCourseHelper } from '@features/course/services/course-helper'; + +/** + * Directive to allow downloading and open the main file of a module. + * When the item with this directive is clicked, the module will be downloaded (if needed) and opened. + * This is meant for modules like mod_resource. + * + * This directive must receive either a module or a moduleId. If no files are provided, it will use module.contents. + */ +@Directive({ + selector: '[core-course-download-module-main-file]', +}) +export class CoreCourseDownloadModuleMainFileDirective implements OnInit { + + @Input() module?: CoreCourseWSModule; // The module. + @Input() moduleId?: string | number; // The module ID. Required if module is not supplied. + @Input() courseId?: string | number; // The course ID. + @Input() component?: string; // Component to link the file to. + @Input() componentId?: string | number; // Component ID to use in conjunction with the component. If not defined, use moduleId. + @Input() files?: CoreCourseModuleContentFile[]; // List of files of the module. If not provided, use module.contents. + + protected element: HTMLElement; + + constructor(element: ElementRef) { + this.element = element.nativeElement; + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.element.addEventListener('click', async (ev: Event) => { + if (!this.module && !this.moduleId) { + return; + } + + ev.preventDefault(); + ev.stopPropagation(); + + const modal = await CoreDomUtils.instance.showModalLoading(); + const courseId = typeof this.courseId == 'string' ? parseInt(this.courseId, 10) : this.courseId; + + try { + if (!this.module) { + // Try to get the module from cache. + this.moduleId = typeof this.moduleId == 'string' ? parseInt(this.moduleId, 10) : this.moduleId; + this.module = await CoreCourse.instance.getModule(this.moduleId!, courseId); + } + + const componentId = this.componentId || module.id; + + await CoreCourseHelper.instance.downloadModuleAndOpenFile( + this.module, + courseId ?? this.module.course!, + this.component, + componentId, + this.files, + ); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.errordownloading', true); + } finally { + modal.dismiss(); + } + }); + } + +} diff --git a/src/core/features/course/pages/contents/contents.ts b/src/core/features/course/pages/contents/contents.ts index 8fc4c86cc..a6c632816 100644 --- a/src/core/features/course/pages/contents/contents.ts +++ b/src/core/features/course/pages/contents/contents.ts @@ -28,20 +28,19 @@ import { } from '@features/course/services/course'; import { CoreCourseHelper, CoreCourseSectionFormatted, CorePrefetchStatusInfo } from '@features/course/services/course-helper'; import { CoreCourseFormatDelegate } from '@features/course/services/format-delegate'; -// import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; +import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; import { CoreCourseOptionsDelegate, CoreCourseOptionsMenuHandlerToDisplay, } from '@features/course/services/course-options-delegate'; // import { CoreCourseSyncProvider } from '../../providers/sync'; -// import { CoreCourseFormatComponent } from '../../components/format/format'; +import { CoreCourseFormatComponent } from '../../components/format/format'; import { CoreEvents, CoreEventObserver, CoreEventCourseStatusChanged, CoreEventCompletionModuleViewedData, } from '@singletons/events'; -import { Translate } from '@singletons'; import { CoreNavHelper } from '@services/nav-helper'; /** @@ -54,7 +53,7 @@ import { CoreNavHelper } from '@services/nav-helper'; export class CoreCourseContentsPage implements OnInit, OnDestroy { @ViewChild(IonContent) content?: IonContent; - // @ViewChild(CoreCourseFormatComponent) formatComponent: CoreCourseFormatComponent; + @ViewChild(CoreCourseFormatComponent) formatComponent?: CoreCourseFormatComponent; course!: CoreCourseAnyCourseData; sections?: CoreCourseSectionFormatted[]; @@ -244,9 +243,9 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy { if (refresh) { // Invalidate the recently downloaded module list. To ensure info can be prefetched. - // const modules = CoreCourse.instance.getSectionsModules(sections); + const modules = CoreCourse.instance.getSectionsModules(sections); - // @todo await this.prefetchDelegate.invalidateModules(modules, this.course.id); + await CoreCourseModulePrefetchDelegate.instance.invalidateModules(modules, this.course.id); } let completionStatus: Record = {}; @@ -279,14 +278,7 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy { if (CoreCourseFormatDelegate.instance.canViewAllSections(this.course)) { // Add a fake first section (all sections). - this.sections.unshift({ - id: CoreCourseProvider.ALL_SECTIONS_ID, - name: Translate.instance.instant('core.course.allsections'), - hasContent: true, - summary: '', - summaryformat: 1, - modules: [], - }); + this.sections.unshift(CoreCourseHelper.instance.createAllSectionsSection()); } // Get whether to show the refresher now that we have sections. @@ -345,8 +337,8 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy { } finally { // Do not call doRefresh on the format component if the refresher is defined in the format component // to prevent an inifinite loop. - if (this.displayRefresher) { - // @todo await CoreUtils.instance.ignoreErrors(this.formatComponent.doRefresh(refresher)); + if (this.displayRefresher && this.formatComponent) { + await CoreUtils.instance.ignoreErrors(this.formatComponent.doRefresh(refresher)); } refresher?.detail.complete(); @@ -384,7 +376,7 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy { promises.push(CoreCourseFormatDelegate.instance.invalidateData(this.course, this.sections || [])); if (this.sections) { - // @todo promises.push(this.prefetchDelegate.invalidateCourseUpdates(this.course.id)); + promises.push(CoreCourseModulePrefetchDelegate.instance.invalidateCourseUpdates(this.course.id)); } await Promise.all(promises); @@ -408,7 +400,7 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy { try { await this.loadData(true, sync); - // @todo await this.formatComponent.doRefresh(undefined, undefined, true); + await this.formatComponent?.doRefresh(undefined, undefined, true); } finally { this.dataLoaded = true; @@ -431,15 +423,15 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy { /** * Prefetch the whole course. */ - prefetchCourse(): void { + async prefetchCourse(): Promise { try { - // @todo await CoreCourseHelper.instance.confirmAndPrefetchCourse( - // this.prefetchCourseData, - // this.course, - // this.sections, - // this.courseHandlers, - // this.courseMenuHandlers, - // ); + await CoreCourseHelper.instance.confirmAndPrefetchCourse( + this.prefetchCourseData, + this.course, + this.sections, + undefined, + this.courseMenuHandlers, + ); } catch (error) { if (this.isDestroyed) { return; @@ -497,14 +489,14 @@ export class CoreCourseContentsPage implements OnInit, OnDestroy { * User entered the page. */ ionViewDidEnter(): void { - // @todo this.formatComponent?.ionViewDidEnter(); + this.formatComponent?.ionViewDidEnter(); } /** * User left the page. */ ionViewDidLeave(): void { - // @todo this.formatComponent?.ionViewDidLeave(); + this.formatComponent?.ionViewDidLeave(); } } diff --git a/src/core/features/course/services/course-helper.ts b/src/core/features/course/services/course-helper.ts index 1552ce651..fa67a14b3 100644 --- a/src/core/features/course/services/course-helper.ts +++ b/src/core/features/course/services/course-helper.ts @@ -14,18 +14,22 @@ import { Injectable } from '@angular/core'; import { Params } from '@angular/router'; +import moment from 'moment'; + import { CoreSites } from '@services/sites'; import { CoreCourse, CoreCourseCompletionActivityStatus, CoreCourseModuleCompletionData, + CoreCourseModuleContentFile, CoreCourseModuleData, + CoreCourseProvider, CoreCourseSection, } from './course'; import { CoreConstants } from '@/core/constants'; import { CoreLogger } from '@singletons/logger'; import { makeSingleton, Translate } from '@singletons'; -import { CoreFilepool } from '@services/filepool'; +import { CoreFilepool, CoreFilepoolComponentFileEventData } from '@services/filepool'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreUtils } from '@services/utils/utils'; import { @@ -43,6 +47,22 @@ import { CoreCourseOptionsMenuHandlerToDisplay, } from './course-options-delegate'; import { CoreCourseModuleDelegate, CoreCourseModuleHandlerData } from './module-delegate'; +import { CoreError } from '@classes/errors/error'; +import { + CoreCourseModulePrefetchDelegate, + CoreCourseModulePrefetchHandler, + CoreCourseModulesStatus, +} from './module-prefetch-delegate'; +import { CoreFileSizeSum } from '@services/plugin-file-delegate'; +import { CoreFileHelper } from '@services/file-helper'; +import { CoreApp } from '@services/app'; +import { CoreSite } from '@classes/site'; +import { CoreFile } from '@services/file'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreEventObserver, CoreEventPackageStatusChanged, CoreEvents } from '@singletons/events'; +import { CoreFilterHelper } from '@features/filter/services/filter-helper'; /** * Prefetch info of a module. @@ -110,6 +130,7 @@ export type CorePrefetchStatusInfo = { icon: string; // Icon based on the status. loading: boolean; // If it's a loading status. badge?: string; // Progress badge string if any. + downloadSucceeded?: boolean; // Whether download has succeeded (in case it's downloaded). }; /** @@ -207,8 +228,51 @@ export class CoreCourseHelperProvider { * @param checkUpdates Whether to use the WS to check updates. Defaults to true. * @return Promise resolved when the status is calculated. */ - calculateSectionStatus(): void { - // @todo params and logic + async calculateSectionStatus( + section: CoreCourseSectionFormatted, + courseId: number, + refresh?: boolean, + checkUpdates: boolean = true, + ): Promise<{statusData: CoreCourseModulesStatus; section: CoreCourseSectionWithStatus}> { + if (section.id == CoreCourseProvider.ALL_SECTIONS_ID) { + throw new CoreError('Invalid section'); + } + + const sectionWithStatus = section; + + // Get the status of this section. + const result = await CoreCourseModulePrefetchDelegate.instance.getModulesStatus( + section.modules, + courseId, + section.id, + refresh, + true, + checkUpdates, + ); + + // Check if it's being downloaded. + const downloadId = this.getSectionDownloadId(section); + if (CoreCourseModulePrefetchDelegate.instance.isBeingDownloaded(downloadId)) { + result.status = CoreConstants.DOWNLOADING; + } + + sectionWithStatus.downloadStatus = result.status; + sectionWithStatus.canCheckUpdates = CoreCourseModulePrefetchDelegate.instance.canCheckUpdates(); + + // Set this section data. + if (result.status !== CoreConstants.DOWNLOADING) { + sectionWithStatus.isDownloading = false; + sectionWithStatus.total = 0; + } else { + // Section is being downloaded. + sectionWithStatus.isDownloading = true; + CoreCourseModulePrefetchDelegate.instance.setOnProgress(downloadId, (data) => { + sectionWithStatus.count = data.count; + sectionWithStatus.total = data.total; + }); + } + + return { statusData: result, section: sectionWithStatus }; } /** @@ -220,8 +284,51 @@ export class CoreCourseHelperProvider { * @param checkUpdates Whether to use the WS to check updates. Defaults to true. * @return Promise resolved when the states are calculated. */ - calculateSectionsStatus(): void { - // @todo params and logic + async calculateSectionsStatus( + sections: CoreCourseSectionFormatted[], + courseId: number, + refresh?: boolean, + checkUpdates: boolean = true, + ): Promise { + let allSectionsSection: CoreCourseSectionWithStatus | undefined; + let allSectionsStatus = CoreConstants.NOT_DOWNLOADABLE; + + const promises = sections.map(async (section: CoreCourseSectionWithStatus) => { + section.isCalculating = true; + + if (section.id === CoreCourseProvider.ALL_SECTIONS_ID) { + // "All sections" section status is calculated using the status of the rest of sections. + allSectionsSection = section; + + return; + } + + try { + const result = await this.calculateSectionStatus(section, courseId, refresh, checkUpdates); + + // Calculate "All sections" status. + allSectionsStatus = CoreFilepool.instance.determinePackagesStatus(allSectionsStatus, result.statusData.status); + } finally { + section.isCalculating = false; + } + }); + + try { + await Promise.all(promises); + + if (allSectionsSection) { + // Set "All sections" data. + allSectionsSection.downloadStatus = allSectionsStatus; + allSectionsSection.canCheckUpdates = CoreCourseModulePrefetchDelegate.instance.canCheckUpdates(); + allSectionsSection.isDownloading = allSectionsStatus === CoreConstants.DOWNLOADING; + } + + return sections; + } finally { + if (allSectionsSection) { + allSectionsSection.isCalculating = false; + } + } } /** @@ -236,8 +343,50 @@ export class CoreCourseHelperProvider { * @param menuHandlers List of course menu handlers. * @return Promise resolved when the download finishes, rejected if an error occurs or the user cancels. */ - confirmAndPrefetchCourse(): void { - // @todo params and logic + async confirmAndPrefetchCourse( + data: CorePrefetchStatusInfo, + course: CoreCourseAnyCourseData, + sections?: CoreCourseSection[], + courseHandlers?: CoreCourseOptionsHandlerToDisplay[], + menuHandlers?: CoreCourseOptionsMenuHandlerToDisplay[], + ): Promise { + const initialIcon = data.icon; + const initialStatus = data.statusTranslatable; + const siteId = CoreSites.instance.getCurrentSiteId(); + + data.downloadSucceeded = false; + data.icon = 'spinner'; + data.statusTranslatable = 'core.downloading'; + + // Get the sections first if needed. + if (!sections) { + sections = await CoreCourse.instance.getSections(course.id, false, true); + } + + try { + // Confirm the download. + await this.confirmDownloadSizeSection(course.id, undefined, sections, true); + } catch (error) { + // User cancelled or there was an error calculating the size. + data.icon = initialIcon; + data.statusTranslatable = initialStatus; + + throw error; + } + + // User confirmed, get the course handlers if needed. + if (!courseHandlers) { + courseHandlers = await CoreCourseOptionsDelegate.instance.getHandlersToDisplay(course); + } + if (!menuHandlers) { + menuHandlers = await CoreCourseOptionsDelegate.instance.getMenuHandlersToDisplay(course); + } + + // Now we have all the data, download the course. + await this.prefetchCourse(course, sections, courseHandlers, menuHandlers, siteId); + + // Download successful. + data.downloadSucceeded = true; } /** @@ -313,7 +462,6 @@ export class CoreCourseHelperProvider { * @param courseId Course ID the module belongs to. * @param done Function to call when done. It will close the context menu. * @return Promise resolved when done. - * @todo module type. */ async confirmAndRemoveFiles(module: CoreCourseModuleData, courseId: number, done?: () => void): Promise { let modal: CoreIonLoadingElement | undefined; @@ -346,22 +494,106 @@ export class CoreCourseHelperProvider { * @param alwaysConfirm True to show a confirm even if the size isn't high, false otherwise. * @return Promise resolved if the user confirms or there's no need to confirm. */ - confirmDownloadSizeSection(): void { - // @todo params and logic + async confirmDownloadSizeSection( + courseId: number, + section?: CoreCourseSection, + sections?: CoreCourseSection[], + alwaysConfirm?: boolean, + ): Promise { + let hasEmbeddedFiles = false; + let sizeSum: CoreFileSizeSum = { + size: 0, + total: true, + }; + + if (!section && !sections) { + throw new CoreError('Either section or list of sections needs to be supplied.'); + } + + // Calculate the size of the download. + if (section && section.id != CoreCourseProvider.ALL_SECTIONS_ID) { + sizeSum = await CoreCourseModulePrefetchDelegate.instance.getDownloadSize(section.modules, courseId); + + // Check if the section has embedded files in the description. + hasEmbeddedFiles = CoreFilepool.instance.extractDownloadableFilesFromHtml(section.summary).length > 0; + } else { + await Promise.all(sections!.map(async (section) => { + if (section.id == CoreCourseProvider.ALL_SECTIONS_ID) { + return; + } + + const sectionSize = await CoreCourseModulePrefetchDelegate.instance.getDownloadSize(section.modules, courseId); + + sizeSum.total = sizeSum.total && sectionSize.total; + sizeSum.size += sectionSize.size; + + // Check if the section has embedded files in the description. + if (!hasEmbeddedFiles && CoreFilepool.instance.extractDownloadableFilesFromHtml(section.summary).length > 0) { + hasEmbeddedFiles = true; + } + })); + } + + if (hasEmbeddedFiles) { + sizeSum.total = false; + } + + // Show confirm modal if needed. + await CoreDomUtils.instance.confirmDownloadSize(sizeSum, undefined, undefined, undefined, undefined, alwaysConfirm); } /** * Helper function to prefetch a module, showing a confirmation modal if the size is big. * This function is meant to be called from a context menu option. It will also modify some data like the prefetch icon. * - * @param instance The component instance that has the context menu. It should have prefetchStatusIcon and isDestroyed. + * @param instance The component instance that has the context menu. * @param module Module to be prefetched * @param courseId Course ID the module belongs to. * @param done Function to call when done. It will close the context menu. * @return Promise resolved when done. */ - contextMenuPrefetch(): void { - // @todo params and logic + async contextMenuPrefetch( + instance: ComponentWithContextMenu, + module: CoreCourseModuleData, + courseId: number, + done?: () => void, + ): Promise { + const initialIcon = instance.prefetchStatusIcon; + instance.prefetchStatusIcon = 'spinner'; // Show spinner since this operation might take a while. + + try { + // We need to call getDownloadSize, the package might have been updated. + const size = await CoreCourseModulePrefetchDelegate.instance.getModuleDownloadSize(module, courseId, true); + + await CoreDomUtils.instance.confirmDownloadSize(size); + + await CoreCourseModulePrefetchDelegate.instance.prefetchModule(module, courseId, true); + + // Success, close menu. + done && done(); + } catch (error) { + instance.prefetchStatusIcon = initialIcon; + + if (!instance.isDestroyed) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.errordownloading', true); + } + } + } + + /** + * Create and return a section for "All sections". + * + * @return Created section. + */ + createAllSectionsSection(): CoreCourseSectionFormatted { + return { + id: CoreCourseProvider.ALL_SECTIONS_ID, + name: Translate.instance.instant('core.course.allsections'), + hasContent: true, + summary: '', + summaryformat: 1, + modules: [], + }; } /** @@ -403,8 +635,125 @@ export class CoreCourseHelperProvider { * @param siteId The site ID. If not defined, current site. * @return Resolved on success. */ - downloadModuleAndOpenFile(): void { - // @todo params and logic + async downloadModuleAndOpenFile( + module: CoreCourseModuleData, + courseId: number, + component?: string, + componentId?: string | number, + files?: CoreCourseModuleContentFile[], + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (!files || !files.length) { + // Make sure that module contents are loaded. + await CoreCourse.instance.loadModuleContents(module, courseId); + + files = module.contents; + } + + if (!files || !files.length) { + throw new CoreError(Translate.instance.instant('core.filenotfound')); + } + + if (!CoreFileHelper.instance.isOpenableInApp(module.contents[0])) { + await CoreFileHelper.instance.showConfirmOpenUnsupportedFile(); + } + + const site = await CoreSites.instance.getSite(siteId); + + const mainFile = files[0]; + + // Check if the file should be opened in browser. + if (CoreFileHelper.instance.shouldOpenInBrowser(mainFile)) { + return this.openModuleFileInBrowser(mainFile.fileurl, site, module, courseId, component, componentId, files); + } + + // File shouldn't be opened in browser. Download the module if it needs to be downloaded. + const result = await this.downloadModuleWithMainFileIfNeeded(module, courseId, component || '', componentId, files, siteId); + + if (CoreUrlUtils.instance.isLocalFileUrl(result.path)) { + return CoreUtils.instance.openFile(result.path); + } + + /* In iOS, if we use the same URL in embedded browser and background download then the download only + downloads a few bytes (cached ones). Add a hash to the URL so both URLs are different. */ + result.path = result.path + '#moodlemobile-embedded'; + + try { + await CoreUtils.instance.openOnlineFile(result.path); + } catch (error) { + // Error opening the file, some apps don't allow opening online files. + if (!CoreFile.instance.isAvailable()) { + throw error; + } else if (result.status === CoreConstants.DOWNLOADING) { + throw new CoreError(Translate.instance.instant('core.erroropenfiledownloading')); + } + + let path: string | undefined; + if (result.status === CoreConstants.NOT_DOWNLOADED) { + // Not downloaded, download it now and return the local file. + await this.downloadModule(module, courseId, component, componentId, files, siteId); + + path = await CoreFilepool.instance.getInternalUrlByUrl(siteId, mainFile.fileurl); + } else { + // File is outdated or stale and can't be opened in online, return the local URL. + path = await CoreFilepool.instance.getInternalUrlByUrl(siteId, mainFile.fileurl); + } + + await CoreUtils.instance.openFile(path); + } + } + + /** + * Convenience function to open a module main file in case it needs to be opened in browser. + * + * @param fileUrl URL of the main file. + * @param site Site instance. + * @param module The module to download. + * @param courseId The course ID of the module. + * @param component The component to link the files to. + * @param componentId An ID to use in conjunction with the component. + * @param files List of files of the module. If not provided, use module.contents. + * @return Resolved on success. + */ + protected async openModuleFileInBrowser( + fileUrl: string, + site: CoreSite, + module: CoreCourseModuleData, + courseId: number, + component?: string, + componentId?: string | number, + files?: CoreCourseModuleContentFile[], + ): Promise { + if (!CoreApp.instance.isOnline()) { + // Not online, get the offline file. It will fail if not found. + let path: string | undefined; + try { + path = await CoreFilepool.instance.getInternalUrlByUrl(site.getId(), fileUrl); + } catch { + throw new CoreError(Translate.instance.instant('core.networkerrormsg')); + } + + return CoreUtils.instance.openFile(path); + } + + // Open in browser. + let fixedUrl = await site.checkAndFixPluginfileURL(fileUrl); + + fixedUrl = fixedUrl.replace('&offline=1', ''); + // Remove forcedownload when followed by another param. + fixedUrl = fixedUrl.replace(/forcedownload=\d+&/, ''); + // Remove forcedownload when not followed by any param. + fixedUrl = fixedUrl.replace(/[?|&]forcedownload=\d+/, ''); + + CoreUtils.instance.openInBrowser(fixedUrl); + + if (CoreFile.instance.isAvailable()) { + // Download the file if needed (file outdated or not downloaded). + // Download will be in background, don't return the promise. + this.downloadModule(module, courseId, component, componentId, files, site.getId()); + } } /** @@ -419,8 +768,61 @@ export class CoreCourseHelperProvider { * @param siteId The site ID. If not defined, current site. * @return Promise resolved when done. */ - downloadModuleWithMainFileIfNeeded(): void { - // @todo params and logic + async downloadModuleWithMainFileIfNeeded( + module: CoreCourseModuleData, + courseId: number, + component: string, + componentId?: string | number, + files?: CoreCourseModuleContentFile[], + siteId?: string, + ): Promise<{ fixedUrl: string; path: string; status?: string }> { + + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (!files || !files.length) { + // Module not valid, stop. + throw new CoreError('File list not supplied.'); + } + + const mainFile = files[0]; + const site = await CoreSites.instance.getSite(siteId); + + const fixedUrl = await site.checkAndFixPluginfileURL(mainFile.fileurl); + + if (!CoreFile.instance.isAvailable()) { + return { + path: fixedUrl, // Use the online URL. + fixedUrl, + }; + } + + // The file system is available. + const status = await CoreFilepool.instance.getPackageStatus(siteId, component, componentId); + + let path = ''; + + if (status === CoreConstants.DOWNLOADING) { + // Use the online URL. + path = fixedUrl; + } else if (status === CoreConstants.DOWNLOADED) { + try { + // Get the local file URL. + path = await CoreFilepool.instance.getInternalUrlByUrl(siteId, mainFile.fileurl); + } catch (error){ + // File not found, mark the module as not downloaded. + await CoreFilepool.instance.storePackageStatus(siteId, CoreConstants.NOT_DOWNLOADED, component, componentId); + } + } + + if (!path) { + path = await this.downloadModuleWithMainFile(module, courseId, fixedUrl, files, status, component, componentId, siteId); + } + + return { + path, + fixedUrl, + status, + }; } /** @@ -437,8 +839,57 @@ export class CoreCourseHelperProvider { * @param siteId The site ID. If not defined, current site. * @return Promise resolved when done. */ - protected downloadModuleWithMainFile(): void { - // @todo params and logic + protected async downloadModuleWithMainFile( + module: CoreCourseModuleData, + courseId: number, + fixedUrl: string, + files: CoreCourseModuleContentFile[], + status: string, + component?: string, + componentId?: string | number, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const isOnline = CoreApp.instance.isOnline(); + const mainFile = files[0]; + const timemodified = mainFile.timemodified || 0; + + if (!isOnline && status === CoreConstants.NOT_DOWNLOADED) { + // Not downloaded and we're offline, reject. + throw new CoreError(Translate.instance.instant('core.networkerrormsg')); + } + + const shouldDownloadFirst = await CoreFilepool.instance.shouldDownloadFileBeforeOpen(fixedUrl, mainFile.filesize); + + if (shouldDownloadFirst) { + // Download and then return the local URL. + await this.downloadModule(module, courseId, component, componentId, files, siteId); + + return CoreFilepool.instance.getInternalUrlByUrl(siteId, mainFile.fileurl); + } + + // Start the download if in wifi, but return the URL right away so the file is opened. + if (CoreApp.instance.isWifi()) { + this.downloadModule(module, courseId, component, componentId, files, siteId); + } + + if (!CoreFileHelper.instance.isStateDownloaded(status) || isOnline) { + // Not downloaded or online, return the online URL. + return fixedUrl; + } else { + // Outdated but offline, so we return the local URL. Use getUrlByUrl so it's added to the queue. + return CoreFilepool.instance.getUrlByUrl( + siteId, + mainFile.fileurl, + component, + componentId, + timemodified, + false, + false, + mainFile, + ); + } } /** @@ -452,8 +903,31 @@ export class CoreCourseHelperProvider { * @param siteId The site ID. If not defined, current site. * @return Promise resolved when done. */ - downloadModule(): void { - // @todo params and logic + async downloadModule( + module: CoreCourseModuleData, + courseId: number, + component?: string, + componentId?: string | number, + files?: CoreCourseModuleContentFile[], + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const prefetchHandler = CoreCourseModulePrefetchDelegate.instance.getPrefetchHandlerFor(module); + + if (prefetchHandler) { + // Use the prefetch handler to download the module. + if (prefetchHandler.download) { + return await prefetchHandler.download(module, courseId); + } + + return await prefetchHandler.prefetch(module, courseId, true); + } + + // There's no prefetch handler for the module, just download the files. + files = files || module.contents; + + await CoreFilepool.instance.downloadOrPrefetchFiles(siteId, files, false, false, component, componentId); } /** @@ -466,8 +940,74 @@ export class CoreCourseHelperProvider { * @param component Component of the module. * @return Promise resolved when done. */ - fillContextMenu(): void { - // @todo params and logic + async fillContextMenu( + instance: ComponentWithContextMenu, + module: CoreCourseModuleData, + courseId: number, + invalidateCache?: boolean, + component?: string, + ): Promise { + const siteId = CoreSites.instance.getCurrentSiteId(); + + const moduleInfo = await this.getModulePrefetchInfo(module, courseId, invalidateCache, component); + + instance.size = moduleInfo.size && moduleInfo.size > 0 ? moduleInfo.sizeReadable! : ''; + instance.prefetchStatusIcon = moduleInfo.statusIcon; + instance.prefetchStatus = moduleInfo.status; + + if (moduleInfo.status != CoreConstants.NOT_DOWNLOADABLE) { + // Module is downloadable, get the text to display to prefetch. + if (moduleInfo.downloadTime && moduleInfo.downloadTime > 0) { + instance.prefetchText = Translate.instance.instant('core.lastdownloaded') + ': ' + moduleInfo.downloadTimeReadable; + } else { + // Module not downloaded, show a default text. + instance.prefetchText = Translate.instance.instant('core.download'); + } + } + + if (moduleInfo.status == CoreConstants.DOWNLOADING) { + // Set this to empty to prevent "remove file" option showing up while downloading. + instance.size = ''; + } + + if (!instance.contextMenuStatusObserver && component) { + instance.contextMenuStatusObserver = CoreEvents.on( + CoreEvents.PACKAGE_STATUS_CHANGED, + (data) => { + if (data.componentId == module.id && data.component == component) { + this.fillContextMenu(instance, module, courseId, false, component); + } + }, + siteId, + ); + } + + if (!instance.contextFileStatusObserver && component) { + // Debounce the update size function to prevent too many calls when downloading or deleting a whole activity. + const debouncedUpdateSize = CoreUtils.instance.debounce(async () => { + const moduleSize = await CoreCourseModulePrefetchDelegate.instance.getModuleStoredSize(module, courseId); + + instance.size = moduleSize > 0 ? CoreTextUtils.instance.bytesToSize(moduleSize, 2) : ''; + }, 1000); + + instance.contextFileStatusObserver = CoreEvents.on( + CoreEvents.COMPONENT_FILE_ACTION, + (data) => { + if (data.component != component || data.componentId != module.id) { + // The event doesn't belong to this component, ignore. + return; + } + + if (!CoreFilepool.instance.isFileEventDownloadedOrDeleted(data)) { + return; + } + + // Update the module size. + debouncedUpdateSize(); + }, + siteId, + ); + } } /** @@ -742,7 +1282,6 @@ export class CoreCourseHelperProvider { * @param module Name of the module. E.g. 'glossary'. * @param siteId Site ID. If not defined, current site. * @return Promise resolved with the module's course ID. - * @todo module type. */ async getModuleCourseIdByInstance(id: number, module: string, siteId?: string): Promise { try { @@ -765,8 +1304,68 @@ export class CoreCourseHelperProvider { * @param component Component of the module. * @return Promise resolved with the info. */ - getModulePrefetchInfo(): void { - // @todo params and logic + async getModulePrefetchInfo( + module: CoreCourseModuleData, + courseId: number, + invalidateCache?: boolean, + component?: string, + ): Promise { + const moduleInfo: CoreCourseModulePrefetchInfo = {}; + const siteId = CoreSites.instance.getCurrentSiteId(); + + if (invalidateCache) { + CoreCourseModulePrefetchDelegate.instance.invalidateModuleStatusCache(module); + } + + const results = await Promise.all([ + CoreCourseModulePrefetchDelegate.instance.getModuleStoredSize(module, courseId), + CoreCourseModulePrefetchDelegate.instance.getModuleStatus(module, courseId), + CoreUtils.instance.ignoreErrors(CoreFilepool.instance.getPackageData(siteId, component || '', module.id)), + ]); + + // Treat stored size. + moduleInfo.size = results[0]; + moduleInfo.sizeReadable = CoreTextUtils.instance.bytesToSize(results[0], 2); + + // Treat module status. + moduleInfo.status = results[1]; + switch (results[1]) { + case CoreConstants.NOT_DOWNLOADED: + moduleInfo.statusIcon = 'cloud-download'; + break; + case CoreConstants.DOWNLOADING: + moduleInfo.statusIcon = 'spinner'; + break; + case CoreConstants.OUTDATED: + moduleInfo.statusIcon = 'refresh'; + break; + case CoreConstants.DOWNLOADED: + if (!CoreCourseModulePrefetchDelegate.instance.canCheckUpdates()) { + moduleInfo.statusIcon = 'refresh'; + } + break; + default: + moduleInfo.statusIcon = ''; + break; + } + + // Treat download time. + if (!results[2] || !results[2].downloadTime || !CoreFileHelper.instance.isStateDownloaded(results[2].status || '')) { + // Not downloaded. + moduleInfo.downloadTime = 0; + + return moduleInfo; + } + + const now = CoreTimeUtils.instance.timestamp(); + moduleInfo.downloadTime = results[2].downloadTime; + if (now - results[2].downloadTime < 7 * 86400) { + moduleInfo.downloadTimeReadable = moment(results[2].downloadTime * 1000).fromNow(); + } else { + moduleInfo.downloadTimeReadable = moment(results[2].downloadTime * 1000).calendar(); + } + + return moduleInfo; } /** @@ -774,7 +1373,6 @@ export class CoreCourseHelperProvider { * * @param section Section. * @return Section download ID. - * @todo section type. */ getSectionDownloadId(section: {id: number}): string { return 'Section-' + section.id; @@ -858,7 +1456,7 @@ export class CoreCourseHelperProvider { * @return Promise resolved when the download finishes. */ async prefetchCourse( - course: CoreEnrolledCourseDataWithExtraInfoAndOptions, + course: CoreCourseAnyCourseData, sections: CoreCourseSection[], courseHandlers: CoreCourseOptionsHandlerToDisplay[], courseMenuHandlers: CoreCourseOptionsMenuHandlerToDisplay[], @@ -882,13 +1480,12 @@ export class CoreCourseHelperProvider { const promises: Promise[] = []; - /* @todo // Prefetch all the sections. If the first section is "All sections", use it. Otherwise, use a fake "All sections". - let allSectionsSection: Partial = sections[0]; + let allSectionsSection: CoreCourseSection = sections[0]; if (sections[0].id != CoreCourseProvider.ALL_SECTIONS_ID) { - allSectionsSection = { id: CoreCourseProvider.ALL_SECTIONS_ID }; + allSectionsSection = this.createAllSectionsSection(); } - promises.push(this.prefetchSection(allSectionsSection, course.id, sections));*/ + promises.push(this.prefetchSection(allSectionsSection, course.id, sections)); // Prefetch course options. courseHandlers.forEach((handler) => { @@ -912,7 +1509,7 @@ export class CoreCourseHelperProvider { promises.push(CoreCourse.instance.getActivitiesCompletionStatus(course.id)); } - // @todo promises.push(this.filterHelper.getFilters('course', course.id)); + promises.push(CoreFilterHelper.instance.getFilters('course', course.id)); await CoreUtils.instance.allPromises(promises); @@ -934,15 +1531,29 @@ export class CoreCourseHelperProvider { * Helper function to prefetch a module, showing a confirmation modal if the size is big * and invalidating contents if refreshing. * - * @param handler Prefetch handler to use. Must implement 'prefetch' and 'invalidateContent'. + * @param handler Prefetch handler to use. * @param module Module to download. - * @param size Object containing size to download (in bytes) and a boolean to indicate if its totally calculated. + * @param size Size to download. * @param courseId Course ID of the module. * @param refresh True if refreshing, false otherwise. * @return Promise resolved when downloaded. */ - prefetchModule(): void { - // @todo params and logic + async prefetchModule( + handler: CoreCourseModulePrefetchHandler, + module: CoreCourseModuleData, + size: CoreFileSizeSum, + courseId: number, + refresh?: boolean, + ): Promise { + // Show confirmation if needed. + await CoreDomUtils.instance.confirmDownloadSize(size); + + // Invalidate content if refreshing and download the data. + if (refresh) { + await CoreUtils.instance.ignoreErrors(handler.invalidateContent(module.id, courseId)); + } + + await CoreCourseModulePrefetchDelegate.instance.prefetchModule(module, courseId, true); } /** @@ -954,8 +1565,57 @@ export class CoreCourseHelperProvider { * @param sections List of sections. Used when downloading all the sections. * @return Promise resolved when the prefetch is finished. */ - async prefetchSection(): Promise { - // @todo params and logic + async prefetchSection( + section: CoreCourseSectionWithStatus, + courseId: number, + sections?: CoreCourseSectionWithStatus[], + ): Promise { + if (section.id != CoreCourseProvider.ALL_SECTIONS_ID) { + try { + // Download only this section. + await this.prefetchSingleSectionIfNeeded(section, courseId); + } finally { + // Calculate the status of the section that finished. + await this.calculateSectionStatus(section, courseId, false, false); + } + + return; + } + + if (!sections) { + throw new CoreError('List of sections is required when downloading all sections.'); + } + + // Download all the sections except "All sections". + let allSectionsStatus = CoreConstants.NOT_DOWNLOADABLE; + + section.isDownloading = true; + const promises = sections.map(async (section) => { + if (section.id == CoreCourseProvider.ALL_SECTIONS_ID) { + return; + } + + try { + await this.prefetchSingleSectionIfNeeded(section, courseId); + } finally { + // Calculate the status of the section that finished. + const result = await this.calculateSectionStatus(section, courseId, false, false); + + // Calculate "All sections" status. + allSectionsStatus = CoreFilepool.instance.determinePackagesStatus(allSectionsStatus, result.statusData.status); + } + }); + + try { + await CoreUtils.instance.allPromises(promises); + + // Set "All sections" data. + section.downloadStatus = allSectionsStatus; + section.canCheckUpdates = CoreCourseModulePrefetchDelegate.instance.canCheckUpdates(); + section.isDownloading = allSectionsStatus === CoreConstants.DOWNLOADING; + } finally { + section.isDownloading = false; + } } /** @@ -966,8 +1626,52 @@ export class CoreCourseHelperProvider { * @param courseId Course ID the section belongs to. * @return Promise resolved when the section is prefetched. */ - protected prefetchSingleSectionIfNeeded(): void { - // @todo params and logic + protected async prefetchSingleSectionIfNeeded(section: CoreCourseSectionWithStatus, courseId: number): Promise { + if (section.id == CoreCourseProvider.ALL_SECTIONS_ID || section.hiddenbynumsections) { + return; + } + + const promises: Promise[] = []; + const siteId = CoreSites.instance.getCurrentSiteId(); + + section.isDownloading = true; + + // Download the modules. + promises.push(this.syncModulesAndPrefetchSection(section, courseId)); + + // Download the files in the section description. + const introFiles = CoreFilepool.instance.extractDownloadableFilesFromHtmlAsFakeFileObjects(section.summary); + promises.push(CoreUtils.instance.ignoreErrors( + CoreFilepool.instance.addFilesToQueue(siteId, introFiles, CoreCourseProvider.COMPONENT, courseId), + )); + + try { + await Promise.all(promises); + } finally { + section.isDownloading = false; + } + } + + /** + * Sync modules in a section and prefetch them. + * + * @param section Section to prefetch. + * @param courseId Course ID the section belongs to. + * @return Promise resolved when the section is prefetched. + */ + protected async syncModulesAndPrefetchSection(section: CoreCourseSectionWithStatus, courseId: number): Promise { + // Sync the modules first. + await CoreCourseModulePrefetchDelegate.instance.syncModules(section.modules, courseId); + + // Validate the section needs to be downloaded and calculate amount of modules that need to be downloaded. + const result = await CoreCourseModulePrefetchDelegate.instance.getModulesStatus(section.modules, courseId, section.id); + + if (result.status == CoreConstants.DOWNLOADED || result.status == CoreConstants.NOT_DOWNLOADABLE) { + // Section is downloaded or not downloadable, nothing to do. + return ; + } + + await this.prefetchSingleSection(section, result, courseId); } /** @@ -979,8 +1683,32 @@ export class CoreCourseHelperProvider { * @param courseId Course ID the section belongs to. * @return Promise resolved when the section has been prefetched. */ - protected prefetchSingleSection(): void { - // @todo params and logic + protected async prefetchSingleSection( + section: CoreCourseSectionWithStatus, + result: CoreCourseModulesStatus, + courseId: number, + ): Promise { + if (section.id == CoreCourseProvider.ALL_SECTIONS_ID) { + return; + } + + if (section.total && section.total > 0) { + // Already being downloaded. + return ; + } + + // We only download modules with status notdownloaded, downloading or outdated. + const modules = result[CoreConstants.OUTDATED].concat(result[CoreConstants.NOT_DOWNLOADED]) + .concat(result[CoreConstants.DOWNLOADING]); + const downloadId = this.getSectionDownloadId(section); + + section.isDownloading = true; + + // Prefetch all modules to prevent incoeherences in download count and to download stale data not marked as outdated. + await CoreCourseModulePrefetchDelegate.instance.prefetchModules(downloadId, modules, courseId, (data) => { + section.count = data.count; + section.total = data.total; + }); } /** @@ -988,7 +1716,6 @@ export class CoreCourseHelperProvider { * * @param section Section to check. * @return Whether the section has content. - * @todo section type. */ sectionHasContent(section: CoreCourseSection): boolean { if (section.hiddenbynumsections) { @@ -1051,19 +1778,16 @@ export class CoreCourseHelperProvider { * @param courseId Course ID the module belongs to. * @return Promise resolved when done. */ - // @todo remove when done. - // eslint-disable-next-line @typescript-eslint/no-unused-vars async removeModuleStoredData(module: CoreCourseModuleData, courseId: number): Promise { const promises: Promise[] = []; - // @todo - // promises.push(this.prefetchDelegate.removeModuleFiles(module, courseId)); + promises.push(CoreCourseModulePrefetchDelegate.instance.removeModuleFiles(module, courseId)); - // @todo - // const handler = this.prefetchDelegate.getPrefetchHandlerFor(module); - // if (handler) { - // promises.push(CoreSites.instance.getCurrentSite().deleteComponentFromCache(handler.component, module.id)); - // } + const handler = CoreCourseModulePrefetchDelegate.instance.getPrefetchHandlerFor(module); + const site = CoreSites.instance.getCurrentSite(); + if (handler && site) { + promises.push(site.deleteComponentFromCache(handler.component, module.id)); + } await Promise.all(promises); } @@ -1080,6 +1804,18 @@ export type CoreCourseSectionFormatted = Omit & { modules: CoreCourseModuleDataFormatted[]; }; +/** + * Section with data about prefetch. + */ +export type CoreCourseSectionWithStatus = CoreCourseSectionFormatted & { + downloadStatus?: string; // Section status. + canCheckUpdates?: boolean; // Whether can check updates. + isDownloading?: boolean; // Whether section is being downloaded. + total?: number; // Total of modules being downloaded. + count?: number; // Number of downloaded modules. + isCalculating?: boolean; // Whether status is being calculated. +}; + /** * Module with calculated data. */ @@ -1099,3 +1835,13 @@ export type CoreCourseModuleCompletionDataFormatted = CoreCourseModuleCompletion cmid?: number; offline?: boolean; }; + +type ComponentWithContextMenu = { + prefetchStatusIcon?: string; + isDestroyed?: boolean; + size?: string; + prefetchStatus?: string; + prefetchText?: string; + contextMenuStatusObserver?: CoreEventObserver; + contextFileStatusObserver?: CoreEventObserver; +}; diff --git a/src/core/features/course/services/course-options-delegate.ts b/src/core/features/course/services/course-options-delegate.ts index 30d0bac44..cfb872ad3 100644 --- a/src/core/features/course/services/course-options-delegate.ts +++ b/src/core/features/course/services/course-options-delegate.ts @@ -11,7 +11,6 @@ // 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. -// @todo test delegate import { Injectable } from '@angular/core'; import { CoreDelegate, CoreDelegateHandler, CoreDelegateToDisplay } from '@classes/delegate'; @@ -178,7 +177,7 @@ export interface CoreCourseOptionsHandlerToDisplay extends CoreDelegateToDisplay * @param course The course. * @return Promise resolved when done. */ - prefetch?(course: CoreEnrolledCourseDataWithExtraInfoAndOptions): Promise; + prefetch?(course: CoreCourseAnyCourseData): Promise; } /** @@ -206,7 +205,7 @@ export interface CoreCourseOptionsMenuHandlerToDisplay { * @param course The course. * @return Promise resolved when done. */ - prefetch?(course: CoreEnrolledCourseDataWithExtraInfoAndOptions): Promise; + prefetch?(course: CoreCourseAnyCourseData): Promise; } /** diff --git a/src/core/features/course/services/course.ts b/src/core/features/course/services/course.ts index 1634572e0..6aff7c7d7 100644 --- a/src/core/features/course/services/course.ts +++ b/src/core/features/course/services/course.ts @@ -109,7 +109,6 @@ export class CoreCourseProvider { * * @param courseId Course ID. * @param completion Completion status of the module. - * @todo Add completion type. */ checkModuleCompletion(courseId: number, completion: CoreCourseModuleCompletionDataFormatted): void { if (completion && completion.tracking === 2 && completion.state === 0) { @@ -830,7 +829,7 @@ export class CoreCourseProvider { * @return Promise resolved when loaded. */ async loadModuleContents( - module: CoreCourseModuleData & CoreCourseModuleBasicInfo, + module: CoreCourseModuleData, courseId?: number, sectionId?: number, preferCache?: boolean, @@ -1412,14 +1411,13 @@ export type CoreCourseModuleContentFile = { filename: string; // Filename. filepath: string; // Filepath. filesize: number; // Filesize. - fileurl?: string; // Downloadable file url. - url?: string; // @deprecated. Use fileurl instead. + fileurl: string; // Downloadable file url. content?: string; // Raw content, will be used when type is content. timecreated: number; // Time created. timemodified: number; // Time modified. sortorder: number; // Content sort order. mimetype?: string; // File mime type. - isexternalfile?: boolean; // Whether is an external file. + isexternalfile?: number; // Whether is an external file. repositorytype?: string; // The repository type for external files. userid: number; // User who added this content to moodle. author: string; // Content owner. diff --git a/src/core/features/course/services/database/module-prefetch.ts b/src/core/features/course/services/database/module-prefetch.ts new file mode 100644 index 000000000..8b269cd4a --- /dev/null +++ b/src/core/features/course/services/database/module-prefetch.ts @@ -0,0 +1,46 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSiteSchema } from '@services/sites'; + +/** + * Database variables for CoreCourseModulePrefetchDelegate service. + */ +export const CHECK_UPDATES_TIMES_TABLE = 'check_updates_times'; +export const SITE_SCHEMA: CoreSiteSchema = { + name: 'CoreCourseModulePrefetchDelegate', + version: 1, + tables: [ + { + name: CHECK_UPDATES_TIMES_TABLE, + columns: [ + { + name: 'courseId', + type: 'INTEGER', + primaryKey: true, + }, + { + name: 'time', + type: 'INTEGER', + notNull: true, + }, + ], + }, + ], +}; + +export type CoreCourseCheckUpdatesDBRecord = { + courseId: number; + time: number; +}; diff --git a/src/core/features/course/services/module-prefetch-delegate.ts b/src/core/features/course/services/module-prefetch-delegate.ts new file mode 100644 index 000000000..b0a791fd2 --- /dev/null +++ b/src/core/features/course/services/module-prefetch-delegate.ts @@ -0,0 +1,1594 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { Subject, BehaviorSubject, Subscription } from 'rxjs'; +import { Md5 } from 'ts-md5/dist/md5'; + +import { CoreFile } from '@services/file'; +import { CoreFileHelper } from '@services/file-helper'; +import { CoreFilepool, CoreFilepoolComponentFileEventData } from '@services/filepool'; +import { CoreSites } from '@services/sites'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreCourse, CoreCourseModuleContentFile, CoreCourseModuleData } from './course'; +import { CoreCache } from '@classes/cache'; +import { CoreSiteWSPreSets } from '@classes/site'; +import { CoreConstants } from '@/core/constants'; +import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { makeSingleton } from '@singletons'; +import { CoreEventPackageStatusChanged, CoreEvents, CoreEventSectionStatusChangedData } from '@singletons/events'; +import { CoreError } from '@classes/errors/error'; +import { CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws'; +import { CHECK_UPDATES_TIMES_TABLE, CoreCourseCheckUpdatesDBRecord } from './database/module-prefetch'; +import { CoreFileSizeSum } from '@services/plugin-file-delegate'; + +const ROOT_CACHE_KEY = 'mmCourse:'; + +/** + * Delegate to register module prefetch handlers. + */ +@Injectable({ providedIn: 'root' }) +export class CoreCourseModulePrefetchDelegateService extends CoreDelegate { + + protected statusCache = new CoreCache(); + protected featurePrefix = 'CoreCourseModuleDelegate_'; + protected handlerNameProperty = 'modName'; + + // Promises for check updates, to prevent performing the same request twice at the same time. + protected courseUpdatesPromises: Record>> = {}; + + // Promises and observables for prefetching, to prevent downloading same section twice at the same time and notify progress. + protected prefetchData: Record> = {}; + + constructor() { + super('CoreCourseModulePrefetchDelegate', true); + } + + /** + * Initialize. + */ + initialize(): void { + CoreEvents.on(CoreEvents.LOGOUT, this.clearStatusCache.bind(this)); + + CoreEvents.on(CoreEvents.PACKAGE_STATUS_CHANGED, (data) => { + this.updateStatusCache(data.status, data.component, data.componentId); + }, CoreSites.instance.getCurrentSiteId()); + + // If a file inside a module is downloaded/deleted, clear the corresponding cache. + CoreEvents.on(CoreEvents.COMPONENT_FILE_ACTION, (data) => { + if (!CoreFilepool.instance.isFileEventDownloadedOrDeleted(data)) { + return; + } + + this.statusCache.invalidate(CoreFilepool.instance.getPackageId(data.component, data.componentId)); + }, CoreSites.instance.getCurrentSiteId()); + } + + /** + * Check if current site can check updates using core_course_check_updates. + * + * @return True if can check updates, false otherwise. + */ + canCheckUpdates(): boolean { + return CoreSites.instance.wsAvailableInCurrentSite('core_course_check_updates'); + } + + /** + * Check if a certain module can use core_course_check_updates. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Promise resolved with boolean: whether the module can use check updates WS. + */ + async canModuleUseCheckUpdates(module: CoreCourseModuleData, courseId: number): Promise { + const handler = this.getPrefetchHandlerFor(module); + + if (!handler) { + // Module not supported, cannot use check updates. + return false; + } + + if (handler.canUseCheckUpdates) { + return await handler.canUseCheckUpdates(module, courseId); + } + + // By default, modules can use check updates. + return true; + } + + /** + * Clear the status cache. + */ + clearStatusCache(): void { + this.statusCache.clear(); + } + + /** + * Creates the list of modules to check for get course updates. + * + * @param modules List of modules. + * @param courseId Course ID the modules belong to. + * @return Promise resolved with the lists. + */ + protected async createToCheckList(modules: CoreCourseModuleData[], courseId: number): Promise { + const result: ToCheckList = { + toCheck: [], + cannotUse: [], + }; + + const promises = modules.map(async (module) => { + try { + const data = await this.getModuleStatusAndDownloadTime(module, courseId); + if (data.status != CoreConstants.DOWNLOADED) { + return; + } + + // Module is downloaded and not outdated. Check if it can check updates. + const canUse = await this.canModuleUseCheckUpdates(module, courseId); + 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. + } + }); + + await Promise.all(promises); + + // Sort toCheck list. + result.toCheck.sort((a, b) => a.id >= b.id ? 1 : -1); + + return result; + } + + /** + * Determines a module status based on current status, restoring downloads if needed. + * + * @param module Module. + * @param status Current status. + * @param canCheck True if updates can be checked using core_course_check_updates. + * @return Module status. + */ + determineModuleStatus(module: CoreCourseModuleData, status: string, canCheck?: boolean): string { + const handler = this.getPrefetchHandlerFor(module); + const siteId = CoreSites.instance.getCurrentSiteId(); + + if (!handler) { + return status; + } + + if (status == CoreConstants.DOWNLOADING) { + // Check if the download is being handled. + if (!CoreFilepool.instance.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. + canCheck = canCheck ?? this.canCheckUpdates(); + + return handler.determineStatus(module, status, canCheck); + } + + return status; + } + + /** + * Download a module. + * + * @param module Module to download. + * @param courseId Course ID the module belongs to. + * @param dirPath Path of the directory where to store all the content files. + * @return Promise resolved when finished. + */ + async downloadModule(module: CoreCourseModuleData, courseId: number, dirPath?: string): Promise { + // Check if the module has a prefetch handler. + const handler = this.getPrefetchHandlerFor(module); + + if (!handler) { + return; + } + + await this.syncModule(module, courseId); + + await handler.download(module, courseId, dirPath); + } + + /** + * Check for updates in a course. + * + * @param modules List of modules. + * @param courseId Course ID the modules belong to. + * @return 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. + */ + async getCourseUpdates(modules: CoreCourseModuleData[], courseId: number): Promise { + if (!this.canCheckUpdates()) { + throw new CoreError('Cannot check course updates.'); + } + + // Check if there's already a getCourseUpdates in progress. + const id = Md5.hashAsciiStr(courseId + '#' + JSON.stringify(modules)); + const siteId = CoreSites.instance.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.fetchCourseUpdates(modules, courseId, siteId); + + try { + return await this.courseUpdatesPromises[siteId][id]; + } finally { + // Get updates finished, delete the promise. + delete this.courseUpdatesPromises[siteId][id]; + } + } + + /** + * Fetch updates in a course. + * + * @param modules List of modules. + * @param courseId Course ID the modules belong to. + * @param siteId Site ID. + * @return Promise resolved with the updates. If a module is set to false, it means updates cannot be + * checked for that module in the site. + */ + protected async fetchCourseUpdates( + modules: CoreCourseModuleData[], + courseId: number, + siteId: string, + ): Promise { + const data = await this.createToCheckList(modules, courseId); + const result: CourseUpdates = {}; + + // 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. + const site = await CoreSites.instance.getSite(siteId); + + const params: CoreCourseCheckUpdatesWSParams = { + courseid: courseId, + tocheck: data.toCheck, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCourseUpdatesCacheKey(courseId), + emergencyCache: false, // If downloaded data has changed and offline, just fail. See MOBILE-2085. + uniqueCacheKey: true, + splitRequest: { + param: 'tocheck', + maxLength: 10, + }, + }; + + try { + const response = await site.read('core_course_check_updates', params, preSets); + + // Store the last execution of the check updates call. + const entry: CoreCourseCheckUpdatesDBRecord = { + courseId: courseId, + time: CoreTimeUtils.instance.timestamp(), + }; + CoreUtils.instance.ignoreErrors(site.getDb().insertRecord(CHECK_UPDATES_TIMES_TABLE, entry)); + + return this.treatCheckUpdatesResult(data.toCheck, response, result); + } catch (error) { + // Cannot get updates. + // Get cached entries but discard modules with a download time higher than the last execution of check updates. + let entry: CoreCourseCheckUpdatesDBRecord | undefined; + try { + entry = await site.getDb().getRecord( + CHECK_UPDATES_TIMES_TABLE, + { courseId: courseId }, + ); + } catch { + // No previous executions, return result as it is. + return result; + } + + preSets.getCacheUsingCacheKey = true; + preSets.omitExpires = true; + + const response = await site.read('core_course_check_updates', params, preSets); + + return this.treatCheckUpdatesResult(data.toCheck, response, result, entry.time); + } + } + + /** + * Check for updates in a course. + * + * @param courseId Course ID the modules belong to. + * @return Promise resolved with the updates. + */ + async getCourseUpdatesByCourseId(courseId: number): Promise { + if (!this.canCheckUpdates()) { + throw new CoreError('Cannot check course updates.'); + } + + // Get course sections and all their modules. + const sections = await CoreCourse.instance.getSections(courseId, false, true, { omitExpires: true }); + + return this.getCourseUpdates(CoreCourse.instance.getSectionsModules(sections), courseId); + } + + /** + * Get cache key for course updates WS calls. + * + * @param courseId Course ID. + * @return Cache key. + */ + protected getCourseUpdatesCacheKey(courseId: number): string { + return ROOT_CACHE_KEY + 'courseUpdates:' + courseId; + } + + /** + * Get modules download size. Only treat the modules with status not downloaded or outdated. + * + * @param modules List of modules. + * @param courseId Course ID the modules belong to. + * @return Promise resolved with the size. + */ + async getDownloadSize(modules: CoreCourseModuleData[], courseId: number): Promise { + // Get the status of each module. + const data = await this.getModulesStatus(modules, courseId); + + const downloadableModules = data[CoreConstants.NOT_DOWNLOADED].concat(data[CoreConstants.OUTDATED]); + const result: CoreFileSizeSum = { + size: 0, + total: true, + }; + + await Promise.all(downloadableModules.map(async (module) => { + const size = await this.getModuleDownloadSize(module, courseId); + + result.total = result.total && size.total; + result.size += size.size; + })); + + return result; + } + + /** + * Get the download size of a module. + * + * @param module Module to get size. + * @param courseId Course ID the module belongs to. + * @param single True if we're downloading a single module, false if we're downloading a whole section. + * @return Promise resolved with the size. + */ + async getModuleDownloadSize(module: CoreCourseModuleData, courseId: number, single?: boolean): Promise { + const handler = this.getPrefetchHandlerFor(module); + + if (!handler) { + return { size: 0, total: false }; + } + const downloadable = await this.isModuleDownloadable(module, courseId); + if (!downloadable) { + return { size: 0, total: true }; + } + + const packageId = CoreFilepool.instance.getPackageId(handler.component, module.id); + const downloadSize = this.statusCache.getValue(packageId, 'downloadSize'); + if (typeof downloadSize != 'undefined') { + return downloadSize; + } + + try { + const size = await handler.getDownloadSize(module, courseId, single); + + return this.statusCache.setValue(packageId, 'downloadSize', size); + } catch (error) { + const cachedSize = this.statusCache.getValue(packageId, 'downloadSize', true); + if (cachedSize) { + return cachedSize; + } + + throw error; + } + } + + /** + * Get the download size of a module. + * + * @param module Module to get size. + * @param courseId Course ID the module belongs to. + * @return Promise resolved with the size. + */ + async getModuleDownloadedSize(module: CoreCourseModuleData, courseId: number): Promise { + const handler = this.getPrefetchHandlerFor(module); + if (!handler) { + return 0; + } + + const downloadable = await this.isModuleDownloadable(module, courseId); + if (!downloadable) { + return 0; + } + + const packageId = CoreFilepool.instance.getPackageId(handler.component, module.id); + const downloadedSize = this.statusCache.getValue(packageId, 'downloadedSize'); + if (typeof downloadedSize != 'undefined') { + return downloadedSize; + } + + try { + let size = 0; + + if (handler.getDownloadedSize) { + // Handler implements a method to calculate the downloaded size, use it. + size = await handler.getDownloadedSize(module, courseId); + } else { + // Handler doesn't implement it, get the module files and check if they're downloaded. + const files = await this.getModuleFiles(module, courseId); + + const siteId = CoreSites.instance.getCurrentSiteId(); + + // Retrieve file size if it's downloaded. + await Promise.all(files.map(async (file) => { + const path = await CoreFilepool.instance.getFilePathByUrl(siteId, file.fileurl || ''); + + try { + const fileSize = await CoreFile.instance.getFileSize(path); + + size += fileSize; + } catch { + // Error getting size. Check if the file is being downloaded. + try { + await CoreFilepool.instance.isFileDownloadingByUrl(siteId, file.fileurl || ''); + + // If downloading, count as downloaded. + size += file.filesize || 0; + } catch { + // Not downloading and not found in disk, don't add any size + } + } + })); + } + + return this.statusCache.setValue(packageId, 'downloadedSize', size); + } catch { + return this.statusCache.getValue(packageId, 'downloadedSize', true) || 0; + } + } + + /** + * Gets the estimated total size of data stored for a module. This includes + * the files downloaded for it (getModuleDownloadedSize) and also the total + * size of web service requests stored for it. + * + * @param module Module to get the size. + * @param courseId Course ID the module belongs to. + * @return Promise resolved with the total size (0 if unknown) + */ + async getModuleStoredSize(module: CoreCourseModuleData, courseId: number): Promise { + let downloadedSize = await this.getModuleDownloadedSize(module, courseId); + + if (isNaN(downloadedSize)) { + downloadedSize = 0; + } + + const site = CoreSites.instance.getCurrentSite(); + const handler = this.getPrefetchHandlerFor(module); + if (!handler || !site) { + // If there is no handler then we can't find out the component name. + // We can't work out the cached size, so just return downloaded size. + return downloadedSize; + } + + + const cachedSize = await site.getComponentCacheSize(handler.component, module.id); + + return cachedSize + downloadedSize; + } + + /** + * Get module files. + * + * @param module Module to get the files. + * @param courseId Course ID the module belongs to. + * @return Promise resolved with the list of files. + */ + async getModuleFiles( + module: CoreCourseModuleData, + courseId: number, + ): Promise<(CoreWSExternalFile | CoreCourseModuleContentFile)[]> { + const handler = this.getPrefetchHandlerFor(module); + + if (handler?.getFiles) { + // The handler defines a function to get files, use it. + return await handler.getFiles(module, courseId); + } else if (handler?.loadContents) { + // The handler defines a function to load contents, use it before returning module contents. + await handler.loadContents(module, courseId); + + return module.contents; + } else { + return module.contents || []; + } + } + + /** + * Get the module status. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @param 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 refresh True if it should ignore the cache. + * @param sectionId ID of the section the module belongs to. + * @return Promise resolved with the status. + */ + async getModuleStatus( + module: CoreCourseModuleData, + courseId: number, + updates?: CourseUpdates | false, + refresh?: boolean, + sectionId?: number, + ): Promise { + const handler = this.getPrefetchHandlerFor(module); + const canCheck = this.canCheckUpdates(); + + if (!handler) { + // No handler found, module not downloadable. + return CoreConstants.NOT_DOWNLOADABLE; + } + + // Check if the status is cached. + const packageId = CoreFilepool.instance.getPackageId(handler.component, module.id); + const status = this.statusCache.getValue(packageId, 'status'); + + if (!refresh && typeof status != 'undefined') { + this.storeCourseAndSection(packageId, courseId, sectionId); + + return this.determineModuleStatus(module, status, canCheck); + } + + const result = await this.calculateModuleStatus(handler, module, courseId, updates, sectionId); + if (result.updateStatus) { + this.updateStatusCache(result.status, handler.component, module.id, courseId, sectionId); + } + + return this.determineModuleStatus(module, result.status, canCheck); + } + + /** + * Calculate a module status. + * + * @param handler Prefetch handler. + * @param module Module. + * @param courseId Course ID the module belongs to. + * @param 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 sectionId ID of the section the module belongs to. + * @return Promise resolved with the status. + */ + protected async calculateModuleStatus( + handler: CoreCourseModulePrefetchHandler, + module: CoreCourseModuleData, + courseId: number, + updates?: CourseUpdates | false, + sectionId?: number, + ): Promise<{status: string; updateStatus: boolean}> { + // Check if the module is downloadable. + const downloadable = await this.isModuleDownloadable(module, courseId); + if (!downloadable) { + return { + status: CoreConstants.NOT_DOWNLOADABLE, + updateStatus: true, + }; + } + + // Get the saved package status. + const siteId = CoreSites.instance.getCurrentSiteId(); + const canCheck = this.canCheckUpdates(); + const currentStatus = await CoreFilepool.instance.getPackageStatus(siteId, handler.component, module.id); + + let status = handler.determineStatus ? handler.determineStatus(module, currentStatus, canCheck) : currentStatus; + if (status != CoreConstants.DOWNLOADED || updates === false) { + return { + status, + updateStatus: true, + }; + } + + // Module is downloaded. Determine if there are updated in the module to show them outdated. + if (typeof updates == 'undefined') { + try { + // We don't have course updates, calculate them. + updates = await this.getCourseUpdatesByCourseId(courseId); + } catch { + // Error getting updates, show the stored status. + const packageId = CoreFilepool.instance.getPackageId(handler.component, module.id); + this.storeCourseAndSection(packageId, courseId, sectionId); + + return { + status: currentStatus, + updateStatus: false, + }; + } + } + + if (!updates || updates[module.id] === false) { + // Cannot check updates, always show outdated. + return { + status: CoreConstants.OUTDATED, + updateStatus: true, + }; + } + + try { + // Check if the module has any update. + const hasUpdates = await this.moduleHasUpdates(module, courseId, updates); + + if (!hasUpdates) { + // No updates, keep current status. + return { + status, + updateStatus: true, + }; + } + + // Has updates, mark the module as outdated. + status = CoreConstants.OUTDATED; + + await CoreUtils.instance.ignoreErrors( + CoreFilepool.instance.storePackageStatus(siteId, status, handler.component, module.id), + ); + + return { + status, + updateStatus: true, + }; + } catch { + // Error checking if module has updates. + const packageId = CoreFilepool.instance.getPackageId(handler.component, module.id); + const status = this.statusCache.getValue(packageId, 'status', true); + + return { + status: this.determineModuleStatus(module, status || CoreConstants.NOT_DOWNLOADED, canCheck), + updateStatus: true, + }; + } + } + + /** + * Get the status of a list of modules, along with the lists of modules for each status. + * + * @param modules List of modules to prefetch. + * @param courseId Course ID the modules belong to. + * @param sectionId ID of the section the modules belong to. + * @param refresh True if it should always check the DB (slower). + * @param onlyToDisplay True if the status will only be used to determine which button should be displayed. + * @param checkUpdates Whether to use the WS to check updates. Defaults to true. + * @return Promise resolved with the data. + */ + async getModulesStatus( + modules: CoreCourseModuleData[], + courseId: number, + sectionId?: number, + refresh?: boolean, + onlyToDisplay?: boolean, + checkUpdates: boolean = true, + ): Promise { + + let updates: CourseUpdates | false = false; + const result: CoreCourseModulesStatus = { + total: 0, + status: CoreConstants.NOT_DOWNLOADABLE, + [CoreConstants.NOT_DOWNLOADED]: [], + [CoreConstants.DOWNLOADED]: [], + [CoreConstants.DOWNLOADING]: [], + [CoreConstants.OUTDATED]: [], + }; + + if (checkUpdates) { + // Check updates in course. Don't use getCourseUpdates because the list of modules might not be the whole course list. + try { + updates = await this.getCourseUpdatesByCourseId(courseId); + } catch { + // Cannot get updates. + } + } + + await Promise.all(modules.map(async (module) => { + const handler = this.getPrefetchHandlerFor(module); + if (!handler || onlyToDisplay || handler.skipListStatus) { + return; + } + + try { + const modStatus = await this.getModuleStatus(module, courseId, updates, refresh); + + if (!result[modStatus]) { + return; + } + + result.status = CoreFilepool.instance.determinePackagesStatus(status, modStatus); + result[modStatus].push(module); + result.total++; + } catch (error) { + const packageId = CoreFilepool.instance.getPackageId(handler.component, module.id); + const cacheStatus = this.statusCache.getValue(packageId, 'status', true); + if (typeof cacheStatus == 'undefined') { + throw error; + } + + if (!result[cacheStatus]) { + return; + } + + result.status = CoreFilepool.instance.determinePackagesStatus(status, cacheStatus); + result[cacheStatus].push(module); + result.total++; + } + })); + + return result; + } + + /** + * Get a module status and download time. It will only return the download time if the module is downloaded or outdated. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Promise resolved with the data. + */ + protected async getModuleStatusAndDownloadTime( + module: CoreCourseModuleData, + courseId: number, + ): Promise<{ status: string; downloadTime?: number }> { + const handler = this.getPrefetchHandlerFor(module); + const siteId = CoreSites.instance.getCurrentSiteId(); + + if (!handler) { + // No handler found, module not downloadable. + return { status: CoreConstants.NOT_DOWNLOADABLE }; + } + + // Get the status from the cache. + const packageId = CoreFilepool.instance.getPackageId(handler.component, module.id); + const status = this.statusCache.getValue(packageId, 'status'); + + if (typeof status != 'undefined' && !CoreFileHelper.instance.isStateDownloaded(status)) { + // Module isn't downloaded, just return the status. + return { status }; + } + + // Check if the module is downloadable. + const downloadable = await this.isModuleDownloadable(module, courseId); + if (!downloadable) { + return { status: CoreConstants.NOT_DOWNLOADABLE }; + } + + // Get the stored data to get the status and downloadTime. + const data = await CoreFilepool.instance.getPackageData(siteId, handler.component, module.id); + + return { + status: data.status || CoreConstants.NOT_DOWNLOADED, + downloadTime: data.downloadTime || 0, + }; + } + + /** + * Get updates for a certain module. + * It will only return the updates if the module can use check updates and it's downloaded or outdated. + * + * @param module Module to check. + * @param courseId Course the module belongs to. + * @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down). + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the updates. + */ + async getModuleUpdates( + module: CoreCourseModuleData, + courseId: number, + ignoreCache?: boolean, + siteId?: string, + ): Promise { + + const site = await CoreSites.instance.getSite(siteId); + + const data = await this.getModuleStatusAndDownloadTime(module, courseId); + if (!CoreFileHelper.instance.isStateDownloaded(data.status)) { + // Not downloaded, no updates. + return null; + } + + // Module is downloaded. Check if it can check updates. + const canUse = await this.canModuleUseCheckUpdates(module, courseId); + if (!canUse) { + // Can't use check updates, no updates. + return null; + } + + const params: CoreCourseCheckUpdatesWSParams = { + courseid: courseId, + tocheck: [ + { + contextlevel: 'module', + id: module.id, + since: data.downloadTime || 0, + }, + ], + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getModuleUpdatesCacheKey(courseId, module.id), + }; + + if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + const response = await site.read('core_course_check_updates', params, preSets); + if (!response.instances[0]) { + throw new CoreError('Could not get module updates.'); + } + + return response.instances[0]; + } + + /** + * Get cache key for module updates WS calls. + * + * @param courseId Course ID. + * @param moduleId Module ID. + * @return Cache key. + */ + protected getModuleUpdatesCacheKey(courseId: number, moduleId: number): string { + return this.getCourseUpdatesCacheKey(courseId) + ':' + moduleId; + } + + /** + * Get a prefetch handler. + * + * @param module The module to work on. + * @return Prefetch handler. + */ + getPrefetchHandlerFor(module: CoreCourseModuleData): CoreCourseModulePrefetchHandler | undefined { + return this.getHandler(module.modname, true); + } + + /** + * Invalidate check updates WS call. + * + * @param courseId Course ID. + * @return Promise resolved when data is invalidated. + */ + async invalidateCourseUpdates(courseId: number): Promise { + const site = CoreSites.instance.getCurrentSite(); + if (!site) { + return; + } + + await site.invalidateWsCacheForKey(this.getCourseUpdatesCacheKey(courseId)); + } + + /** + * Invalidate a list of modules in a course. This should only invalidate WS calls, not downloaded files. + * + * @param modules List of modules. + * @param courseId Course ID. + * @return Promise resolved when modules are invalidated. + */ + async invalidateModules(modules: CoreCourseModuleData[], courseId: number): Promise { + + const promises = modules.map(async (module) => { + const handler = this.getPrefetchHandlerFor(module); + if (!handler) { + return; + } + + if (handler.invalidateModule) { + await CoreUtils.instance.ignoreErrors(handler.invalidateModule(module, courseId)); + } + + // Invalidate cache. + this.invalidateModuleStatusCache(module); + }); + + promises.push(this.invalidateCourseUpdates(courseId)); + + await Promise.all(promises); + } + + /** + * Invalidates the cache for a given module. + * + * @param module Module to be invalidated. + */ + invalidateModuleStatusCache(module: CoreCourseModuleData): void { + const handler = this.getPrefetchHandlerFor(module); + if (handler) { + this.statusCache.invalidate(CoreFilepool.instance.getPackageId(handler.component, module.id)); + } + } + + /** + * Invalidate check updates WS call for a certain module. + * + * @param courseId Course ID. + * @param moduleId Module ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when data is invalidated. + */ + async invalidateModuleUpdates(courseId: number, moduleId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getModuleUpdatesCacheKey(courseId, moduleId)); + } + + /** + * Check if a list of modules is being downloaded. + * + * @param id An ID to identify the download. + * @return True if it's being downloaded, false otherwise. + */ + isBeingDownloaded(id: string): boolean { + const siteId = CoreSites.instance.getCurrentSiteId(); + + return !!(this.prefetchData[siteId]?.[id]); + } + + /** + * Check if a module is downloadable. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Promise resolved with true if downloadable, false otherwise. + */ + async isModuleDownloadable(module: CoreCourseModuleData, courseId: number): Promise { + if (module.uservisible === false) { + // Module isn't visible by the user, cannot be downloaded. + return false; + } + + const handler = this.getPrefetchHandlerFor(module); + if (!handler) { + return false; + } + + if (!handler.isDownloadable) { + // Function not defined, assume it's downloadable. + return true; + } + + const packageId = CoreFilepool.instance.getPackageId(handler.component, module.id); + let downloadable = this.statusCache.getValue(packageId, 'downloadable'); + + if (typeof downloadable != 'undefined') { + return downloadable; + } + + try { + downloadable = await handler.isDownloadable(module, courseId); + + return this.statusCache.setValue(packageId, 'downloadable', downloadable); + } catch { + // Something went wrong, assume it's not downloadable. + return false; + } + } + + /** + * Check if a module has updates based on the result of getCourseUpdates. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @param updates Result of getCourseUpdates. + * @return Promise resolved with boolean: whether the module has updates. + */ + async moduleHasUpdates(module: CoreCourseModuleData, courseId: number, updates: CourseUpdates): Promise { + const handler = this.getPrefetchHandlerFor(module); + const moduleUpdates = updates[module.id]; + + if (handler?.hasUpdates) { + // Handler implements its own function to check the updates, use it. + return await handler.hasUpdates(module, courseId, moduleUpdates); + } else if (!moduleUpdates || !moduleUpdates.updates || !moduleUpdates.updates.length) { + // Module doesn't have any update. + return false; + } else if (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 true; + } + } + + return false; + } + + // Handler doesn't define hasUpdates or updatesNames and there is at least 1 update. Assume it has updates. + return true; + } + + /** + * Prefetch a module. + * + * @param module Module to prefetch. + * @param courseId Course ID the module belongs to. + * @param single True if we're downloading a single module, false if we're downloading a whole section. + * @return Promise resolved when finished. + */ + async prefetchModule(module: CoreCourseModuleData, courseId: number, single?: boolean): Promise { + const handler = this.getPrefetchHandlerFor(module); + if (!handler) { + return; + } + + await this.syncModule(module, courseId); + + await handler.prefetch(module, courseId, single); + } + + /** + * Sync a group of modules. + * + * @param modules Array of modules to sync. + * @param courseId Course ID the module belongs to. + * @return Promise resolved when finished. + */ + syncModules(modules: CoreCourseModuleData[], courseId: number): Promise { + return Promise.all(modules.map(async (module) => { + await this.syncModule(module, courseId); + + // Invalidate course updates. + await CoreUtils.instance.ignoreErrors(this.invalidateCourseUpdates(courseId)); + })); + } + + /** + * Sync a module. + * + * @param module Module to sync. + * @param courseId Course ID the module belongs to. + * @return Promise resolved when finished. + */ + async syncModule(module: CoreCourseModuleData, courseId: number): Promise { + const handler = this.getPrefetchHandlerFor(module); + if (!handler?.sync) { + return; + } + + const result = await CoreUtils.instance.ignoreErrors(handler.sync(module, courseId)); + + // Always invalidate status cache for this module. We cannot know if data was sent to server or not. + this.invalidateModuleStatusCache(module); + + return result; + } + + /** + * Prefetches a list of modules using their prefetch handlers. + * If a prefetch already exists for this site and id, returns the current promise. + * + * @param id An ID to identify the download. It can be used to retrieve the download promise. + * @param modules List of modules to prefetch. + * @param courseId Course ID the modules belong to. + * @param onProgress Function to call everytime a module is downloaded. + * @return Promise resolved when all modules have been prefetched. + */ + async prefetchModules( + id: string, + modules: CoreCourseModuleData[], + courseId: number, + onProgress?: CoreCourseModulesProgressFunction, + ): Promise { + + const siteId = CoreSites.instance.getCurrentSiteId(); + const currentPrefetchData = this.prefetchData[siteId]?.[id]; + + if (currentPrefetchData) { + // There's a prefetch ongoing, return the current promise. + if (onProgress) { + currentPrefetchData.subscriptions.push(currentPrefetchData.observable.subscribe(onProgress)); + } + + return currentPrefetchData.promise; + } + + let count = 0; + const total = modules.length; + const moduleIds = modules.map((module) => module.id); + const prefetchData: OngoingPrefetch = { + observable: new BehaviorSubject({ count: count, total: total }), + promise: Promise.resolve(), + subscriptions: [], + }; + + if (onProgress) { + prefetchData.observable.subscribe(onProgress); + } + + const promises = modules.map(async (module) => { + // Check if the module has a prefetch handler. + const handler = this.getPrefetchHandlerFor(module); + if (!handler) { + return; + } + + const downloadable = await this.isModuleDownloadable(module, courseId); + if (!downloadable) { + return; + } + + await handler.prefetch(module, courseId); + + const index = moduleIds.indexOf(module.id); + if (index > -1) { + moduleIds.splice(index, 1); + count++; + prefetchData.observable.next({ count: count, total: total }); + } + }); + + // Set the promise. + prefetchData.promise = CoreUtils.instance.allPromises(promises); + + // Store the prefetch data in the list. + this.prefetchData[siteId] = this.prefetchData[siteId] || {}; + this.prefetchData[siteId][id] = prefetchData; + + try { + await prefetchData.promise; + } finally { + // Unsubscribe all observers. + prefetchData.subscriptions.forEach((subscription) => { + subscription.unsubscribe(); + }); + delete this.prefetchData[siteId][id]; + } + } + + /** + * Remove module Files from handler. + * + * @param module Module to remove the files. + * @param courseId Course ID the module belongs to. + * @return Promise resolved when done. + */ + async removeModuleFiles(module: CoreCourseModuleData, courseId: number): Promise { + const handler = this.getPrefetchHandlerFor(module); + const siteId = CoreSites.instance.getCurrentSiteId(); + + if (handler?.removeFiles) { + // Handler implements a method to remove the files, use it. + await handler.removeFiles(module, courseId); + } else { + // No method to remove files, use get files to try to remove the files. + const files = await this.getModuleFiles(module, courseId); + + await Promise.all(files.map(async (file) => { + await CoreUtils.instance.ignoreErrors(CoreFilepool.instance.removeFileByUrl(siteId, file.fileurl || '')); + })); + } + + if (!handler) { + return; + } + + // Update downloaded size. + const packageId = CoreFilepool.instance.getPackageId(handler.component, module.id); + this.statusCache.setValue(packageId, 'downloadedSize', 0); + + // If module is downloadable, set not dowloaded status. + const downloadable = await this.isModuleDownloadable(module, courseId); + if (!downloadable) { + return; + } + + await CoreFilepool.instance.storePackageStatus(siteId, CoreConstants.NOT_DOWNLOADED, handler.component, module.id); + } + + /** + * Set an on progress function for the download of a list of modules. + * + * @param id An ID to identify the download. + * @param onProgress Function to call everytime a module is downloaded. + */ + setOnProgress(id: string, onProgress: CoreCourseModulesProgressFunction): void { + const currentData = this.prefetchData[CoreSites.instance.getCurrentSiteId()]?.[id]; + + if (currentData) { + // There's a prefetch ongoing, return the current promise. + currentData.subscriptions.push(currentData.observable.subscribe(onProgress)); + } + } + + /** + * If courseId or sectionId is set, save them in the cache. + * + * @param packageId The package ID. + * @param courseId Course ID. + * @param sectionId Section ID. + */ + storeCourseAndSection(packageId: string, courseId?: number, sectionId?: number): void { + if (courseId) { + this.statusCache.setValue(packageId, 'courseId', courseId); + } + if (sectionId && sectionId > 0) { + this.statusCache.setValue(packageId, 'sectionId', sectionId); + } + } + + /** + * Treat the result of the check updates WS call. + * + * @param toCheckList List of modules to check (from createToCheckList). + * @param response WS call response. + * @param result Object where to store the result. + * @param previousTime Time of the previous check updates execution. If set, modules downloaded + * after this time will be ignored. + * @return Result. + */ + protected treatCheckUpdatesResult( + toCheckList: CheckUpdatesToCheckWSParam[], + response: CoreCourseCheckUpdatesWSResponse, + result: CourseUpdates, + previousTime?: number, + ): CourseUpdates { + // Format the response to index it by module ID. + CoreUtils.instance.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 status of a module in the "cache". + * + * @param status New status. + * @param component Package's component. + * @param componentId An ID to use in conjunction with the component. + * @param courseId Course ID of the module. + * @param sectionId Section ID of the module. + */ + updateStatusCache( + status: string, + component: string, + componentId?: string | number, + courseId?: number, + sectionId?: number, + ): void { + const packageId = CoreFilepool.instance.getPackageId(component, componentId); + const cachedStatus = this.statusCache.getValue(packageId, 'status', true); + + // If courseId/sectionId is set, store it. + this.storeCourseAndSection(packageId, courseId, sectionId); + + if (cachedStatus === undefined || cachedStatus === status) { + this.statusCache.setValue(packageId, 'status', status); + + return; + } + + // The status has changed, notify that the section has changed. + courseId = courseId || this.statusCache.getValue(packageId, 'courseId', true); + sectionId = sectionId || this.statusCache.getValue(packageId, 'sectionId', true); + + // Invalidate and set again. + this.statusCache.invalidate(packageId); + this.statusCache.setValue(packageId, 'status', status); + + if (sectionId) { + const data: CoreEventSectionStatusChangedData = { + sectionId, + courseId: courseId!, + }; + CoreEvents.trigger(CoreEvents.SECTION_STATUS_CHANGED, data, CoreSites.instance.getCurrentSiteId()); + } + } + +} + +export class CoreCourseModulePrefetchDelegate extends makeSingleton(CoreCourseModulePrefetchDelegateService) {} + +/** + * Progress of downloading a list of modules. + */ +export type CoreCourseModulesProgress = { + /** + * Number of modules downloaded so far. + */ + count: number; + + /** + * Toal of modules to download. + */ + total: number; +}; + +/** + * Progress function for downloading a list of modules. + * + * @param data Progress data. + */ +export type CoreCourseModulesProgressFunction = (data: CoreCourseModulesProgress) => void; + +/** + * Interface that all course prefetch handlers must implement. + */ +export interface CoreCourseModulePrefetchHandler extends CoreDelegateHandler { + /** + * Name of the handler. + */ + name: string; + + /** + * Name of the module. It should match the "modname" of the module returned in core_course_get_contents. + */ + modName: string; + + /** + * The handler's component. + */ + 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. + */ + updatesNames?: RegExp; + + /** + * If true, this module will be treated as not downloadable when determining the status of a list of modules. The module will + * still be downloaded when downloading the section/course, it only affects whether the button should be displayed. + */ + skipListStatus: boolean; + + /** + * Get the download size of a module. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @param single True if we're downloading a single module, false if we're downloading a whole section. + * @return Promise resolved with the size. + */ + getDownloadSize(module: CoreCourseModuleData, courseId: number, single?: boolean): Promise; + + /** + * Prefetch a module. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @param single True if we're downloading a single module, false if we're downloading a whole section. + * @param dirPath Path of the directory where to store all the content files. + * @return Promise resolved when done. + */ + prefetch(module: CoreCourseModuleData, courseId?: number, single?: boolean, dirPath?: string): Promise; + + /** + * Download the module. + * + * @param module The module object returned by WS. + * @param courseId Course ID. + * @param dirPath Path of the directory where to store all the content files. + * @return Promise resolved when all content is downloaded. + */ + download(module: CoreCourseModuleData, courseId: number, dirPath?: string): Promise; + + /** + * Invalidate the prefetched content. + * + * @param moduleId The module ID. + * @param courseId Course ID the module belongs to. + * @return Promise resolved when the data is invalidated. + */ + invalidateContent(moduleId: number, courseId: number): 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 module Module. + * @param courseId Course ID the module belongs to. + * @return Whether the module can use check_updates. The promise should never be rejected. + */ + canUseCheckUpdates?(module: CoreCourseModuleData, courseId: number): 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 module Module. + * @param status The current status. + * @param canCheck Whether the site allows checking for updates. + * @return Status to display. + */ + determineStatus?(module: CoreCourseModuleData, 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 module Module. + * @param courseId Course ID the module belongs to. + * @return Size, or promise resolved with the size. + */ + getDownloadedSize?(module: CoreCourseModuleData, courseId: number): Promise; + + /** + * Get the list of files of the module. If not defined, we'll assume they are in module.contents. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return List of files, or promise resolved with the files. + */ + getFiles?(module: CoreCourseModuleData, courseId: number): Promise<(CoreWSExternalFile | CoreCourseModuleContentFile)[]>; + + /** + * Check if a certain module has updates based on the result of check updates. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @param moduleUpdates List of updates for the module. + * @return Whether the module has updates. The promise should never be rejected. + */ + hasUpdates?(module: CoreCourseModuleData, courseId: number, moduleUpdates: false | CheckUpdatesWSInstance): Promise; + + /** + * Invalidate WS calls needed to determine module status (usually, to check if module is downloadable). + * It doesn't need to invalidate check updates. It should NOT invalidate files nor all the prefetched data. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Promise resolved when invalidated. + */ + invalidateModule?(module: CoreCourseModuleData, courseId: number): Promise; + + /** + * Check if a module can be downloaded. If the function is not defined, we assume that all modules are downloadable. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Whether the module can be downloaded. The promise should never be rejected. + */ + isDownloadable?(module: CoreCourseModuleData, courseId: number): Promise; + + /** + * Load module contents in module.contents if they aren't loaded already. This is meant for resources. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Promise resolved when done. + */ + loadContents?(module: CoreCourseModuleData, courseId: number): Promise; + + /** + * Remove module downloaded files. If not defined, we'll use getFiles to remove them (slow). + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Promise resolved when done. + */ + removeFiles?(module: CoreCourseModuleData, courseId: number): Promise; + + /** + * Sync a module. + * + * @param module Module. + * @param courseId Course ID the module belongs to + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + sync?(module: CoreCourseModuleData, courseId: number, siteId?: string): Promise; +} + +type ToCheckList = { + toCheck: CheckUpdatesToCheckWSParam[]; + cannotUse: CoreCourseModuleData[]; +}; + +/** + * Course updates. + */ +type CourseUpdates = Record; + +/** + * Status data about a list of modules. + */ +export type CoreCourseModulesStatus = { + total: number; // Number of modules. + status: string; // Status of the list of modules. + [CoreConstants.NOT_DOWNLOADED]: CoreCourseModuleData[]; // Modules with state NOT_DOWNLOADED. + [CoreConstants.DOWNLOADED]: CoreCourseModuleData[]; // Modules with state DOWNLOADED. + [CoreConstants.DOWNLOADING]: CoreCourseModuleData[]; // Modules with state DOWNLOADING. + [CoreConstants.OUTDATED]: CoreCourseModuleData[]; // Modules with state OUTDATED. +}; + +/** + * Data for an ongoing module prefetch. + */ +type OngoingPrefetch = { + promise: Promise; // Prefetch promise. + observable: Subject; // Observable to notify the download progress. + subscriptions: Subscription[]; // Subscriptions that are currently listening the progress. +}; + +/** + * Params of core_course_check_updates WS. + */ +export type CoreCourseCheckUpdatesWSParams = { + courseid: number; // Course id to check. + tocheck: CheckUpdatesToCheckWSParam[]; // Instances to check. + filter?: string[]; // Check only for updates in these areas. +}; + +/** + * Data to send in tocheck parameter. + */ +type CheckUpdatesToCheckWSParam = { + contextlevel: string; // The context level for the file location. Only module supported right now. + id: number; // Context instance id. + since: number; // Check updates since this time stamp. +}; + +/** + * Data returned by core_course_check_updates WS. + */ +export type CoreCourseCheckUpdatesWSResponse = { + instances: CheckUpdatesWSInstance[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Instance data returned by the WS. + */ +type CheckUpdatesWSInstance = { + contextlevel: string; // The context level. + id: number; // Instance id. + updates: { + name: string; // Name of the area updated. + timeupdated?: number; // Last time was updated. + itemids?: number[]; // The ids of the items updated. + }[]; +}; diff --git a/src/core/features/sitehome/pages/index/index.ts b/src/core/features/sitehome/pages/index/index.ts index 6c9f5f626..548f77d87 100644 --- a/src/core/features/sitehome/pages/index/index.ts +++ b/src/core/features/sitehome/pages/index/index.ts @@ -26,6 +26,7 @@ import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreCourseHelper } from '@features/course/services/course-helper'; import { CoreBlockCourseBlocksComponent } from '@features/block/components/course-blocks/course-blocks'; import { CoreCourseModuleDelegate, CoreCourseModuleHandlerData } from '@features/course/services/module-delegate'; +import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; /** * Page that displays site home index. @@ -59,7 +60,6 @@ export class CoreSiteHomeIndexPage implements OnInit, OnDestroy { constructor( protected route: ActivatedRoute, protected navCtrl: NavController, - // @todo private prefetchDelegate: CoreCourseModulePrefetchDelegate, ) {} /** @@ -86,8 +86,8 @@ export class CoreSiteHomeIndexPage implements OnInit, OnDestroy { const module = navParams['module']; if (module) { - // @todo const modParams = navParams.get('modParams'); - // CoreCourseHelper.instance.openModule(module, this.siteHomeId, undefined, modParams); + const modParams = navParams['modParams']; + CoreCourseHelper.instance.openModule(module, this.siteHomeId, undefined, modParams); } this.loadContent().finally(() => { @@ -174,7 +174,7 @@ export class CoreSiteHomeIndexPage implements OnInit, OnDestroy { if (this.section && this.section.modules) { // Invalidate modules prefetch data. - // @todo promises.push(this.prefetchDelegate.invalidateModules(this.section.modules, this.siteHomeId)); + promises.push(CoreCourseModulePrefetchDelegate.instance.invalidateModules(this.section.modules, this.siteHomeId)); } if (this.courseBlocksComponent) { diff --git a/src/core/services/file-helper.ts b/src/core/services/file-helper.ts index f494b2433..1623b80ac 100644 --- a/src/core/services/file-helper.ts +++ b/src/core/services/file-helper.ts @@ -160,40 +160,39 @@ export class CoreFileHelperProvider { onProgress({ calculating: true }); } - try { - await CoreFilepool.instance.shouldDownloadBeforeOpen(fixedUrl, file.filesize || 0); - } catch (error) { - // Start the download if in wifi, but return the URL right away so the file is opened. - if (isWifi) { - this.downloadFile(fileUrl, component, componentId, timemodified, onProgress, file, siteId); - } - - if (!this.isStateDownloaded(state) || isOnline) { - // Not downloaded or online, return the online URL. + const shouldDownloadFirst = await CoreFilepool.instance.shouldDownloadFileBeforeOpen(fixedUrl, file.filesize || 0); + if (shouldDownloadFirst) { + // Download the file first. + if (state == CoreConstants.DOWNLOADING) { + // It's already downloading, stop. return fixedUrl; - } else { - // Outdated but offline, so we return the local URL. - return CoreFilepool.instance.getUrlByUrl( - siteId, - fileUrl, - component, - componentId, - timemodified, - false, - false, - file, - ); } + + // Download and then return the local URL. + return this.downloadFile(fileUrl, component, componentId, timemodified, onProgress, file, siteId); } - // Download the file first. - if (state == CoreConstants.DOWNLOADING) { - // It's already downloading, stop. + // Start the download if in wifi, but return the URL right away so the file is opened. + if (isWifi) { + this.downloadFile(fileUrl, component, componentId, timemodified, onProgress, file, siteId); + } + + if (!this.isStateDownloaded(state) || isOnline) { + // Not downloaded or online, return the online URL. return fixedUrl; + } else { + // Outdated but offline, so we return the local URL. + return CoreFilepool.instance.getUrlByUrl( + siteId, + fileUrl, + component, + componentId, + timemodified, + false, + false, + file, + ); } - - // Download and then return the local URL. - return this.downloadFile(fileUrl, component, componentId, timemodified, onProgress, file, siteId); } } diff --git a/src/core/services/filepool.ts b/src/core/services/filepool.ts index 974225eed..aa2518302 100644 --- a/src/core/services/filepool.ts +++ b/src/core/services/filepool.ts @@ -2763,13 +2763,7 @@ export class CoreFilepoolProvider { * @param url File online URL. * @param size File size. * @return Promise resolved if should download before open, rejected otherwise. - * @description - * Convenience function to check if a file should be downloaded before opening it. - * - * The default behaviour in the app is to download first and then open the local file in the following cases: - * - The file is small (less than DOWNLOAD_THRESHOLD). - * - The file cannot be streamed. - * If the file is big and can be streamed, the promise returned by this function will be rejected. + * @ddeprecated since 3.9.5. Please use shouldDownloadFileBeforeOpen instead. */ async shouldDownloadBeforeOpen(url: string, size: number): Promise { if (size >= 0 && size <= CoreFilepoolProvider.DOWNLOAD_THRESHOLD) { @@ -2784,6 +2778,32 @@ export class CoreFilepoolProvider { } } + /** + * Convenience function to check if a file should be downloaded before opening it. + * + * @param url File online URL. + * @param size File size. + * @return Promise resolved with boolean: whether file should be downloaded before opening it. + * @description + * Convenience function to check if a file should be downloaded before opening it. + * + * The default behaviour in the app is to download first and then open the local file in the following cases: + * - The file is small (less than DOWNLOAD_THRESHOLD). + * - The file cannot be streamed. + * If the file is big and can be streamed, the promise returned by this function will be rejected. + */ + async shouldDownloadFileBeforeOpen(url: string, size: number): Promise { + if (size >= 0 && size <= CoreFilepoolProvider.DOWNLOAD_THRESHOLD) { + // The file is small, download it. + return true; + } + + const mimetype = await CoreUtils.instance.getMimeTypeFromUrl(url); + + // If the file is streaming (audio or video), return false. + return mimetype.indexOf('video') == -1 && mimetype.indexOf('audio') == -1; + } + /** * Store package status. * diff --git a/src/core/services/utils/utils.ts b/src/core/services/utils/utils.ts index 261b5b92c..4aa3d14f4 100644 --- a/src/core/services/utils/utils.ts +++ b/src/core/services/utils/utils.ts @@ -114,7 +114,7 @@ export class CoreUtilsProvider { * @param result Object where to put the properties. If not defined, a new object will be created. * @return The object. */ - arrayToObject | string>( + arrayToObject( array: T[], propertyName?: string, result: Record = {},