// (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 { CoreUtils } from '@services/utils/utils'; import { CoreSites, CoreSitesCommonWSOptions } from '@services/sites'; import { CoreCourseAnyCourseDataWithOptions, CoreCourses, CoreCourseSearchedData, CoreCourseUserAdminOrNavOptionIndexed, CoreEnrolledCourseData, } from './courses'; import { makeSingleton, Translate } from '@singletons'; import { CoreWSExternalFile } from '@services/ws'; import { AddonCourseCompletion } from '@addons/coursecompletion/services/coursecompletion'; import moment from 'moment-timezone'; import { of, firstValueFrom } from 'rxjs'; import { zipIncludingComplete } from '@/core/utils/rxjs'; import { catchError, map } from 'rxjs/operators'; import { chainRequests, WSObservable } from '@classes/sites/authenticated-site'; import { LazyRoutesModule } from '@/app/app-routing.module'; // Id for a course item representing all courses (for example, for course filters). export const ALL_COURSES_ID = -1; /** * Helper to gather some common courses functions. */ @Injectable({ providedIn: 'root' }) export class CoreCoursesHelperProvider { protected courseSiteColors: Record = {}; /** * Get the courses to display the course picker popover. If a courseId is specified, it will also return its categoryId. * * @param courseId Course ID to get the category. * @returns Promise resolved with the list of courses and the category. */ async getCoursesForPopover(courseId?: number): Promise<{courses: CoreEnrolledCourseData[]; categoryId?: number}> { const courses: CoreEnrolledCourseData[] = await CoreCourses.getUserCourses(false); // Add "All courses". courses.unshift({ id: ALL_COURSES_ID, fullname: Translate.instant('core.fulllistofcourses'), shortname: Translate.instant('core.fulllistofcourses'), categoryid: -1, summary: '', summaryformat: 1, }); let categoryId: number | undefined; if (courseId) { // Search the course to get the category. const course = courses.find((course) => course.id == courseId); if (course) { categoryId = course.categoryid; } } return { courses: courses, categoryId: categoryId, }; } /** * Given a course object returned by core_enrol_get_users_courses and another one returned by core_course_get_courses_by_field, * load some extra data to the first one. * * @param course Course returned by core_enrol_get_users_courses. * @param courseByField Course returned by core_course_get_courses_by_field. * @param addCategoryName Whether add category name or not. */ protected loadCourseExtraInfo( course: CoreEnrolledCourseDataWithExtraInfo, courseByField: CoreCourseSearchedData, addCategoryName: boolean = false, ): void { if (courseByField) { course.displayname = courseByField.displayname; course.categoryname = addCategoryName ? courseByField.categoryname : undefined; course.overviewfiles = course.overviewfiles || courseByField.overviewfiles; } else { delete course.displayname; } } /** * Loads the color of courses or the thumb image. * * @param courses List of courses. * @returns Promise resolved when done. */ async loadCoursesColorAndImage(courses: CoreCourseSearchedData[]): Promise { if (!courses.length) { return; } await Promise.all(courses.map((course) => this.loadCourseColorAndImage(course))); } /** * Given a list of courses returned by core_enrol_get_users_courses, load some extra data using the WebService * core_course_get_courses_by_field if available. * * @param courses List of courses. * @param loadCategoryNames Whether load category names or not. * @returns Promise resolved when done. */ loadCoursesExtraInfo( courses: CoreEnrolledCourseDataWithExtraInfo[], loadCategoryNames: boolean = false, ): Promise { return firstValueFrom(this.loadCoursesExtraInfoObservable(courses, loadCategoryNames)); } /** * Given a list of courses returned by core_enrol_get_users_courses, load some extra data using the WebService * core_course_get_courses_by_field if available. * * @param courses List of courses. * @param loadCategoryNames Whether load category names or not. * @returns Promise resolved when done. */ loadCoursesExtraInfoObservable( courses: CoreEnrolledCourseDataWithExtraInfo[], loadCategoryNames: boolean = false, options: CoreSitesCommonWSOptions = {}, ): WSObservable { if (!courses.length) { return of([]); } if (!loadCategoryNames && (courses[0].overviewfiles !== undefined || courses[0].displayname !== undefined)) { // No need to load more data. return of(courses); } const courseIds = courses.map((course) => course.id).join(','); // Get the extra data for the courses. return CoreCourses.getCoursesByFieldObservable('ids', courseIds, options).pipe(map(coursesInfosArray => { const coursesInfo = CoreUtils.arrayToObject(coursesInfosArray, 'id'); courses.forEach((course) => { this.loadCourseExtraInfo(course, coursesInfo[course.id], loadCategoryNames); }); return courses; })); } /** * Load course colors from site config. * * @returns course colors RGB. */ protected async loadCourseSiteColors(): Promise<(string | undefined)[]> { const site = CoreSites.getRequiredCurrentSite(); const siteId = site.getId(); if (this.courseSiteColors[siteId] !== undefined) { return this.courseSiteColors[siteId]; } if (!site.isVersionGreaterEqualThan('3.8')) { this.courseSiteColors[siteId] = []; return []; } const colors: (string | undefined)[] = []; try { const configs = await site.getConfig(); for (let x = 0; x < 10; x++) { colors[x] = configs['core_admin_coursecolor' + (x + 1)] || undefined; } this.courseSiteColors[siteId] = colors; } catch { // Ignore errors. } return colors; } /** * Loads the color of the course or the thumb image. * * @param course Course data. */ async loadCourseColorAndImage(course: CoreCourseWithImageAndColor): Promise { // Moodle 4.1 downwards geopatterns are embedded in b64 in only some WS, remote them to keep it coherent. if (course.courseimage?.startsWith('data')) { course.courseimage = undefined; } if (course.courseimage !== undefined) { course.courseImage = course.courseimage; // @deprecated since 4.3 use courseimage instead. return; } if (course.overviewfiles && course.overviewfiles[0]) { course.courseimage = course.overviewfiles[0].fileurl; course.courseImage = course.courseimage; // @deprecated since 4.3 use courseimage instead. return; } const colors = await this.loadCourseSiteColors(); course.colorNumber = course.id % 10; course.color = colors.length ? colors[course.colorNumber] : undefined; } /** * Get user courses with admin and nav options. * * @param sort Sort courses after get them. If sort is not defined it won't be sorted. * @param slice Slice results to get the X first one. If slice > 0 it will be done after sorting. * @param filter Filter using some field. * @param loadCategoryNames Whether load category names or not. * @param options Options. * @returns Courses filled with options. */ getUserCoursesWithOptions( sort: string = 'fullname', slice: number = 0, filter?: string, loadCategoryNames: boolean = false, options: CoreSitesCommonWSOptions = {}, ): Promise { return firstValueFrom(this.getUserCoursesWithOptionsObservable({ sort, slice, filter, loadCategoryNames, ...options, })); } /** * Get user courses with admin and nav options. * * @param options Options. * @returns Courses filled with options. */ getUserCoursesWithOptionsObservable( options: CoreCoursesGetWithOptionsOptions = {}, ): WSObservable { return CoreCourses.getUserCoursesObservable(options).pipe( chainRequests(options.readingStrategy, (courses, newReadingStrategy) => { if (courses.length <= 0) { return of([]); } const courseIds = courses.map((course) => course.id); // Use all courses to get options, to use cache. const newOptions = { ...options, readingStrategy: newReadingStrategy, }; courses = this.filterAndSortCoursesWithOptions(courses, options); return zipIncludingComplete( this.loadCoursesExtraInfoObservable(courses, options.loadCategoryNames, newOptions), CoreCourses.getCoursesAdminAndNavOptionsObservable(courseIds, newOptions).pipe(map(courseOptions => { courses.forEach((course: CoreEnrolledCourseDataWithOptions) => { course.navOptions = courseOptions.navOptions[course.id]; course.admOptions = courseOptions.admOptions[course.id]; }); })), ...courses.map(course => this.loadCourseCompletedStatus(course, newOptions)), ).pipe(map(() => courses)); }), ); } /** * Filter and sort some courses. * * @param courses Courses. * @param options Options * @returns Courses filtered and sorted. */ protected filterAndSortCoursesWithOptions( courses: CoreEnrolledCourseData[], options: CoreCoursesGetWithOptionsOptions = {}, ): CoreEnrolledCourseData[] { const sort = options.sort ?? 'fullname'; const slice = options.slice ?? -1; switch (options.filter) { case 'isfavourite': courses = courses.filter((course) => !!course.isfavourite); break; default: // Filter not implemented. } switch (sort) { case 'fullname': courses.sort((a, b) => { const compareA = a.fullname.toLowerCase(); const compareB = b.fullname.toLowerCase(); return compareA.localeCompare(compareB); }); break; case 'lastaccess': courses.sort((a, b) => (b.lastaccess || 0) - (a.lastaccess || 0)); break; // Time modified property is defined on Moodle 4.0. case 'timemodified': courses.sort((a, b) => (b.timemodified || 0) - (a.timemodified || 0)); break; case 'shortname': courses.sort((a, b) => { const compareA = a.shortname.toLowerCase(); const compareB = b.shortname.toLowerCase(); return compareA.localeCompare(compareB); }); break; default: // Sort not implemented. Do not sort. } courses = slice > 0 ? courses.slice(0, slice) : courses; return courses; } /** * Given a course object, fetch and set its completed status if not present already. * * @param course Course. * @returns Observable. */ protected loadCourseCompletedStatus( course: CoreEnrolledCourseDataWithExtraInfo, options: CoreSitesCommonWSOptions = {}, ): WSObservable { if (course.completed !== undefined) { // The WebService already returns the completed status, no need to fetch it. return of(course); } if (course.enablecompletion !== undefined && !course.enablecompletion) { // Completion is disabled for this course, there is no need to fetch the completion status. return of(course); } return AddonCourseCompletion.getCompletionObservable(course.id, options).pipe( map(completion => { course.completed = completion.completed; return course; }), catchError(() => { // Ignore error, maybe course completion is disabled or user has no permission. course.completed = false; return of(course); }), ); } /** * Calculates if course date is past. * * @param course Course Object. * @param gradePeriodAfter Classify past courses as in progress for these many days after the course end date. * @returns Wether the course is past. */ isPastCourse(course: CoreEnrolledCourseDataWithOptions, gradePeriodAfter = 0): boolean { if (course.completed) { return true; } if (!course.enddate) { return false; } // Calculate the end date to use for display classification purposes, incorporating the grace period, if any. const endDate = moment(course.enddate * 1000).add(gradePeriodAfter, 'days').valueOf(); return endDate < Date.now(); } /** * Calculates if course date is future. * * @param course Course Object. * @param gradePeriodAfter Classify past courses as in progress for these many days after the course end date. * @param gradePeriodBefore Classify future courses as in progress for these many days prior to the course start date. * @returns Wether the course is future. */ isFutureCourse( course: CoreEnrolledCourseDataWithOptions, gradePeriodAfter = 0, gradePeriodBefore = 0, ): boolean { if (this.isPastCourse(course, gradePeriodAfter) || !course.startdate) { return false; } // Calculate the start date to use for display classification purposes, incorporating the grace period, if any. const startDate = moment(course.startdate * 1000).subtract(gradePeriodBefore, 'days').valueOf(); return startDate > Date.now(); } /** * Retrieves my courses page module. * * @returns My courses page module. */ async getMyRouteModule(): Promise { return import('../courses-my-lazy.module').then(m => m.CoreCoursesMyLazyModule); } } export const CoreCoursesHelper = makeSingleton(CoreCoursesHelperProvider); /** * Course with colors info and course image. */ export type CoreCourseWithImageAndColor = { id: number; // Course id. overviewfiles?: CoreWSExternalFile[]; colorNumber?: number; // Color index number. color?: string; // Color RGB. courseImage?: string; // Course thumbnail. @deprecated since 4.3, use courseimage instead. courseimage?: string; // Course thumbnail. }; /** * Enrolled course data with extra rendering info. */ export type CoreEnrolledCourseDataWithExtraInfo = CoreCourseWithImageAndColor & CoreEnrolledCourseData & { categoryname?: string; // Category name, }; /** * Enrolled course data with admin and navigation option availability. */ export type CoreEnrolledCourseDataWithOptions = CoreEnrolledCourseData & { navOptions?: CoreCourseUserAdminOrNavOptionIndexed; admOptions?: CoreCourseUserAdminOrNavOptionIndexed; }; /** * Course summary data with admin and navigation option availability. */ export type CoreCourseSearchedDataWithOptions = CoreCourseSearchedData & { navOptions?: CoreCourseUserAdminOrNavOptionIndexed; admOptions?: CoreCourseUserAdminOrNavOptionIndexed; }; /** * Enrolled course data with admin and navigation option availability and extra rendering info. */ export type CoreEnrolledCourseDataWithExtraInfoAndOptions = CoreEnrolledCourseDataWithExtraInfo & CoreEnrolledCourseDataWithOptions; /** * Searched course data with admin and navigation option availability and extra rendering info. */ export type CoreCourseSearchedDataWithExtraInfoAndOptions = CoreCourseWithImageAndColor & CoreCourseSearchedDataWithOptions; /** * Any course data with admin and navigation option availability and extra rendering info. */ export type CoreCourseAnyCourseDataWithExtraInfoAndOptions = CoreCourseWithImageAndColor & CoreCourseAnyCourseDataWithOptions & { categoryname?: string; // Category name, }; /** * Options for getUserCoursesWithOptionsObservable. */ export type CoreCoursesGetWithOptionsOptions = CoreSitesCommonWSOptions & { sort?: string; // Sort courses after get them. Defaults to 'fullname'. slice?: number; // Slice results to get the X first one. If slice > 0 it will be done after sorting. filter?: string; // Filter using some field. loadCategoryNames?: boolean; // Whether load category names or not. };