// (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 { CoreConstants } from '@/core/constants'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, OnDestroy, OnInit } from '@angular/core'; import { CoreCourse, CoreCourseProvider } from '@features/course/services/course'; import { CoreCourseHelper, CoreCourseModuleData, CoreCourseSectionWithStatus, CorePrefetchStatusInfo, } from '@features/course/services/course-helper'; import { CoreCourseModulePrefetchDelegate, CoreCourseModulePrefetchHandler } from '@features/course/services/module-prefetch-delegate'; import { CoreCourses } from '@features/courses/services/courses'; import { CoreNavigator } from '@services/navigator'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreUtils } from '@services/utils/utils'; import { Translate } from '@singletons'; import { CoreDom } from '@singletons/dom'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; /** * Page that displays the amount of file storage used by each activity on the course, and allows * the user to prefecth and delete this data. */ @Component({ selector: 'page-addon-storagemanager-course-storage', templateUrl: 'course-storage.html', styleUrls: ['course-storage.scss'], changeDetection: ChangeDetectionStrategy.OnPush, }) export class AddonStorageManagerCourseStoragePage implements OnInit, OnDestroy { courseId!: number; title = ''; loaded = false; sections: AddonStorageManagerCourseSection[] = []; totalSize = 0; calculatingSize = true; downloadEnabled = false; downloadCourseEnabled = false; prefetchCourseData: CorePrefetchStatusInfo = { icon: CoreConstants.ICON_LOADING, statusTranslatable: 'core.course.downloadcourse', status: '', loading: true, }; statusDownloaded = CoreConstants.DOWNLOADED; protected initialSectionId?: number; protected siteUpdatedObserver?: CoreEventObserver; protected courseStatusObserver?: CoreEventObserver; protected sectionStatusObserver?: CoreEventObserver; protected moduleStatusObserver?: CoreEventObserver; protected isDestroyed = false; protected isGuest = false; constructor(protected elementRef: ElementRef, protected changeDetectorRef: ChangeDetectorRef) { // Refresh the enabled flags if site is updated. this.siteUpdatedObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => { this.downloadCourseEnabled = !CoreCourses.isDownloadCourseDisabledInSite(); this.downloadEnabled = !CoreSites.getRequiredCurrentSite().isOfflineDisabled(); this.initCoursePrefetch(); this.initModulePrefetch(); this.changeDetectorRef.markForCheck(); }, CoreSites.getCurrentSiteId()); } /** * @inheritdoc */ async ngOnInit(): Promise { try { this.courseId = CoreNavigator.getRequiredRouteParam('courseId'); } catch (error) { CoreDomUtils.showErrorModal(error); CoreNavigator.back(); return; } this.title = CoreNavigator.getRouteParam('title') || ''; if (!this.title && this.courseId == CoreSites.getCurrentSiteHomeId()) { this.title = Translate.instant('core.sitehome.sitehome'); } this.isGuest = !!CoreNavigator.getRouteBooleanParam('isGuest'); this.initialSectionId = CoreNavigator.getRouteNumberParam('sectionId'); this.downloadCourseEnabled = !CoreCourses.isDownloadCourseDisabledInSite(); this.downloadEnabled = !CoreSites.getRequiredCurrentSite().isOfflineDisabled(); const sections = (await CoreCourse.getSections(this.courseId, false, true)) .filter((section) => !CoreCourseHelper.isSectionStealth(section)); this.sections = (await CoreCourseHelper.addHandlerDataForModules(sections, this.courseId)).sections .map(section => ({ ...section, totalSize: 0, calculatingSize: true, expanded: section.id === this.initialSectionId, modules: section.modules.map(module => ({ ...module, calculatingSize: true, })), })); this.loaded = true; CoreDom.scrollToElement( this.elementRef.nativeElement, '.core-course-storage-section-expanded', { addYAxis: -10 }, ); await Promise.all([ this.initSizes(), this.initCoursePrefetch(), this.initModulePrefetch(), ]); this.changeDetectorRef.markForCheck(); } /** * Init course prefetch information. * * @returns Promise resolved when done. */ protected async initCoursePrefetch(): Promise { if (!this.downloadCourseEnabled || this.courseStatusObserver) { return; } // Listen for changes in course status. this.courseStatusObserver = CoreEvents.on(CoreEvents.COURSE_STATUS_CHANGED, (data) => { if (data.courseId == this.courseId || data.courseId == CoreCourseProvider.ALL_COURSES_CLEARED) { this.updateCourseStatus(data.status); } }, CoreSites.getCurrentSiteId()); // Determine the course prefetch status. await this.determineCoursePrefetchIcon(); if (this.prefetchCourseData.icon != CoreConstants.ICON_LOADING) { return; } // Course is being downloaded. Get the download promise. const promise = CoreCourseHelper.getCourseDownloadPromise(this.courseId); if (promise) { // There is a download promise. Show an error if it fails. promise.catch((error) => { if (!this.isDestroyed) { CoreDomUtils.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true); } }); } else { // No download, this probably means that the app was closed while downloading. Set previous status. const status = await CoreCourse.setCoursePreviousStatus(this.courseId); this.updateCourseStatus(status); } } /** * Init module prefetch information. * * @returns Promise resolved when done. */ protected async initModulePrefetch(): Promise { if (!this.downloadEnabled || this.sectionStatusObserver) { return; } // Listen for section status changes. this.sectionStatusObserver = CoreEvents.on( CoreEvents.SECTION_STATUS_CHANGED, async (data) => { if (!this.downloadEnabled || !this.sections.length || !data.sectionId || data.courseId != this.courseId) { return; } // 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.getSectionDownloadId({ id: data.sectionId }); if (CoreCourseModulePrefetchDelegate.isBeingDownloaded(downloadId)) { return; } // Get the affected section. const section = this.sections.find(section => section.id == data.sectionId); if (!section) { return; } // Recalculate the status. await CoreCourseHelper.calculateSectionStatus(section, this.courseId, false); if (section.isDownloading && !CoreCourseModulePrefetchDelegate.isBeingDownloaded(downloadId)) { // All the modules are now downloading, set a download all promise. this.prefecthSection(section); } }, CoreSites.getCurrentSiteId(), ); // The download status of a section might have been changed from within a module page. CoreCourseHelper.calculateSectionsStatus(this.sections, this.courseId, false, false); this.sections.forEach((section) => { section.modules.forEach((module) => { if (module.handlerData?.showDownloadButton) { module.spinner = true; // Listen for changes on this module status, even if download isn't enabled. module.prefetchHandler = CoreCourseModulePrefetchDelegate.getPrefetchHandlerFor(module.modname); this.calculateModuleStatus(module); } }); }); this.moduleStatusObserver = CoreEvents.on(CoreEvents.PACKAGE_STATUS_CHANGED, (data) => { let module: AddonStorageManagerModule | undefined; this.sections.some((section) => { module = section.modules.find((module) => module.id == data.componentId && module.prefetchHandler && data.component == module.prefetchHandler?.component); return !!module; }); if (!module) { return; } // Call determineModuleStatus to get the right status to display. const status = CoreCourseModulePrefetchDelegate.determineModuleStatus(module, data.status); // Update the status. this.updateModuleStatus(module, status); }, CoreSites.getCurrentSiteId()); } /** * Init section, course and modules sizes. */ protected async initSizes(): Promise { await Promise.all(this.sections.map(async (section) => { await Promise.all(section.modules.map(async (module) => { // Note: This function only gets the size for modules which are downloadable. // For other modules it always returns 0, even if they have downloaded some files. // However there is no 100% reliable way to actually track the files in this case. // You can maybe guess it based on the component and componentid. // But these aren't necessarily consistent, for example mod_frog vs mmaModFrog. // There is nothing enforcing correct values. // Most modules which have large files are downloadable, so I think this is sufficient. const size = await CoreCourseModulePrefetchDelegate.getModuleStoredSize(module, this.courseId); // There are some cases where the return from this is not a valid number. if (!isNaN(size)) { module.totalSize = Number(size); section.totalSize += size; this.totalSize += size; } module.calculatingSize = false; })); section.calculatingSize = false; })); this.calculatingSize = false; // Mark course as not downloaded if course size is 0. if (this.totalSize == 0) { this.markCourseAsNotDownloaded(); } } /** * Update the sizes of some modules. * * @param modules Modules. * @param section Section the modules belong to. * @returns Promise resolved when done. */ protected async updateModulesSizes( modules: AddonStorageManagerModule[], section?: AddonStorageManagerCourseSection, ): Promise { this.calculatingSize = true; await Promise.all(modules.map(async (module) => { if (module.calculatingSize) { return; } module.calculatingSize = true; this.changeDetectorRef.markForCheck(); if (!section) { section = this.sections.find((section) => section.modules.some((mod) => mod.id === module.id)); if (section) { section.calculatingSize = true; this.changeDetectorRef.markForCheck(); } } try { const size = await CoreCourseModulePrefetchDelegate.getModuleStoredSize(module, this.courseId); const diff = (isNaN(size) ? 0 : size) - (module.totalSize ?? 0); module.totalSize = Number(size); this.totalSize += diff; if (section) { section.totalSize += diff; } } catch { // Ignore errors, it shouldn't happen. } finally { module.calculatingSize = false; this.changeDetectorRef.markForCheck(); } })); this.calculatingSize = false; if (section) { section.calculatingSize = false; } this.changeDetectorRef.markForCheck(); } /** * The user has requested a delete for the whole course data. * * (This works by deleting data for each module on the course that has data.) * * @param event Event object. */ async deleteForCourse(event: Event): Promise { event.stopPropagation(); event.preventDefault(); try { await CoreDomUtils.showDeleteConfirm( 'addon.storagemanager.confirmdeletedatafrom', { name: this.title }, ); } catch (error) { if (!CoreDomUtils.isCanceledError(error)) { throw error; } return; } const modules: AddonStorageManagerModule[] = []; this.sections.forEach((section) => { section.modules.forEach((module) => { if (module.totalSize && module.totalSize > 0) { modules.push(module); } }); }); this.deleteModules(modules); } /** * The user has requested a delete for a section's data. * * (This works by deleting data for each module in the section that has data.) * * @param event Event object. * @param section Section object with information about section and modules */ async deleteForSection(event: Event, section: AddonStorageManagerCourseSection): Promise { event.stopPropagation(); event.preventDefault(); try { await CoreDomUtils.showDeleteConfirm( 'addon.storagemanager.confirmdeletedatafrom', { name: section.name }, ); } catch (error) { if (!CoreDomUtils.isCanceledError(error)) { throw error; } return; } const modules: AddonStorageManagerModule[] = []; section.modules.forEach((module) => { if (module.totalSize && module.totalSize > 0) { modules.push(module); } }); this.deleteModules(modules, section); } /** * The user has requested a delete for a module's data * * @param event Event object. * @param module Module details * @param section Section the module belongs to. */ async deleteForModule( event: Event, module: AddonStorageManagerModule, section: AddonStorageManagerCourseSection, ): Promise { event.stopPropagation(); event.preventDefault(); if (module.totalSize === 0) { return; } try { await CoreDomUtils.showDeleteConfirm( 'addon.storagemanager.confirmdeletedatafrom', { name: module.name }, ); } catch (error) { if (!CoreDomUtils.isCanceledError(error)) { throw error; } return; } this.deleteModules([module], section); } /** * Deletes the specified modules, showing the loading overlay while it happens. * * @param modules Modules to delete * @param section Section the modules belong to. * @returns Promise Once deleting has finished */ protected async deleteModules(modules: AddonStorageManagerModule[], section?: AddonStorageManagerCourseSection): Promise { const modal = await CoreDomUtils.showModalLoading('core.deleting', true); const promises: Promise[] = []; modules.forEach((module) => { // Remove the files. const promise = CoreCourseHelper.removeModuleStoredData(module, this.courseId).then(() => { const moduleSize = module.totalSize || 0; // When the files and cache are removed, update the size. if (section) { section.totalSize -= moduleSize; } this.totalSize -= moduleSize; module.totalSize = 0; return; }); promises.push(promise); }); try { await Promise.all(promises); } catch (error) { CoreDomUtils.showErrorModalDefault(error, Translate.instant('core.errordeletefile')); } finally { modal.dismiss(); await this.updateModulesSizes(modules, section); CoreCourseHelper.calculateSectionsStatus(this.sections, this.courseId, false, false); // For delete all, reset all section sizes so icons are updated. if (this.totalSize == 0) { this.sections.map((section) => { section.calculatingSize = true; section.totalSize = 0; section.calculatingSize = false; }); } this.changeDetectorRef.markForCheck(); } } /** * Mark course as not downloaded. */ protected markCourseAsNotDownloaded(): void { // @TODO In order to correctly check the status of the course we should check all module statuses. // We are currently marking as not downloaded if size is 0 but we should take into account that // resources without files can be downloaded and cached. CoreCourse.setCourseStatus(this.courseId, CoreConstants.NOT_DOWNLOADED); } /** * Calculate the status of sections. * * @param refresh If refresh or not. */ protected calculateSectionsStatus(refresh?: boolean): void { if (!this.sections) { return; } CoreUtils.ignoreErrors(CoreCourseHelper.calculateSectionsStatus(this.sections, this.courseId, refresh)); } /** * Confirm and prefetch a section. If the section is "all sections", prefetch all the sections. * * @param section Section to download. */ async prefecthSection(section: AddonStorageManagerCourseSection): Promise { section.isCalculating = true; this.changeDetectorRef.markForCheck(); try { await CoreCourseHelper.confirmDownloadSizeSection(this.courseId, section, this.sections); try { await CoreCourseHelper.prefetchSection(section, this.courseId, this.sections); } catch (error) { if (!this.isDestroyed) { CoreDomUtils.showErrorModalDefault(error, 'core.course.errordownloadingsection', true); } } finally { await this.updateModulesSizes(section.modules, section); this.changeDetectorRef.markForCheck(); } } catch (error) { // User cancelled or there was an error calculating the size. if (!this.isDestroyed && error) { CoreDomUtils.showErrorModal(error); this.changeDetectorRef.markForCheck(); return; } } finally { section.isCalculating = false; this.changeDetectorRef.markForCheck(); } } /** * Download the module. * * @param module Module to prefetch. * @param refresh Whether it's refreshing. * @returns Promise resolved when done. */ async prefetchModule( module: AddonStorageManagerModule, refresh = false, ): Promise { if (!module.prefetchHandler) { return; } // Show spinner since this operation might take a while. module.spinner = true; try { // Get download size to ask for confirm if it's high. const size = await module.prefetchHandler.getDownloadSize(module, module.course, true); await CoreCourseHelper.prefetchModule(module.prefetchHandler, module, size, module.course, refresh); CoreCourseHelper.calculateSectionsStatus(this.sections, this.courseId, false, false); } catch (error) { if (!this.isDestroyed) { CoreDomUtils.showErrorModalDefault(error, 'core.errordownloading', true); } } finally { module.spinner = false; await this.updateModulesSizes([module]); } } /** * Show download buttons according to module status. * * @param module Module to update. * @param status Module status. */ protected updateModuleStatus(module: AddonStorageManagerModule, status: string): void { if (!status) { return; } module.spinner = false; module.downloadStatus = status; module.handlerData?.updateStatus?.(status); this.changeDetectorRef.markForCheck(); } /** * Calculate and show module status. * * @param module Module to update. * @returns Promise resolved when done. */ protected async calculateModuleStatus(module: AddonStorageManagerModule): Promise { if (!module) { return; } const status = await CoreCourseModulePrefetchDelegate.getModuleStatus(module, this.courseId); this.updateModuleStatus(module, status); } /** * Determines the prefetch icon of the course. * * @returns Promise resolved when done. */ protected async determineCoursePrefetchIcon(): Promise { this.prefetchCourseData = await CoreCourseHelper.getCourseStatusIconAndTitle(this.courseId); } /** * Update the course status icon and title. * * @param status Status to show. */ protected updateCourseStatus(status: string): void { const statusData = CoreCourseHelper.getCoursePrefetchStatusInfo(status); this.prefetchCourseData.status = statusData.status; this.prefetchCourseData.icon = statusData.icon; this.prefetchCourseData.statusTranslatable = statusData.statusTranslatable; this.prefetchCourseData.loading = statusData.loading; this.changeDetectorRef.markForCheck(); } /** * Prefetch the whole course. * * @param event Event object. */ async prefetchCourse(event: Event): Promise { event.stopPropagation(); event.preventDefault(); const courses = await CoreCourses.getUserCourses(true); let course = courses.find((course) => course.id == this.courseId); if (!course) { course = await CoreCourses.getCourse(this.courseId); } if (!course) { return; } try { this.changeDetectorRef.markForCheck(); await CoreCourseHelper.confirmAndPrefetchCourse( this.prefetchCourseData, course, { sections: this.sections, isGuest: this.isGuest, }, ); this.changeDetectorRef.markForCheck(); } catch (error) { if (this.isDestroyed) { return; } CoreDomUtils.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true); } } /** * Toggle expand status. * * @param event Event object. * @param section Section to expand / collapse. */ toggleExpand(event: Event, section: AddonStorageManagerCourseSection): void { section.expanded = !section.expanded; event.stopPropagation(); event.preventDefault(); } /** * @inheritdoc */ ngOnDestroy(): void { this.courseStatusObserver?.off(); this.sectionStatusObserver?.off(); this.moduleStatusObserver?.off(); this.siteUpdatedObserver?.off(); this.sections.forEach((section) => { section.modules.forEach((module) => { module.handlerData?.onDestroy?.(); }); }); this.isDestroyed = true; } } type AddonStorageManagerCourseSection = Omit & { totalSize: number; calculatingSize: boolean; expanded: boolean; modules: AddonStorageManagerModule[]; }; type AddonStorageManagerModule = CoreCourseModuleData & { totalSize?: number; calculatingSize: boolean; prefetchHandler?: CoreCourseModulePrefetchHandler; spinner?: boolean; downloadStatus?: string; };