781 lines
27 KiB
TypeScript

// (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<void> {
try {
this.courseId = CoreNavigator.getRequiredRouteParam('courseId');
} catch (error) {
CoreDomUtils.showErrorModal(error);
CoreNavigator.back();
return;
}
this.title = CoreNavigator.getRouteParam<string>('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<void> {
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<void> {
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<void> {
await this.updateSizes(this.sections);
}
/**
* Update the sizes of some sections and modules.
*
* @param sections Modules.
*/
protected async updateSizes(sections: AddonStorageManagerCourseSection[]): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
const modal = await CoreLoadings.show('core.deleting', true);
const sections = new Set<AddonStorageManagerCourseSection>();
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<void> {
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<void> {
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<void> {
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<void> {
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<CoreCourseAnyCourseData | undefined> {
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<void> {
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<void> {
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<void> {
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<CoreCourseSectionWithStatus, 'contents'> & {
totalSize: number;
calculatingSize: boolean;
expanded: boolean;
contents: (AddonStorageManagerCourseSection | AddonStorageManagerModule)[];
};
type AddonStorageManagerModule = CoreCourseModuleData & {
totalSize?: number;
calculatingSize: boolean;
prefetchHandler?: CoreCourseModulePrefetchHandler;
spinner?: boolean;
downloadStatus?: DownloadStatus;
};