// (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, DownloadStatus } from '@/core/constants'; import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, OnDestroy, OnInit } from '@angular/core'; import { CoreCourse, CoreCourseProvider, sectionContentIsModule } from '@features/course/services/course'; import { CoreCourseHelper, CoreCourseModuleData, CoreCourseSection, CoreCourseSectionWithStatus, CorePrefetchStatusInfo, } from '@features/course/services/course-helper'; import { CoreCourseModulePrefetchDelegate, CoreCourseModulePrefetchHandler } from '@features/course/services/module-prefetch-delegate'; import { CoreCourseAnyCourseData, CoreCourses } from '@features/courses/services/courses'; import { AccordionGroupChangeEventDetail } from '@ionic/angular'; import { CoreLoadings } from '@services/loadings'; import { CoreNavigator } from '@services/navigator'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; 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', styleUrl: 'course-storage.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) export class AddonStorageManagerCourseStoragePage implements OnInit, OnDestroy { courseId!: number; title = ''; loaded = false; sections: AddonStorageManagerCourseSection[] = []; totalSize = 0; calculatingSize = true; accordionMultipleValue: string[] = []; downloadEnabled = false; downloadCourseEnabled = false; prefetchCourseData: CorePrefetchStatusInfo = { icon: CoreConstants.ICON_LOADING, statusTranslatable: 'core.course.downloadcourse', status: DownloadStatus.DOWNLOADABLE_NOT_DOWNLOADED, loading: true, }; statusDownloaded = DownloadStatus.DOWNLOADED; isModule = sectionContentIsModule; 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') ?? (await CoreCourseHelper.courseUsesGuestAccessInfo(this.courseId)).guestAccess; const 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 => this.formatSection(section)); this.loaded = true; if (initialSectionId !== undefined && initialSectionId > 0) { this.accordionMultipleValue.push(initialSectionId.toString()); this.accordionGroupChange(); CoreDom.scrollToElement( this.elementRef.nativeElement, `#addons-course-storage-${initialSectionId}`, { addYAxis: -10 }, ); } else { this.accordionMultipleValue.push(this.sections[0].id.toString()); this.accordionGroupChange(); } await Promise.all([ this.initSizes(), this.initCoursePrefetch(), this.initModulePrefetch(), ]); this.changeDetectorRef.markForCheck(); } /** * Format a section. * * @param section Section to format. * @param expanded Whether section should be expanded. * @returns Formatted section, */ protected formatSection(section: CoreCourseSection, expanded = false): AddonStorageManagerCourseSection { return { ...section, totalSize: 0, calculatingSize: true, expanded: expanded, contents: section.contents.map(modOrSubsection => { if (sectionContentIsModule(modOrSubsection)) { return { ...modOrSubsection, totalSize: 0, calculatingSize: false, }; } return this.formatSection(modOrSubsection, expanded); }), }; } /** * Init course prefetch information. */ 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. */ 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; } // Get the affected section. const { section } = CoreCourseHelper.findSection(this.sections, { id: data.sectionId }); if (!section) { return; } // @todo: Handle parents too? It seems the SECTION_STATUS_CHANGED event is never triggered. // 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: section.id }); if (CoreCourseModulePrefetchDelegate.isBeingDownloaded(downloadId)) { return; } // Recalculate the status. await this.updateSizes([section]); if (section.isDownloading && !CoreCourseModulePrefetchDelegate.isBeingDownloaded(downloadId)) { // All the modules are now downloading, set a download all promise. this.prefetchSection(section); } }, CoreSites.getCurrentSiteId(), ); this.moduleStatusObserver = CoreEvents.on(CoreEvents.PACKAGE_STATUS_CHANGED, (data) => { const modules = CoreCourse.getSectionsModules(this.sections); const moduleFound = modules.find(module => module.id === data.componentId && module.prefetchHandler && data.component === module.prefetchHandler?.component); if (!moduleFound) { return; } // Call determineModuleStatus to get the right status to display. const status = CoreCourseModulePrefetchDelegate.determineModuleStatus(moduleFound, data.status); // Update the status. this.updateModuleStatus(moduleFound, status); }, CoreSites.getCurrentSiteId()); // The download status of a section might have been changed from within a module page. this.updateSizes(this.sections); } /** * Init section, course and modules sizes. */ protected async initSizes(): Promise { await this.updateSizes(this.sections); } /** * Update the sizes of some sections and modules. * * @param sections Modules. */ protected async updateSizes(sections: AddonStorageManagerCourseSection[]): Promise { this.calculatingSize = true; CoreCourseHelper.flattenSections(sections).forEach((section) => { section.calculatingSize = true; }); this.changeDetectorRef.markForCheck(); // Update only affected module sections. const modules = CoreCourse.getSectionsModules(sections); await Promise.all(modules.map(async (module) => { await this.calculateModuleSize(module); })); const updateSectionSize = (section: AddonStorageManagerCourseSection): void => { section.totalSize = 0; section.calculatingSize = true; this.changeDetectorRef.markForCheck(); section.contents.forEach((modOrSubsection) => { if (!sectionContentIsModule(modOrSubsection)) { updateSectionSize(modOrSubsection); } section.totalSize += modOrSubsection.totalSize ?? 0; this.changeDetectorRef.markForCheck(); }); section.calculatingSize = false; this.changeDetectorRef.markForCheck(); }; // Update section and total sizes. this.totalSize = 0; this.sections.forEach((section) => { updateSectionSize(section); this.totalSize += section.totalSize; }); this.calculatingSize = false; // Mark course as not downloaded if course size is 0. if (this.totalSize === 0) { this.markCourseAsNotDownloaded(); } this.changeDetectorRef.markForCheck(); await this.calculateSectionsStatus(sections); 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 = CoreCourse.getSectionsModules(this.sections) .filter((module) => module.totalSize && module.totalSize > 0); await 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 = CoreCourse.getSectionsModules([section]).filter((module) => module.totalSize && module.totalSize > 0); await this.deleteModules(modules); } /** * The user has requested a delete for a module's data * * @param event Event object. * @param module Module details */ async deleteForModule( event: Event, module: AddonStorageManagerModule, ): 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; } await this.deleteModules([module]); } /** * Deletes the specified modules, showing the loading overlay while it happens. * * @param modules Modules to delete */ protected async deleteModules(modules: AddonStorageManagerModule[]): Promise { const modal = await CoreLoadings.show('core.deleting', true); const sections = new Set(); const promises = modules.map(async (module) => { // Remove the files. await CoreCourseHelper.removeModuleStoredData(module, this.courseId); module.totalSize = 0; const { section, parents } = CoreCourseHelper.findSection(this.sections, { id: module.section }); const rootSection = parents[0] ?? section; if (rootSection && !sections.has(rootSection)) { sections.add(rootSection); } }); try { await Promise.all(promises); } catch (error) { CoreDomUtils.showErrorModalDefault(error, Translate.instant('core.errordeletefile')); } finally { modal.dismiss(); await this.updateSizes(Array.from(sections)); 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, DownloadStatus.DOWNLOADABLE_NOT_DOWNLOADED); } /** * Confirm and prefetch a section. If the section is "all sections", prefetch all the sections. * * @param section Section to download. */ async prefetchSection(section: AddonStorageManagerCourseSection): Promise { section.isCalculating = true; this.changeDetectorRef.markForCheck(); try { await CoreCourseHelper.confirmDownloadSizeSection(this.courseId, [section]); try { await CoreCourseHelper.prefetchSections([section], this.courseId); } catch (error) { if (!this.isDestroyed) { CoreDomUtils.showErrorModalDefault(error, 'core.course.errordownloadingsection', true); } } finally { await this.updateSizes([section]); } } 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); } catch (error) { if (!this.isDestroyed) { CoreDomUtils.showErrorModalDefault(error, 'core.errordownloading', true); } } finally { module.spinner = false; const { section, parents } = CoreCourseHelper.findSection(this.sections, { id: module.section }); const rootSection = parents[0] ?? section; if (rootSection) { await this.updateSizes([rootSection]); } } } /** * Show download buttons according to module status. * * @param module Module to update. * @param status Module status. */ protected updateModuleStatus(module: AddonStorageManagerModule, status: DownloadStatus): void { if (!status) { return; } module.spinner = false; module.downloadStatus = status; module.handlerData?.updateStatus?.(status); this.changeDetectorRef.markForCheck(); } /** * Calculate all modules status on a section. * * @param section Section to check. */ protected async calculateModulesStatusOnSection(section: AddonStorageManagerCourseSection): Promise { await Promise.all(section.contents.map(async (modOrSubsection) => { if (!sectionContentIsModule(modOrSubsection)) { await this.calculateModulesStatusOnSection(modOrSubsection); return; } if (modOrSubsection.handlerData?.showDownloadButton) { modOrSubsection.spinner = true; // Listen for changes on this module status, even if download isn't enabled. modOrSubsection.prefetchHandler = CoreCourseModulePrefetchDelegate.getPrefetchHandlerFor(modOrSubsection.modname); const status = await CoreCourseModulePrefetchDelegate.getModuleStatus(modOrSubsection, this.courseId); this.updateModuleStatus(modOrSubsection, status); } })); } /** * Determines the prefetch icon of the course. */ 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: DownloadStatus): 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(); } /** * Get the course object. * * @param courseId Course ID. * @returns Promise resolved with the course object if found. */ protected async getCourse(courseId: number): Promise { try { // Check if user is enrolled. If enrolled, no guest access. return await CoreCourses.getUserCourse(courseId, true); } catch { // Ignore errors. } try { // The user is not enrolled in the course. Use getCourses to see if it's an admin/manager and can see the course. return await CoreCourses.getCourse(courseId); } catch { // Ignore errors. } return await CoreCourses.getCourseByField('id', this.courseId); } /** * Prefetch the whole course. * * @param event Event object. */ async prefetchCourse(event: Event): Promise { event.stopPropagation(); event.preventDefault(); const course = await this.getCourse(this.courseId); if (!course) { CoreDomUtils.showErrorModal('core.course.errordownloadingcourse', true); return; } try { this.changeDetectorRef.markForCheck(); await CoreCourseHelper.confirmAndPrefetchCourse( this.prefetchCourseData, course, { sections: this.sections, isGuest: this.isGuest, }, ); await this.updateSizes(this.sections); } catch (error) { if (this.isDestroyed) { return; } CoreDomUtils.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true); } } /** * Calculate the size of the modules. * * @param module Module to calculate. */ protected async calculateModuleSize(module: AddonStorageManagerModule): Promise { if (module.calculatingSize) { return; } module.calculatingSize = true; this.changeDetectorRef.markForCheck(); // 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. module.totalSize = await CoreCourseModulePrefetchDelegate.getModuleStoredSize(module, this.courseId); module.calculatingSize = false; this.changeDetectorRef.markForCheck(); } /** * Toggle expand status. * * @param event Event object. If not defined, use the current value. */ accordionGroupChange(event?: AccordionGroupChangeEventDetail): void { const sectionIds = event?.value as string[] ?? this.accordionMultipleValue; const allSections = CoreCourseHelper.flattenSections(this.sections); allSections.forEach((section) => { section.expanded = false; }); sectionIds.forEach((sectionId) => { const section = allSections.find((section) => section.id === Number(sectionId)); if (section) { section.expanded = true; } }); } /** * @inheritdoc */ ngOnDestroy(): void { this.courseStatusObserver?.off(); this.sectionStatusObserver?.off(); this.moduleStatusObserver?.off(); this.siteUpdatedObserver?.off(); CoreCourse.getSectionsModules(this.sections).forEach((module) => { module.handlerData?.onDestroy?.(); }); this.isDestroyed = true; } /** * Calculate the status of a list of sections, setting attributes to determine the icons/data to be shown. * * @param sections Sections to calculate their status. */ protected async calculateSectionsStatus(sections: AddonStorageManagerCourseSection[]): Promise { if (!sections) { return; } await Promise.all(sections.map(async (section) => { if (section.id === CoreCourseProvider.ALL_SECTIONS_ID) { return; } try { section.isCalculating = true; await this.calculateModulesStatusOnSection(section); await CoreCourseHelper.calculateSectionStatus(section, this.courseId, false, false); } finally { section.isCalculating = false; } })); } } type AddonStorageManagerCourseSection = Omit & { totalSize: number; calculatingSize: boolean; expanded: boolean; contents: (AddonStorageManagerCourseSection | AddonStorageManagerModule)[]; }; type AddonStorageManagerModule = CoreCourseModuleData & { totalSize?: number; calculatingSize: boolean; prefetchHandler?: CoreCourseModulePrefetchHandler; spinner?: boolean; downloadStatus?: DownloadStatus; };