557 lines
20 KiB
TypeScript
557 lines
20 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 { Injectable } from '@angular/core';
|
|
import { CoreCanceledError } from '@classes/errors/cancelederror';
|
|
import { CoreError } from '@classes/errors/error';
|
|
|
|
import { CoreCourseActivityPrefetchHandlerBase } from '@features/course/classes/activity-prefetch-handler';
|
|
import { CoreCourse, CoreCourseCommonModWSOptions, CoreCourseAnyModuleData } from '@features/course/services/course';
|
|
import { CoreFilepool } from '@services/filepool';
|
|
import { CoreGroups } from '@services/groups';
|
|
import { CoreFileSizeSum, CorePluginFileDelegate } from '@services/plugin-file-delegate';
|
|
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
|
|
import { CoreDomUtils } from '@services/utils/dom';
|
|
import { CoreUtils } from '@services/utils/utils';
|
|
import { CoreWSFile } from '@services/ws';
|
|
import { makeSingleton, Translate } from '@singletons';
|
|
import { AddonModLessonPasswordModalComponent } from '../../components/password-modal/password-modal';
|
|
import {
|
|
AddonModLesson,
|
|
AddonModLessonGetAccessInformationWSResponse,
|
|
AddonModLessonLessonWSData,
|
|
AddonModLessonPasswordOptions,
|
|
AddonModLessonProvider,
|
|
} from '../lesson';
|
|
import { AddonModLessonSync, AddonModLessonSyncResult } from '../lesson-sync';
|
|
|
|
/**
|
|
* Handler to prefetch lessons.
|
|
*/
|
|
@Injectable({ providedIn: 'root' })
|
|
export class AddonModLessonPrefetchHandlerService extends CoreCourseActivityPrefetchHandlerBase {
|
|
|
|
name = 'AddonModLesson';
|
|
modName = 'lesson';
|
|
component = AddonModLessonProvider.COMPONENT;
|
|
// Don't check timers to decrease positives. If a user performs some action it will be reflected in other items.
|
|
updatesNames = /^configuration$|^.*files$|^grades$|^gradeitems$|^pages$|^answers$|^questionattempts$|^pagesviewed$/;
|
|
|
|
/**
|
|
* Ask password.
|
|
*
|
|
* @return Promise resolved with the password.
|
|
*/
|
|
protected async askUserPassword(): Promise<string> {
|
|
// Create and show the modal.
|
|
const modalData = await CoreDomUtils.openModal<string>({
|
|
component: AddonModLessonPasswordModalComponent,
|
|
});
|
|
|
|
if (typeof modalData != 'string') {
|
|
throw new CoreCanceledError();
|
|
}
|
|
|
|
return modalData;
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
async getDownloadSize(module: CoreCourseAnyModuleData, courseId: number, single?: boolean): Promise<CoreFileSizeSum> {
|
|
const siteId = CoreSites.getCurrentSiteId();
|
|
|
|
let lesson = await AddonModLesson.getLesson(courseId, module.id, { siteId });
|
|
|
|
// Get the lesson password if it's needed.
|
|
const passwordData = await this.getLessonPassword(lesson.id, {
|
|
readingStrategy: CoreSitesReadingStrategy.ONLY_NETWORK,
|
|
askPassword: single,
|
|
siteId,
|
|
});
|
|
|
|
lesson = passwordData.lesson || lesson;
|
|
|
|
// Get intro files and media files.
|
|
let files: CoreWSFile[] = lesson.mediafiles || [];
|
|
files = files.concat(this.getIntroFilesFromInstance(module, lesson));
|
|
|
|
const result = await CorePluginFileDelegate.getFilesDownloadSize(files);
|
|
|
|
// Get the pages to calculate the size.
|
|
const pages = await AddonModLesson.getPages(lesson.id, {
|
|
cmId: module.id,
|
|
password: passwordData.password,
|
|
siteId,
|
|
});
|
|
|
|
pages.forEach((page) => {
|
|
result.size += page.filessizetotal;
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Get the lesson password if needed. If not stored, it can ask the user to enter it.
|
|
*
|
|
* @param lessonId Lesson ID.
|
|
* @param options Other options.
|
|
* @return Promise resolved when done.
|
|
*/
|
|
async getLessonPassword(
|
|
lessonId: number,
|
|
options: AddonModLessonGetPasswordOptions = {},
|
|
): Promise<AddonModLessonGetPasswordResult> {
|
|
|
|
options.siteId = options.siteId || CoreSites.getCurrentSiteId();
|
|
|
|
// Get access information to check if password is needed.
|
|
const accessInfo = await AddonModLesson.getAccessInformation(lessonId, options);
|
|
|
|
if (!accessInfo.preventaccessreasons.length) {
|
|
// Password not needed.
|
|
return { accessInfo };
|
|
}
|
|
|
|
const passwordNeeded = accessInfo.preventaccessreasons.length == 1 &&
|
|
AddonModLesson.isPasswordProtected(accessInfo);
|
|
|
|
if (!passwordNeeded) {
|
|
// Lesson cannot be played, reject.
|
|
throw new CoreError(accessInfo.preventaccessreasons[0].message);
|
|
}
|
|
|
|
// The lesson requires a password. Check if there is one in DB.
|
|
let password = await CoreUtils.ignoreErrors(AddonModLesson.getStoredPassword(lessonId));
|
|
|
|
if (password) {
|
|
try {
|
|
return await this.validatePassword(lessonId, accessInfo, password, options);
|
|
} catch {
|
|
// Error validating it.
|
|
}
|
|
}
|
|
|
|
// Ask for the password if allowed.
|
|
if (!options.askPassword) {
|
|
// Cannot ask for password, reject.
|
|
throw new CoreError(accessInfo.preventaccessreasons[0].message);
|
|
}
|
|
|
|
password = await this.askUserPassword();
|
|
|
|
return this.validatePassword(lessonId, accessInfo, password, options);
|
|
}
|
|
|
|
/**
|
|
* Invalidate the prefetched content.
|
|
*
|
|
* @param moduleId The module ID.
|
|
* @param courseId The course ID the module belongs to.
|
|
* @return Promise resolved when the data is invalidated.
|
|
*/
|
|
async invalidateContent(moduleId: number, courseId: number): Promise<void> {
|
|
// Only invalidate the data that doesn't ignore cache when prefetching.
|
|
await Promise.all([
|
|
AddonModLesson.invalidateLessonData(courseId),
|
|
CoreCourse.invalidateModule(moduleId),
|
|
CoreGroups.invalidateActivityAllowedGroups(moduleId),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Invalidate WS calls needed to determine module status.
|
|
*
|
|
* @param module Module.
|
|
* @param courseId Course ID the module belongs to.
|
|
* @return Promise resolved when invalidated.
|
|
*/
|
|
async invalidateModule(module: CoreCourseAnyModuleData, courseId: number): Promise<void> {
|
|
// Invalidate data to determine if module is downloadable.
|
|
const siteId = CoreSites.getCurrentSiteId();
|
|
|
|
const lesson = await AddonModLesson.getLesson(courseId, module.id, {
|
|
readingStrategy: CoreSitesReadingStrategy.PREFER_CACHE,
|
|
siteId,
|
|
});
|
|
|
|
await Promise.all([
|
|
AddonModLesson.invalidateLessonData(courseId, siteId),
|
|
AddonModLesson.invalidateAccessInformation(lesson.id, siteId),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
async isDownloadable(module: CoreCourseAnyModuleData, courseId: number): Promise<boolean> {
|
|
const siteId = CoreSites.getCurrentSiteId();
|
|
|
|
const lesson = await AddonModLesson.getLesson(courseId, module.id, { siteId });
|
|
const accessInfo = await AddonModLesson.getAccessInformation(lesson.id, { cmId: module.id, siteId });
|
|
|
|
// If it's a student and lesson isn't offline, it isn't downloadable.
|
|
if (!accessInfo.canviewreports && !AddonModLesson.isLessonOffline(lesson)) {
|
|
return false;
|
|
}
|
|
|
|
// It's downloadable if there are no prevent access reasons or there is just 1 and it's password.
|
|
return !accessInfo.preventaccessreasons.length ||
|
|
(accessInfo.preventaccessreasons.length == 1 && AddonModLesson.isPasswordProtected(accessInfo));
|
|
}
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
prefetch(module: CoreCourseAnyModuleData, courseId: number, single?: boolean): Promise<void> {
|
|
return this.prefetchPackage(module, courseId, this.prefetchLesson.bind(this, module, courseId, !!single));
|
|
}
|
|
|
|
/**
|
|
* Prefetch a lesson.
|
|
*
|
|
* @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 siteId Site ID.
|
|
* @return Promise resolved when done.
|
|
*/
|
|
protected async prefetchLesson(
|
|
module: CoreCourseAnyModuleData,
|
|
courseId: number,
|
|
single: boolean,
|
|
siteId: string,
|
|
): Promise<void> {
|
|
const commonOptions = {
|
|
readingStrategy: CoreSitesReadingStrategy.ONLY_NETWORK,
|
|
siteId,
|
|
};
|
|
const modOptions = {
|
|
cmId: module.id,
|
|
...commonOptions, // Include all common options.
|
|
};
|
|
|
|
let lesson = await AddonModLesson.getLesson(courseId, module.id, commonOptions);
|
|
|
|
// Get the lesson password if it's needed.
|
|
const passwordData = await this.getLessonPassword(lesson.id, {
|
|
readingStrategy: CoreSitesReadingStrategy.ONLY_NETWORK,
|
|
askPassword: single,
|
|
siteId,
|
|
});
|
|
|
|
lesson = passwordData.lesson || lesson;
|
|
let accessInfo = passwordData.accessInfo;
|
|
const password = passwordData.password;
|
|
|
|
if (AddonModLesson.isLessonOffline(lesson) && !AddonModLesson.leftDuringTimed(accessInfo)) {
|
|
// The user didn't left during a timed session. Call launch retake to make sure there is a started retake.
|
|
accessInfo = await this.launchRetake(lesson.id, password, modOptions, siteId);
|
|
}
|
|
|
|
const promises: Promise<void>[] = [];
|
|
|
|
// Download intro files and media files.
|
|
let files: CoreWSFile[] = (lesson.mediafiles || []);
|
|
files = files.concat(this.getIntroFilesFromInstance(module, lesson));
|
|
promises.push(CoreFilepool.addFilesToQueue(siteId, files, this.component, module.id));
|
|
|
|
if (AddonModLesson.isLessonOffline(lesson)) {
|
|
promises.push(this.prefetchPlayData(lesson, password, accessInfo.attemptscount, modOptions));
|
|
}
|
|
|
|
if (accessInfo.canviewreports) {
|
|
promises.push(this.prefetchGroupInfo(module.id, lesson.id, modOptions));
|
|
promises.push(this.prefetchReportsData(module.id, lesson.id, modOptions));
|
|
}
|
|
|
|
await Promise.all(promises);
|
|
}
|
|
|
|
/**
|
|
* Launch a retake and return the updated access information.
|
|
*
|
|
* @param lessonId Lesson ID.
|
|
* @param password Password (if needed).
|
|
* @param modOptions Options.
|
|
* @param siteId Site ID.
|
|
*/
|
|
protected async launchRetake(
|
|
lessonId: number,
|
|
password: string | undefined,
|
|
modOptions: CoreCourseCommonModWSOptions,
|
|
siteId: string,
|
|
): Promise<AddonModLessonGetAccessInformationWSResponse> {
|
|
// The user didn't left during a timed session. Call launch retake to make sure there is a started retake.
|
|
await AddonModLesson.launchRetake(lessonId, password, undefined, false, siteId);
|
|
|
|
const results = await Promise.all([
|
|
CoreUtils.ignoreErrors(CoreFilepool.updatePackageDownloadTime(siteId, this.component, module.id)),
|
|
AddonModLesson.getAccessInformation(lessonId, modOptions),
|
|
]);
|
|
|
|
return results[1];
|
|
}
|
|
|
|
/**
|
|
* Prefetch data to play the lesson in offline.
|
|
*
|
|
* @param lesson Lesson.
|
|
* @param password Password (if needed).
|
|
* @param retake Retake to prefetch.
|
|
* @param modOptions Options.
|
|
* @return Promise resolved when done.
|
|
*/
|
|
protected async prefetchPlayData(
|
|
lesson: AddonModLessonLessonWSData,
|
|
password: string | undefined,
|
|
retake: number,
|
|
modOptions: CoreCourseCommonModWSOptions,
|
|
): Promise<void> {
|
|
const passwordOptions = {
|
|
password,
|
|
...modOptions, // Include all mod options.
|
|
};
|
|
|
|
await Promise.all([
|
|
this.prefetchPagesData(lesson, passwordOptions),
|
|
// Prefetch user timers to be able to calculate timemodified in offline.
|
|
CoreUtils.ignoreErrors(AddonModLesson.getTimers(lesson.id, modOptions)),
|
|
// Prefetch viewed pages in last retake to calculate progress.
|
|
AddonModLesson.getContentPagesViewedOnline(lesson.id, retake, modOptions),
|
|
// Prefetch question attempts in last retake for offline calculations.
|
|
AddonModLesson.getQuestionsAttemptsOnline(lesson.id, retake, modOptions),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Prefetch data related to pages.
|
|
*
|
|
* @param lesson Lesson.
|
|
* @param options Options.
|
|
* @return Promise resolved when done.
|
|
*/
|
|
protected async prefetchPagesData(
|
|
lesson: AddonModLessonLessonWSData,
|
|
options: AddonModLessonPasswordOptions,
|
|
): Promise<void> {
|
|
const pages = await AddonModLesson.getPages(lesson.id, options);
|
|
|
|
let hasRandomBranch = false;
|
|
|
|
// Get the data for each page.
|
|
const promises = pages.map(async (data) => {
|
|
// Check if any page has a RANDOMBRANCH jump.
|
|
if (!hasRandomBranch) {
|
|
hasRandomBranch = data.jumps.some((jump) => jump === AddonModLessonProvider.LESSON_RANDOMBRANCH);
|
|
}
|
|
|
|
// Get the page data. We don't pass accessInfo because we don't need to calculate the offline data.
|
|
const pageData = await AddonModLesson.getPageData(lesson, data.page.id, {
|
|
includeContents: true,
|
|
includeOfflineData: false,
|
|
...options, // Include all options.
|
|
});
|
|
|
|
// Download the page files.
|
|
let pageFiles = pageData.contentfiles || [];
|
|
|
|
pageData.answers.forEach((answer) => {
|
|
pageFiles = pageFiles.concat(answer.answerfiles);
|
|
pageFiles = pageFiles.concat(answer.responsefiles);
|
|
});
|
|
|
|
await CoreFilepool.addFilesToQueue(options.siteId!, pageFiles, this.component, module.id);
|
|
});
|
|
|
|
// Prefetch the list of possible jumps for offline navigation. Do it here because we know hasRandomBranch.
|
|
promises.push(this.prefetchPossibleJumps(lesson.id, hasRandomBranch, options));
|
|
|
|
await Promise.all(promises);
|
|
}
|
|
|
|
/**
|
|
* Prefetch possible jumps.
|
|
*
|
|
* @param lessonId Lesson ID.
|
|
* @param hasRandomBranch Whether any page has a random branch jump.
|
|
* @param modOptions Options.
|
|
* @return Promise resolved when done.
|
|
*/
|
|
protected async prefetchPossibleJumps(
|
|
lessonId: number,
|
|
hasRandomBranch: boolean,
|
|
modOptions: CoreCourseCommonModWSOptions,
|
|
): Promise<void> {
|
|
try {
|
|
await AddonModLesson.getPagesPossibleJumps(lessonId, modOptions);
|
|
} catch (error) {
|
|
if (hasRandomBranch) {
|
|
// The WebSevice probably failed because RANDOMBRANCH aren't supported if the user hasn't seen any page.
|
|
throw new CoreError(Translate.instant('addon.mod_lesson.errorprefetchrandombranch'));
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Prefetch group info.
|
|
*
|
|
* @param moduleId Module ID.
|
|
* @param lessonId Lesson ID.
|
|
* @param modOptions Options.
|
|
* @return Promise resolved when done.
|
|
*/
|
|
protected async prefetchGroupInfo(
|
|
moduleId: number,
|
|
lessonId: number,
|
|
modOptions: CoreCourseCommonModWSOptions,
|
|
): Promise<void> {
|
|
const groupInfo = await CoreGroups.getActivityGroupInfo(moduleId, false, undefined, modOptions.siteId, true);
|
|
|
|
await Promise.all(groupInfo.groups.map(async (group) => {
|
|
await AddonModLesson.getRetakesOverview(lessonId, {
|
|
groupId: group.id,
|
|
...modOptions, // Include all options.
|
|
});
|
|
}) || []);
|
|
}
|
|
|
|
/**
|
|
* Prefetch reports data.
|
|
*
|
|
* @param moduleId Module ID.
|
|
* @param lessonId Lesson ID.
|
|
* @param modOptions Options.
|
|
* @return Promise resolved when done.
|
|
*/
|
|
protected async prefetchReportsData(
|
|
moduleId: number,
|
|
lessonId: number,
|
|
modOptions: CoreCourseCommonModWSOptions,
|
|
): Promise<void> {
|
|
// Always get all participants, even if there are no groups.
|
|
const data = await AddonModLesson.getRetakesOverview(lessonId, modOptions);
|
|
if (!data || !data.students) {
|
|
return;
|
|
}
|
|
|
|
// Prefetch the last retake for each user.
|
|
await Promise.all(data.students.map(async (student) => {
|
|
const lastRetake = student.attempts?.[student.attempts.length - 1];
|
|
if (!lastRetake) {
|
|
return;
|
|
}
|
|
|
|
const attempt = await AddonModLesson.getUserRetake(lessonId, lastRetake.try, {
|
|
userId: student.id,
|
|
...modOptions, // Include all options.
|
|
});
|
|
|
|
if (!attempt?.answerpages) {
|
|
return;
|
|
}
|
|
|
|
// Download embedded files in essays.
|
|
const files: CoreWSFile[] = [];
|
|
attempt.answerpages.forEach((answerPage) => {
|
|
if (!answerPage.page || answerPage.page.qtype != AddonModLessonProvider.LESSON_PAGE_ESSAY) {
|
|
return;
|
|
}
|
|
|
|
answerPage.answerdata?.answers?.forEach((answer) => {
|
|
files.push(...CoreFilepool.extractDownloadableFilesFromHtmlAsFakeFileObjects(answer[0]));
|
|
});
|
|
});
|
|
|
|
await CoreFilepool.addFilesToQueue(modOptions.siteId!, files, this.component, moduleId);
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Validate the password.
|
|
*
|
|
* @param lessonId Lesson ID.
|
|
* @param accessInfo Lesson access info.
|
|
* @param password Password to check.
|
|
* @param options Other options.
|
|
* @return Promise resolved when done.
|
|
*/
|
|
protected async validatePassword(
|
|
lessonId: number,
|
|
accessInfo: AddonModLessonGetAccessInformationWSResponse,
|
|
password: string,
|
|
options: CoreCourseCommonModWSOptions = {},
|
|
): Promise<AddonModLessonGetPasswordResult> {
|
|
|
|
options.siteId = options.siteId || CoreSites.getCurrentSiteId();
|
|
|
|
const lesson = await AddonModLesson.getLessonWithPassword(lessonId, {
|
|
password,
|
|
...options, // Include all options.
|
|
});
|
|
|
|
// Password is ok, store it and return the data.
|
|
await AddonModLesson.storePassword(lesson.id, password, options.siteId);
|
|
|
|
return {
|
|
password,
|
|
lesson,
|
|
accessInfo,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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: CoreCourseAnyModuleData, courseId: number, siteId?: string): Promise<AddonModLessonSyncResult> {
|
|
return AddonModLessonSync.syncLesson(module.instance, false, false, siteId);
|
|
}
|
|
|
|
}
|
|
|
|
export const AddonModLessonPrefetchHandler = makeSingleton(AddonModLessonPrefetchHandlerService);
|
|
|
|
/**
|
|
* Options to pass to get lesson password.
|
|
*/
|
|
export type AddonModLessonGetPasswordOptions = CoreCourseCommonModWSOptions & {
|
|
askPassword?: boolean; // True if we should ask for password if needed, false otherwise.
|
|
};
|
|
|
|
/**
|
|
* Result of getLessonPassword.
|
|
*/
|
|
export type AddonModLessonGetPasswordResult = {
|
|
password?: string;
|
|
lesson?: AddonModLessonLessonWSData;
|
|
accessInfo: AddonModLessonGetAccessInformationWSResponse;
|
|
};
|