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 = {},