diff --git a/.eslintrc.js b/.eslintrc.js index e0a5c3017..11a9fcf55 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -201,6 +201,7 @@ const appConfig = { 'no-duplicate-imports': 'error', 'no-empty': 'error', 'no-eval': 'error', + 'no-fallthrough': 'off', 'no-invalid-this': 'error', 'no-irregular-whitespace': 'error', 'no-multiple-empty-lines': 'error', diff --git a/src/addons/badges/pages/issued-badge/issued-badge.page.ts b/src/addons/badges/pages/issued-badge/issued-badge.page.ts index 928fff65a..058267aa6 100644 --- a/src/addons/badges/pages/issued-badge/issued-badge.page.ts +++ b/src/addons/badges/pages/issued-badge/issued-badge.page.ts @@ -20,8 +20,8 @@ import { CoreSites } from '@services/sites'; import { CoreUser, CoreUserProfile } from '@features/user/services/user'; import { AddonBadges, AddonBadgesUserBadge } from '../../services/badges'; import { CoreUtils } from '@services/utils/utils'; -import { ActivatedRoute } from '@angular/router'; import { CoreCourses, CoreEnrolledCourseData } from '@features/courses/services/courses'; +import { CoreNavigator } from '@services/navigator'; /** * Page that displays the list of calendar events. @@ -42,18 +42,13 @@ export class AddonBadgesIssuedBadgePage implements OnInit { badgeLoaded = false; currentTime = 0; - constructor( - protected route: ActivatedRoute, - ) { } - /** * View loaded. */ ngOnInit(): void { - this.courseId = parseInt(this.route.snapshot.queryParams['courseId'], 10) || this.courseId; // Use 0 for site badges. - this.userId = this.route.snapshot.queryParams['userId'] || - CoreSites.instance.getCurrentSite()?.getUserId(); - this.badgeHash = this.route.snapshot.queryParams['badgeHash']; + this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId') || this.courseId; // Use 0 for site badges. + this.userId = CoreNavigator.instance.getRouteNumberParam('userId') || CoreSites.instance.getCurrentSite()!.getUserId(); + this.badgeHash = CoreNavigator.instance.getRouteParam('badgeHash') || ''; this.fetchIssuedBadge().finally(() => { this.badgeLoaded = true; diff --git a/src/addons/badges/pages/user-badges/user-badges.page.ts b/src/addons/badges/pages/user-badges/user-badges.page.ts index deea55390..dae15b5e1 100644 --- a/src/addons/badges/pages/user-badges/user-badges.page.ts +++ b/src/addons/badges/pages/user-badges/user-badges.page.ts @@ -20,7 +20,6 @@ import { CoreDomUtils } from '@services/utils/dom'; import { CoreSites } from '@services/sites'; import { CoreUtils } from '@services/utils/utils'; import { CoreNavigator } from '@services/navigator'; -import { ActivatedRoute } from '@angular/router'; // @todo import { CoreSplitViewComponent } from '@components/split-view/split-view'; /** @@ -42,18 +41,13 @@ export class AddonBadgesUserBadgesPage implements OnInit { currentTime = 0; badgeHash!: string; - constructor( - protected route: ActivatedRoute, - ) { } - /** * View loaded. */ ngOnInit(): void { - this.courseId = parseInt(this.route.snapshot.queryParams['courseId'], 10) || this.courseId; // Use 0 for site badges. - this.userId = this.route.snapshot.queryParams['userId'] || - CoreSites.instance.getCurrentSite()?.getUserId(); + this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId') || this.courseId; // Use 0 for site badges. + this.userId = CoreNavigator.instance.getRouteNumberParam('userId') || CoreSites.instance.getCurrentSite()!.getUserId(); this.fetchBadges().finally(() => { // @todo splitview diff --git a/src/addons/block/sitemainmenu/components/sitemainmenu/sitemainmenu.ts b/src/addons/block/sitemainmenu/components/sitemainmenu/sitemainmenu.ts index 5893d36cd..f572b5067 100644 --- a/src/addons/block/sitemainmenu/components/sitemainmenu/sitemainmenu.ts +++ b/src/addons/block/sitemainmenu/components/sitemainmenu/sitemainmenu.ts @@ -14,10 +14,10 @@ import { Component, OnInit, Input } from '@angular/core'; import { CoreSites } from '@services/sites'; -import { CoreCourse, CoreCourseSection } from '@features/course/services/course'; -import { CoreCourseHelper } from '@features/course/services/course-helper'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreCourseHelper, CoreCourseSection } from '@features/course/services/course-helper'; import { CoreSiteHome, FrontPageItemNames } from '@features/sitehome/services/sitehome'; -// @todo import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; +import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; import { CoreBlockBaseComponent } from '@features/block/classes/base-block-component'; /** @@ -63,7 +63,7 @@ export class AddonBlockSiteMainMenuComponent extends CoreBlockBaseComponent impl if (this.mainMenuBlock && this.mainMenuBlock.modules) { // Invalidate modules prefetch data. - // @todo promises.push(this.prefetchDelegate.invalidateModules(this.mainMenuBlock.modules, this.siteHomeId)); + promises.push(CoreCourseModulePrefetchDelegate.instance.invalidateModules(this.mainMenuBlock.modules, this.siteHomeId)); } await Promise.all(promises); @@ -77,8 +77,8 @@ export class AddonBlockSiteMainMenuComponent extends CoreBlockBaseComponent impl protected async fetchContent(): Promise { const sections = await CoreCourse.instance.getSections(this.siteHomeId, false, true); - this.mainMenuBlock = sections.find((section) => section.section == 0); - if (!this.mainMenuBlock) { + const mainMenuBlock = sections.find((section) => section.section == 0); + if (!mainMenuBlock) { return; } @@ -91,10 +91,17 @@ export class AddonBlockSiteMainMenuComponent extends CoreBlockBaseComponent impl const items = config.frontpageloggedin.split(','); const hasNewsItem = items.find((item) => parseInt(item, 10) == FrontPageItemNames['NEWS_ITEMS']); - const hasContent = CoreCourseHelper.instance.sectionHasContent(this.mainMenuBlock); - CoreCourseHelper.instance.addHandlerDataForModules([this.mainMenuBlock], this.siteHomeId, undefined, undefined, true); + const result = await CoreCourseHelper.instance.addHandlerDataForModules( + [mainMenuBlock], + this.siteHomeId, + undefined, + undefined, + true, + ); - if (!hasNewsItem || !hasContent) { + this.mainMenuBlock = result.sections[0]; + + if (!hasNewsItem || !this.mainMenuBlock.hasContent) { return; } diff --git a/src/addons/calendar/components/calendar/calendar.scss b/src/addons/calendar/components/calendar/calendar.scss index 1c3433c6c..1575f794f 100644 --- a/src/addons/calendar/components/calendar/calendar.scss +++ b/src/addons/calendar/components/calendar/calendar.scss @@ -149,6 +149,9 @@ display: inline-block; vertical-align: bottom; } + .core-module-icon[slot="start"] { + padding: 6px; + } } :host-context([dir=rtl]) { diff --git a/src/addons/calendar/pages/day/day.page.ts b/src/addons/calendar/pages/day/day.page.ts index 16a6cab8d..d6b4cd8d6 100644 --- a/src/addons/calendar/pages/day/day.page.ts +++ b/src/addons/calendar/pages/day/day.page.ts @@ -37,7 +37,7 @@ import { AddonCalendarFilterPopoverComponent } from '../../components/filter/fil import moment from 'moment'; import { Network, NgZone } from '@singletons'; import { CoreNavigator } from '@services/navigator'; -import { ActivatedRoute, Params } from '@angular/router'; +import { Params } from '@angular/router'; import { Subscription } from 'rxjs'; import { CoreUtils } from '@services/utils/utils'; @@ -101,7 +101,6 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { }; constructor( - protected route: ActivatedRoute, private popoverCtrl: PopoverController, ) { this.currentSiteId = CoreSites.instance.getCurrentSiteId(); @@ -235,19 +234,18 @@ export class AddonCalendarDayPage implements OnInit, OnDestroy { CoreUtils.instance.enumKeys(AddonCalendarEventType).forEach((name) => { const value = AddonCalendarEventType[name]; - const filter = this.route.snapshot.queryParams[name]; - this.filter[name] = typeof filter == 'undefined' ? true : filter; + this.filter[name] = CoreNavigator.instance.getRouteBooleanParam(name) ?? true; types.push(value); }); - this.filter.courseId = parseInt(this.route.snapshot.queryParams['courseId'], 10) || -1; - this.filter.categoryId = parseInt(this.route.snapshot.queryParams['categoryId'], 10) || undefined; + this.filter.courseId = CoreNavigator.instance.getRouteNumberParam('courseId') || -1; + this.filter.categoryId = CoreNavigator.instance.getRouteNumberParam('categoryId'); this.filter.filtered = typeof this.filter.courseId != 'undefined' || types.some((name) => !this.filter[name]); const now = new Date(); - this.year = this.route.snapshot.queryParams['year'] || now.getFullYear(); - this.month = this.route.snapshot.queryParams['month'] || (now.getMonth() + 1); - this.day = this.route.snapshot.queryParams['day'] || now.getDate(); + this.year = CoreNavigator.instance.getRouteNumberParam('year') || now.getFullYear(); + this.month = CoreNavigator.instance.getRouteNumberParam('month') || (now.getMonth() + 1); + this.day = CoreNavigator.instance.getRouteNumberParam('day') || now.getDate(); this.calculateCurrentMoment(); this.calculateIsCurrentDay(); diff --git a/src/addons/calendar/pages/edit-event/edit-event.page.ts b/src/addons/calendar/pages/edit-event/edit-event.page.ts index 2b97b36fc..c757fc200 100644 --- a/src/addons/calendar/pages/edit-event/edit-event.page.ts +++ b/src/addons/calendar/pages/edit-event/edit-event.page.ts @@ -14,7 +14,7 @@ import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core'; import { FormControl, FormGroup, FormBuilder, Validators } from '@angular/forms'; -import { IonRefresher, NavController } from '@ionic/angular'; +import { IonRefresher } from '@ionic/angular'; import { CoreEvents } from '@singletons/events'; import { CoreGroup, CoreGroups } from '@services/groups'; import { CoreSites } from '@services/sites'; @@ -40,9 +40,9 @@ import { AddonCalendarSync, AddonCalendarSyncProvider } from '../../services/cal import { CoreSite } from '@classes/site'; import { Translate } from '@singletons'; import { CoreFilterHelper } from '@features/filter/services/filter-helper'; -import { ActivatedRoute } from '@angular/router'; import { AddonCalendarOfflineEventDBRecord } from '../../services/database/calendar-offline'; import { CoreError } from '@classes/errors/error'; +import { CoreNavigator } from '@services/navigator'; /** * Page that displays a form to create/edit an event. @@ -90,8 +90,6 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy { protected gotEventData = false; constructor( - protected navCtrl: NavController, - protected route: ActivatedRoute, protected fb: FormBuilder, ) { @@ -128,11 +126,11 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy { * Component being initialized. */ ngOnInit(): void { - this.eventId = this.route.snapshot.queryParams['eventId']; - this.courseId = parseInt(this.route.snapshot.queryParams['courseId'], 10) || 0; + this.eventId = CoreNavigator.instance.getRouteNumberParam('eventId'); + this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId') || 0; this.title = this.eventId ? 'addon.calendar.editevent' : 'addon.calendar.newevent'; - const timestamp = parseInt(this.route.snapshot.queryParams['timestamp'], 10); + const timestamp = CoreNavigator.instance.getRouteNumberParam('timestamp'); const currentDate = CoreTimeUtils.instance.toDatetimeFormat(timestamp); this.form.addControl('timestart', this.fb.control(currentDate, Validators.required)); this.form.addControl('timedurationuntil', this.fb.control(currentDate)); @@ -578,7 +576,7 @@ export class AddonCalendarEditEventPage implements OnInit, OnDestroy { this.originalData = CoreUtils.instance.clone(this.form.value); } else {*/ this.originalData = undefined; // Avoid asking for confirmation. - this.navCtrl.pop(); + CoreNavigator.instance.back(); } /** diff --git a/src/addons/calendar/pages/event/event.page.ts b/src/addons/calendar/pages/event/event.page.ts index ecba77a19..225fd8275 100644 --- a/src/addons/calendar/pages/event/event.page.ts +++ b/src/addons/calendar/pages/event/event.page.ts @@ -43,7 +43,6 @@ import { Subscription } from 'rxjs'; import { CoreNavigator } from '@services/navigator'; import { CoreUtils } from '@services/utils/utils'; import { AddonCalendarReminderDBRecord } from '../../services/database/calendar'; -import { ActivatedRoute } from '@angular/router'; /** * Page that displays a single calendar event. @@ -86,11 +85,7 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy { syncIcon = 'spinner'; // Sync icon. isSplitViewOn = false; - constructor( - protected route: ActivatedRoute, - // @Optional() private svComponent: CoreSplitViewComponent, - ) { - + constructor() { this.notificationsEnabled = CoreLocalNotifications.instance.isAvailable(); this.siteHomeId = CoreSites.instance.getCurrentSiteHomeId(); this.currentSiteId = CoreSites.instance.getCurrentSiteId(); @@ -150,8 +145,15 @@ export class AddonCalendarEventPage implements OnInit, OnDestroy { * View loaded. */ ngOnInit(): void { - this.eventId = this.route.snapshot.queryParams['id']; + const eventId = CoreNavigator.instance.getRouteNumberParam('id'); + if (!eventId) { + CoreDomUtils.instance.showErrorModal('Event ID not supplied.'); + CoreNavigator.instance.back(); + return; + } + + this.eventId = eventId; this.syncIcon = 'spinner'; this.fetchEvent(); diff --git a/src/addons/calendar/pages/index/index.page.ts b/src/addons/calendar/pages/index/index.page.ts index d0bdb8a19..60a7eb3bc 100644 --- a/src/addons/calendar/pages/index/index.page.ts +++ b/src/addons/calendar/pages/index/index.page.ts @@ -168,12 +168,12 @@ export class AddonCalendarIndexPage implements OnInit, OnDestroy { ngOnInit(): void { this.notificationsEnabled = CoreLocalNotifications.instance.isAvailable(); - this.route.queryParams.subscribe(params => { - this.eventId = parseInt(params['eventId'], 10) || undefined; - this.filter.courseId = parseInt(params['courseId'], 10) || -1; - this.year = parseInt(params['year'], 10) || undefined; - this.month = parseInt(params['month'], 10) || undefined; - this.loadUpcoming = !!params['upcoming']; + this.route.queryParams.subscribe(() => { + this.eventId = CoreNavigator.instance.getRouteNumberParam('eventId'); + this.filter.courseId = CoreNavigator.instance.getRouteNumberParam('courseId') || -1; + this.year = CoreNavigator.instance.getRouteNumberParam('year'); + this.month = CoreNavigator.instance.getRouteNumberParam('month'); + this.loadUpcoming = !!CoreNavigator.instance.getRouteBooleanParam('upcoming'); this.showCalendar = !this.loadUpcoming; this.filter.filtered = this.filter.courseId > 0; diff --git a/src/addons/calendar/pages/list/list.page.ts b/src/addons/calendar/pages/list/list.page.ts index 5b99ee493..93451166f 100644 --- a/src/addons/calendar/pages/list/list.page.ts +++ b/src/addons/calendar/pages/list/list.page.ts @@ -34,7 +34,7 @@ import { CoreApp } from '@services/app'; import moment from 'moment'; import { CoreConstants } from '@/core/constants'; import { AddonCalendarFilterPopoverComponent } from '../../components/filter/filter'; -import { ActivatedRoute, Params } from '@angular/router'; +import { Params } from '@angular/router'; import { Subscription } from 'rxjs'; import { Network, NgZone } from '@singletons'; import { CoreCoursesHelper } from '@features/courses/services/courses-helper'; @@ -102,7 +102,6 @@ export class AddonCalendarListPage implements OnInit, OnDestroy { }; constructor( - protected route: ActivatedRoute, private popoverCtrl: PopoverController, ) { @@ -248,8 +247,8 @@ export class AddonCalendarListPage implements OnInit, OnDestroy { * View loaded. */ async ngOnInit(): Promise { - this.eventId = this.route.snapshot.queryParams['eventId'] || undefined; - this.filter.courseId = this.route.snapshot.queryParams['courseId']; + this.eventId = CoreNavigator.instance.getRouteNumberParam('eventId'); + this.filter.courseId = CoreNavigator.instance.getRouteNumberParam('courseId') || -1; if (this.eventId) { // There is an event to load, open the event in a new state. diff --git a/src/addons/filter/mediaplugin/services/handlers/mediaplugin.ts b/src/addons/filter/mediaplugin/services/handlers/mediaplugin.ts index 552df81e3..9d3712100 100644 --- a/src/addons/filter/mediaplugin/services/handlers/mediaplugin.ts +++ b/src/addons/filter/mediaplugin/services/handlers/mediaplugin.ts @@ -61,7 +61,6 @@ export class AddonFilterMediaPluginHandlerService extends CoreFilterDefaultHandl * Treat video filters. Currently only treating youtube video using video JS. * * @param el Video element. - * @param navCtrl NavController to use. */ protected treatVideoFilters(video: HTMLElement): void { // Treat Video JS Youtube video links and translate them to iframes. diff --git a/src/addons/notifications/components/actions/actions.ts b/src/addons/notifications/components/actions/actions.ts index cd0ceb35b..8a4a4e58a 100644 --- a/src/addons/notifications/components/actions/actions.ts +++ b/src/addons/notifications/components/actions/actions.ts @@ -74,7 +74,6 @@ export class AddonNotificationsActionsComponent implements OnInit { * Default action. Open in browser. * * @param siteId Site ID to use. - * @param navCtrl NavController. */ protected async openInBrowser(siteId?: string): Promise { const url = this.data?.appurl || this.contextUrl; diff --git a/src/addons/notifications/pages/settings/settings.ts b/src/addons/notifications/pages/settings/settings.ts index fd24867d5..f93df756a 100644 --- a/src/addons/notifications/pages/settings/settings.ts +++ b/src/addons/notifications/pages/settings/settings.ts @@ -13,7 +13,7 @@ // limitations under the License. import { Component, OnInit, OnDestroy } from '@angular/core'; -import { IonRefresher, NavController } from '@ionic/angular'; +import { IonRefresher } from '@ionic/angular'; import { CoreConfig } from '@services/config'; import { CoreLocalNotifications } from '@services/local-notifications'; @@ -61,10 +61,7 @@ export class AddonNotificationsSettingsPage implements OnInit, OnDestroy { protected updateTimeout?: number; - constructor( - protected navCtrl: NavController, - // @Optional() protected svComponent: CoreSplitViewComponent, - ) { + constructor() { // @todo @Optional() protected svComponent: CoreSplitViewComponent, this.notifPrefsEnabled = AddonNotifications.instance.isNotificationPreferencesEnabled(); this.canChangeSound = CoreLocalNotifications.instance.canDisableSound(); } diff --git a/src/addons/privatefiles/pages/index/index.ts b/src/addons/privatefiles/pages/index/index.ts index c377e7cfe..3a0743bfa 100644 --- a/src/addons/privatefiles/pages/index/index.ts +++ b/src/addons/privatefiles/pages/index/index.ts @@ -13,8 +13,7 @@ // limitations under the License. import { Component, OnDestroy, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { IonRefresher, NavController } from '@ionic/angular'; +import { IonRefresher } from '@ionic/angular'; import { Md5 } from 'ts-md5/dist/md5'; import { CoreApp } from '@services/app'; @@ -32,6 +31,7 @@ import { } from '@/addons/privatefiles/services/privatefiles'; import { AddonPrivateFilesHelper } from '@/addons/privatefiles/services/privatefiles-helper'; import { CoreUtils } from '@services/utils/utils'; +import { CoreNavigator } from '@services/navigator'; /** * Page that displays the list of files. @@ -58,10 +58,7 @@ export class AddonPrivateFilesIndexPage implements OnInit, OnDestroy { protected updateSiteObserver: CoreEventObserver; - constructor( - protected route: ActivatedRoute, - protected navCtrl: NavController, - ) { + constructor() { // Update visibility if current site info is updated. this.updateSiteObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, () => { this.setVisibility(); @@ -72,17 +69,18 @@ export class AddonPrivateFilesIndexPage implements OnInit, OnDestroy { * Component being initialized. */ ngOnInit(): void { - this.root = this.route.snapshot.queryParams['root']; + this.root = CoreNavigator.instance.getRouteParam('root'); + const contextId = CoreNavigator.instance.getRouteNumberParam('contextid'); - if (this.route.snapshot.queryParams['contextid']) { + if (contextId) { // Loading a certain folder. this.path = { - contextid: this.route.snapshot.queryParams['contextid'], - component: this.route.snapshot.queryParams['component'], - filearea: this.route.snapshot.queryParams['filearea'], - itemid: this.route.snapshot.queryParams['itemid'], - filepath: this.route.snapshot.queryParams['filepath'], - filename: this.route.snapshot.queryParams['filename'], + contextid: contextId, + component: CoreNavigator.instance.getRouteParam('component')!, + filearea: CoreNavigator.instance.getRouteParam('filearea')!, + itemid: CoreNavigator.instance.getRouteNumberParam('itemid')!, + filepath: CoreNavigator.instance.getRouteParam('filepath')!, + filename: CoreNavigator.instance.getRouteParam('filename')!, }; } @@ -254,10 +252,7 @@ export class AddonPrivateFilesIndexPage implements OnInit, OnDestroy { const hash = Md5.hashAsciiStr(JSON.stringify(params)); - this.navCtrl.navigateForward([`../${hash}`], { - relativeTo: this.route, - queryParams: params, - }); + CoreNavigator.instance.navigate(`../${hash}`, { params }); } /** diff --git a/src/app/app.component.test.ts b/src/app/app.component.test.ts index 216b99dfe..88a874411 100644 --- a/src/app/app.component.test.ts +++ b/src/app/app.component.test.ts @@ -13,7 +13,6 @@ // limitations under the License. import { Observable } from 'rxjs'; -import { NavController } from '@ionic/angular'; import { AppComponent } from '@/app/app.component'; import { CoreApp } from '@services/app'; @@ -22,11 +21,12 @@ import { CoreLangProvider } from '@services/lang'; import { Network, Platform, NgZone } from '@singletons'; import { mock, mockSingleton, renderComponent, RenderConfig } from '@/testing/utils'; +import { CoreNavigator, CoreNavigatorService } from '@services/navigator'; describe('AppComponent', () => { let langProvider: CoreLangProvider; - let navController: NavController; + let navigator: CoreNavigatorService; let config: Partial; beforeEach(() => { @@ -35,12 +35,11 @@ describe('AppComponent', () => { mockSingleton(Platform, { ready: () => Promise.resolve() }); mockSingleton(NgZone, { run: jest.fn() }); + navigator = mockSingleton(CoreNavigator, ['navigate']); langProvider = mock(['clearCustomStrings']); - navController = mock(['navigateRoot']); config = { providers: [ { provide: CoreLangProvider, useValue: langProvider }, - { provide: NavController, useValue: navController }, ], }; }); @@ -59,7 +58,7 @@ describe('AppComponent', () => { CoreEvents.trigger(CoreEvents.LOGOUT); expect(langProvider.clearCustomStrings).toHaveBeenCalled(); - expect(navController.navigateRoot).toHaveBeenCalledWith('/login/sites'); + expect(navigator.navigate).toHaveBeenCalledWith('/login/sites', { reset: true }); }); it.todo('shows loading while app isn\'t ready'); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 5b8f9f36b..68ce542f6 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -13,7 +13,6 @@ // limitations under the License. import { Component, OnInit } from '@angular/core'; -import { NavController } from '@ionic/angular'; import { CoreLangProvider } from '@services/lang'; import { CoreLoginHelperProvider } from '@features/login/services/login-helper'; @@ -27,6 +26,7 @@ import { import { Network, NgZone, Platform } from '@singletons'; import { CoreApp } from '@services/app'; import { CoreSites } from '@services/sites'; +import { CoreNavigator } from '@services/navigator'; @Component({ selector: 'app-root', @@ -37,7 +37,6 @@ export class AppComponent implements OnInit { constructor( protected langProvider: CoreLangProvider, - protected navCtrl: NavController, protected loginHelper: CoreLoginHelperProvider, ) { } @@ -56,7 +55,7 @@ export class AppComponent implements OnInit { ngOnInit(): void { CoreEvents.on(CoreEvents.LOGOUT, () => { // Go to sites page when user is logged out. - this.navCtrl.navigateRoot('/login/sites'); + CoreNavigator.instance.navigate('/login/sites', { reset: true }); // Unload lang custom strings. this.langProvider.clearCustomStrings(); diff --git a/src/assets/img/completion/completion-auto-fail.svg b/src/assets/img/completion/completion-auto-fail.svg new file mode 100644 index 000000000..771adf36f --- /dev/null +++ b/src/assets/img/completion/completion-auto-fail.svg @@ -0,0 +1,18 @@ + + + +]> + + + + + + diff --git a/src/assets/img/completion/completion-auto-n-override.svg b/src/assets/img/completion/completion-auto-n-override.svg new file mode 100644 index 000000000..6100638d0 --- /dev/null +++ b/src/assets/img/completion/completion-auto-n-override.svg @@ -0,0 +1,3 @@ + +]> \ No newline at end of file diff --git a/src/assets/img/completion/completion-auto-n.svg b/src/assets/img/completion/completion-auto-n.svg new file mode 100644 index 000000000..6a8bc6222 --- /dev/null +++ b/src/assets/img/completion/completion-auto-n.svg @@ -0,0 +1,15 @@ + + + +]> + + + + + diff --git a/src/assets/img/completion/completion-auto-pass.svg b/src/assets/img/completion/completion-auto-pass.svg new file mode 100644 index 000000000..44df83f15 --- /dev/null +++ b/src/assets/img/completion/completion-auto-pass.svg @@ -0,0 +1,17 @@ + + + +]> + + + + + + diff --git a/src/assets/img/completion/completion-auto-y-override.svg b/src/assets/img/completion/completion-auto-y-override.svg new file mode 100644 index 000000000..13cf5d700 --- /dev/null +++ b/src/assets/img/completion/completion-auto-y-override.svg @@ -0,0 +1,3 @@ + +]> \ No newline at end of file diff --git a/src/assets/img/completion/completion-auto-y.svg b/src/assets/img/completion/completion-auto-y.svg new file mode 100644 index 000000000..14822e173 --- /dev/null +++ b/src/assets/img/completion/completion-auto-y.svg @@ -0,0 +1,17 @@ + + + +]> + + + + + + diff --git a/src/assets/img/completion/completion-manual-n-override.svg b/src/assets/img/completion/completion-manual-n-override.svg new file mode 100644 index 000000000..cccfb99cd --- /dev/null +++ b/src/assets/img/completion/completion-manual-n-override.svg @@ -0,0 +1,3 @@ + +]> \ No newline at end of file diff --git a/src/assets/img/completion/completion-manual-n.svg b/src/assets/img/completion/completion-manual-n.svg new file mode 100644 index 000000000..f7750e25a --- /dev/null +++ b/src/assets/img/completion/completion-manual-n.svg @@ -0,0 +1,14 @@ + + + +]> + + + + + diff --git a/src/assets/img/completion/completion-manual-y-override.svg b/src/assets/img/completion/completion-manual-y-override.svg new file mode 100644 index 000000000..69270ba3e --- /dev/null +++ b/src/assets/img/completion/completion-manual-y-override.svg @@ -0,0 +1,3 @@ + +]> \ No newline at end of file diff --git a/src/assets/img/completion/completion-manual-y.svg b/src/assets/img/completion/completion-manual-y.svg new file mode 100644 index 000000000..3b91bdbc7 --- /dev/null +++ b/src/assets/img/completion/completion-manual-y.svg @@ -0,0 +1,17 @@ + + + +]> + + + + + + diff --git a/src/core/classes/errors/network-error.ts b/src/core/classes/errors/network-error.ts new file mode 100644 index 000000000..c59c21045 --- /dev/null +++ b/src/core/classes/errors/network-error.ts @@ -0,0 +1,26 @@ +// (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 { Translate } from '@singletons'; + +/** + * Network error. It will automatically set the right error message if none is provided. + */ +export class CoreNetworkError extends Error { + + constructor(message?: string) { + super(message || Translate.instance.instant('core.networkerrormsg')); + } + +} diff --git a/src/core/classes/site.ts b/src/core/classes/site.ts index cc24d2297..3f1d67d64 100644 --- a/src/core/classes/site.ts +++ b/src/core/classes/site.ts @@ -26,6 +26,7 @@ import { CoreWSAjaxPreSets, CoreWSExternalWarning, CoreWSUploadFileResult, + CoreWSPreSetsSplitRequest, } from '@services/ws'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreTextUtils } from '@services/utils/text'; @@ -516,6 +517,7 @@ export class CoreSite { cleanUnicode: this.cleanUnicode, typeExpected: preSets.typeExpected, responseExpected: preSets.responseExpected, + splitRequest: preSets.splitRequest, }; if (wsPreSets.cleanUnicode && CoreTextUtils.instance.hasUnicodeData(data)) { @@ -2052,6 +2054,12 @@ export type CoreSiteWSPreSets = { * Component id. Optionally included when 'component' is set. */ componentId?: number; + + /** + * Whether to split a request if it has too many parameters. Sending too many parameters to the site + * can cause the request to fail (see PHP's max_input_vars). + */ + splitRequest?: CoreWSPreSetsSplitRequest; }; /** diff --git a/src/core/components/dynamic-component/dynamic-component.ts b/src/core/components/dynamic-component/dynamic-component.ts index 943ec0a4c..b4ffa9f6d 100644 --- a/src/core/components/dynamic-component/dynamic-component.ts +++ b/src/core/components/dynamic-component/dynamic-component.ts @@ -24,12 +24,10 @@ import { KeyValueDiffers, SimpleChange, ChangeDetectorRef, - Optional, ElementRef, KeyValueDiffer, Type, } from '@angular/core'; -import { NavController } from '@ionic/angular'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreLogger } from '@singletons/logger'; @@ -57,7 +55,7 @@ import { CoreLogger } from '@singletons/logger'; * * Alternatively, you can also supply a ComponentRef instead of the class of the component. In this case, the component won't * be instantiated because it already is, it will be attached to the view and the right data will be passed to it. - * Passing ComponentRef is meant for site plugins, so we'll inject a NavController instance to the component. + * Passing ComponentRef is meant for site plugins. * * The contents of this component will be displayed if no component is supplied or it cannot be created. In the example above, * if no component is supplied then the template will show the message "Cannot render the data.". @@ -90,7 +88,6 @@ export class CoreDynamicComponent implements OnChanges, DoCheck { constructor( protected factoryResolver: ComponentFactoryResolver, differs: KeyValueDiffers, - @Optional() protected navCtrl: NavController, protected cdr: ChangeDetectorRef, protected element: ElementRef, ) { @@ -167,7 +164,6 @@ export class CoreDynamicComponent implements OnChanges, DoCheck { // This feature is usually meant for site plugins. Inject some properties. this.instance['ChangeDetectorRef'] = this.cdr; - this.instance['NavController'] = this.navCtrl; this.instance['componentContainer'] = this.element.nativeElement; } else { try { diff --git a/src/core/components/iframe/iframe.ts b/src/core/components/iframe/iframe.ts index c3fa0e8b2..d934b4174 100644 --- a/src/core/components/iframe/iframe.ts +++ b/src/core/components/iframe/iframe.ts @@ -16,7 +16,6 @@ import { Component, Input, Output, ViewChild, ElementRef, EventEmitter, OnChanges, SimpleChange, } from '@angular/core'; import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; -import { NavController } from '@ionic/angular'; import { CoreFile } from '@services/file'; import { CoreDomUtils } from '@services/utils/dom'; @@ -47,7 +46,6 @@ export class CoreIframeComponent implements OnChanges { constructor( protected sanitizer: DomSanitizer, - protected navCtrl: NavController, ) { this.logger = CoreLogger.getInstance('CoreIframe'); @@ -77,7 +75,8 @@ export class CoreIframeComponent implements OnChanges { this.loading = !this.src || !CoreUrlUtils.instance.isLocalFileUrl(this.src); // @todo const navCtrl = this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl; - CoreIframeUtils.instance.treatFrame(iframe, false, this.navCtrl); + // CoreIframeUtils.instance.treatFrame(iframe, false, this.navCtrl); + CoreIframeUtils.instance.treatFrame(iframe, false); iframe.addEventListener('load', () => { this.loading = false; diff --git a/src/core/components/tabs/core-tabs.html b/src/core/components/tabs/core-tabs.html index a1cb8137a..815137ca6 100644 --- a/src/core/components/tabs/core-tabs.html +++ b/src/core/components/tabs/core-tabs.html @@ -1,5 +1,5 @@ - + diff --git a/src/core/components/tabs/tabs.ts b/src/core/components/tabs/tabs.ts index 760e93b54..a8eb31188 100644 --- a/src/core/components/tabs/tabs.ts +++ b/src/core/components/tabs/tabs.ts @@ -24,18 +24,18 @@ import { ViewChild, ElementRef, } from '@angular/core'; -import { Platform, IonSlides, IonTabs, NavController } from '@ionic/angular'; +import { Platform, IonSlides, IonTabs } from '@ionic/angular'; import { TranslateService } from '@ngx-translate/core'; import { Subscription } from 'rxjs'; import { CoreApp } from '@services/app'; import { CoreConfig } from '@services/config'; import { CoreConstants } from '@/core/constants'; import { CoreUtils } from '@services/utils/utils'; -import { NavigationOptions } from '@ionic/angular/providers/nav-controller'; import { Params } from '@angular/router'; import { CoreNavBarButtonsComponent } from '../navbar-buttons/navbar-buttons'; import { CoreDomUtils } from '@services/utils/dom'; import { StackEvent } from '@ionic/angular/directives/navigation/stack-utils'; +import { CoreNavigator } from '@services/navigator'; /** * This component displays some top scrollable tabs that will autohide on vertical scroll. @@ -106,7 +106,7 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe protected unregisterBackButtonAction: any; protected languageChangedSubscription: Subscription; protected isInTransition = false; // Weather Slides is in transition. - protected slidesSwiper: any; + protected slidesSwiper: any; // eslint-disable-line @typescript-eslint/no-explicit-any protected slidesSwiperLoaded = false; protected stackEventsSubscription?: Subscription; @@ -114,7 +114,6 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe protected element: ElementRef, platform: Platform, translate: TranslateService, - protected navCtrl: NavController, ) { this.direction = platform.isRTL ? 'rtl' : 'ltr'; @@ -338,7 +337,7 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe return; } - this.firstSelectedTab = selectedTab.id; + this.firstSelectedTab = selectedTab.id!; this.selectTab(this.firstSelectedTab); // Setup tab scrolling. @@ -548,18 +547,31 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe } /** - * Tab selected. + * Select a tab by ID. * - * @param tabId Selected tab index. + * @param tabId Tab ID. * @param e Event. + * @return Promise resolved when done. */ async selectTab(tabId: string, e?: Event): Promise { - let index = this.tabs.findIndex((tab) => tabId == tab.id); + const index = this.tabs.findIndex((tab) => tabId == tab.id); + + return this.selectByIndex(index, e); + } + + /** + * Select a tab by index. + * + * @param index Index to select. + * @param e Event. + * @return Promise resolved when done. + */ + async selectByIndex(index: number, e?: Event): Promise { if (index < 0 || index >= this.tabs.length) { if (this.selected) { // Invalid index do not change tab. - e && e.preventDefault(); - e && e.stopPropagation(); + e?.preventDefault(); + e?.stopPropagation(); return; } @@ -568,12 +580,11 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe index = 0; } - const selectedTab = this.tabs[index]; - if (tabId == this.selected || !selectedTab || !selectedTab.enabled) { + const tabToSelect = this.tabs[index]; + if (!tabToSelect || !tabToSelect.enabled || tabToSelect.id == this.selected) { // Already selected or not enabled. - - e && e.preventDefault(); - e && e.stopPropagation(); + e?.preventDefault(); + e?.stopPropagation(); return; } @@ -582,18 +593,16 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe await this.slides!.slideTo(index); } - const pageParams: NavigationOptions = {}; - if (selectedTab.pageParams) { - pageParams.queryParams = selectedTab.pageParams; - } - const ok = await this.navCtrl.navigateForward(selectedTab.page, pageParams); + const ok = await CoreNavigator.instance.navigate(tabToSelect.page, { + params: tabToSelect.pageParams, + }); if (ok !== false) { - this.selectHistory.push(tabId); - this.selected = tabId; + this.selectHistory.push(tabToSelect.id!); + this.selected = tabToSelect.id; this.selectedIndex = index; - this.ionChange.emit(selectedTab); + this.ionChange.emit(tabToSelect); } } @@ -644,16 +653,14 @@ export class CoreTabsComponent implements OnInit, AfterViewInit, OnChanges, OnDe /** * Core Tab class. */ -class CoreTab { - - id = ''; // Unique tab id. - class = ''; // Class, if needed. - title = ''; // The translatable tab title. +export type CoreTab = { + page: string; // Page to navigate to. + title: string; // The translatable tab title. + id?: string; // Unique tab id. + class?: string; // Class, if needed. icon?: string; // The tab icon. badge?: string; // A badge to add in the tab. badgeStyle?: string; // The badge color. - enabled = true; // Whether the tab is enabled. - page = ''; // Page to navigate to. + enabled?: boolean; // Whether the tab is enabled. pageParams?: Params; // Page params. - -} +}; diff --git a/src/core/components/user-avatar/user-avatar.ts b/src/core/components/user-avatar/user-avatar.ts index 2e8348a76..88077a8f4 100644 --- a/src/core/components/user-avatar/user-avatar.ts +++ b/src/core/components/user-avatar/user-avatar.ts @@ -13,15 +13,13 @@ // limitations under the License. import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChange } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { NavController } from '@ionic/angular'; import { CoreApp } from '@services/app'; import { CoreSites } from '@services/sites'; import { CoreUtils } from '@services/utils/utils'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; -import { CoreObject } from '@singletons/object'; import { CoreUserProvider, CoreUserBasicData, CoreUserProfilePictureUpdatedData } from '@features/user/services/user'; +import { CoreNavigator } from '@services/navigator'; /** * Component to display a "user avatar". @@ -53,10 +51,7 @@ export class CoreUserAvatarComponent implements OnInit, OnChanges, OnDestroy { protected currentUserId: number; protected pictureObserver: CoreEventObserver; - constructor( - protected navCtrl: NavController, - protected route: ActivatedRoute, - ) { + constructor() { this.currentUserId = CoreSites.instance.getCurrentSiteUserId(); this.pictureObserver = CoreEvents.on( @@ -143,12 +138,11 @@ export class CoreUserAvatarComponent implements OnInit, OnChanges, OnDestroy { event.stopPropagation(); // @todo Decide which navCtrl to use. If this component is inside a split view, use the split view's master nav. - this.navCtrl.navigateForward(['user'], { - relativeTo: this.route, - queryParams: CoreObject.withoutEmpty({ + CoreNavigator.instance.navigateToSitePath('user', { + params: { userId: this.userId, courseId: this.courseId, - }), + }, }); } diff --git a/src/core/directives/format-text.ts b/src/core/directives/format-text.ts index 53574a3e5..67b19b95c 100644 --- a/src/core/directives/format-text.ts +++ b/src/core/directives/format-text.ts @@ -23,7 +23,7 @@ import { Optional, ViewContainerRef, } from '@angular/core'; -import { NavController, IonContent } from '@ionic/angular'; +import { IonContent } from '@ionic/angular'; import { CoreEventLoadingChangedData, CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreSites } from '@services/sites'; @@ -84,7 +84,6 @@ export class CoreFormatTextDirective implements OnChanges { constructor( element: ElementRef, - @Optional() protected navCtrl: NavController, @Optional() protected content: IonContent, protected viewContainerRef: ViewContainerRef, ) { @@ -471,7 +470,8 @@ export class CoreFormatTextDirective implements OnChanges { */ protected async treatHTMLElements(div: HTMLElement, site?: CoreSite): Promise { const canTreatVimeo = site?.isVersionGreaterEqualThan(['3.3.4', '3.4']) || false; - const navCtrl = this.navCtrl; // @todo this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl; + // @todo this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl; + // @todo: Pass navCtrl to all treateFrame calls? const images = Array.from(div.querySelectorAll('img')); const anchors = Array.from(div.querySelectorAll('a')); @@ -521,7 +521,7 @@ export class CoreFormatTextDirective implements OnChanges { }); iframes.forEach((iframe) => { - this.treatIframe(iframe, site, canTreatVimeo, navCtrl); + this.treatIframe(iframe, site, canTreatVimeo); }); svgImages.forEach((image) => { @@ -554,7 +554,7 @@ export class CoreFormatTextDirective implements OnChanges { // Handle all kind of frames. frames.forEach((frame: HTMLFrameElement | HTMLObjectElement | HTMLEmbedElement) => { - CoreIframeUtils.instance.treatFrame(frame, false, navCtrl); + CoreIframeUtils.instance.treatFrame(frame, false); }); CoreDomUtils.instance.handleBootstrapTooltips(div); @@ -671,13 +671,11 @@ export class CoreFormatTextDirective implements OnChanges { * @param iframe Iframe to treat. * @param site Site instance. * @param canTreatVimeo Whether Vimeo videos can be treated in the site. - * @param navCtrl NavController to use. */ protected async treatIframe( iframe: HTMLIFrameElement, site: CoreSite | undefined, canTreatVimeo: boolean, - navCtrl: NavController, ): Promise { const src = iframe.src; const currentSite = CoreSites.instance.getCurrentSite(); @@ -689,8 +687,7 @@ export class CoreFormatTextDirective implements OnChanges { const finalUrl = await currentSite.getAutoLoginUrl(src, false); iframe.src = finalUrl; - - CoreIframeUtils.instance.treatFrame(iframe, false, navCtrl); + CoreIframeUtils.instance.treatFrame(iframe, false); return; } @@ -751,7 +748,7 @@ export class CoreFormatTextDirective implements OnChanges { } } - CoreIframeUtils.instance.treatFrame(iframe, false, navCtrl); + CoreIframeUtils.instance.treatFrame(iframe, false); } /** diff --git a/src/core/directives/tests/format-text.test.ts b/src/core/directives/tests/format-text.test.ts index 7d2e8d4fd..498d3fb79 100644 --- a/src/core/directives/tests/format-text.test.ts +++ b/src/core/directives/tests/format-text.test.ts @@ -13,7 +13,7 @@ // limitations under the License. import { DomSanitizer } from '@angular/platform-browser'; -import { IonContent, NavController } from '@ionic/angular'; +import { IonContent } from '@ionic/angular'; import { NgZone } from '@angular/core'; import Faker from 'faker'; @@ -44,7 +44,6 @@ describe('CoreFormatTextDirective', () => { config = { providers: [ - { provide: NavController, useValue: null }, { provide: IonContent, useValue: null }, ], }; diff --git a/src/core/directives/user-link.ts b/src/core/directives/user-link.ts index b8b2cf2c1..9413d94e0 100644 --- a/src/core/directives/user-link.ts +++ b/src/core/directives/user-link.ts @@ -13,8 +13,6 @@ // limitations under the License. import { Directive, Input, OnInit, ElementRef } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { NavController } from '@ionic/angular'; import { CoreNavigator } from '@services/navigator'; import { CoreObject } from '@singletons/object'; @@ -34,8 +32,6 @@ export class CoreUserLinkDirective implements OnInit { constructor( element: ElementRef, - protected navCtrl: NavController, - protected route: ActivatedRoute, ) { this.element = element.nativeElement; } diff --git a/src/core/features/block/components/course-blocks/core-block-course-blocks.html b/src/core/features/block/components/course-blocks/core-block-course-blocks.html index a875f46e4..3743bb42d 100644 --- a/src/core/features/block/components/course-blocks/core-block-course-blocks.html +++ b/src/core/features/block/components/course-blocks/core-block-course-blocks.html @@ -7,7 +7,8 @@ - + diff --git a/src/core/features/block/components/course-blocks/course-blocks.scss b/src/core/features/block/components/course-blocks/course-blocks.scss index 96774170f..04f290e4f 100644 --- a/src/core/features/block/components/course-blocks/course-blocks.scss +++ b/src/core/features/block/components/course-blocks/course-blocks.scss @@ -21,6 +21,7 @@ max-width: var(--side-blocks-max-width); min-width: var(--side-blocks-min-width); box-shadow: -4px 0px 16px rgba(0, 0, 0, 0.18); + z-index: 2; // @todo @include core-split-area-end(); } diff --git a/src/core/features/block/components/course-blocks/course-blocks.ts b/src/core/features/block/components/course-blocks/course-blocks.ts index c95ab480c..a1eb6b785 100644 --- a/src/core/features/block/components/course-blocks/course-blocks.ts +++ b/src/core/features/block/components/course-blocks/course-blocks.ts @@ -18,6 +18,7 @@ import { CoreDomUtils } from '@services/utils/dom'; import { CoreCourse, CoreCourseBlock } from '@features/course/services/course'; import { CoreBlockHelper } from '../../services/block-helper'; import { CoreBlockComponent } from '../block/block'; +import { CoreUtils } from '@services/utils/utils'; /** * Component that displays the list of course blocks. @@ -108,4 +109,15 @@ export class CoreBlockCourseBlocksComponent implements OnInit { } } + /** + * Refresh data. + * + * @return Promise resolved when done. + */ + async doRefresh(): Promise { + await CoreUtils.instance.ignoreErrors(this.invalidateBlocks()); + + await this.loadContent(); + } + } diff --git a/src/core/features/contentlinks/classes/module-list-handler.ts b/src/core/features/contentlinks/classes/module-list-handler.ts index f1c5c376a..0a05df9e1 100644 --- a/src/core/features/contentlinks/classes/module-list-handler.ts +++ b/src/core/features/contentlinks/classes/module-list-handler.ts @@ -16,6 +16,7 @@ import { CoreContentLinksHandlerBase } from './base-handler'; import { Translate } from '@singletons'; import { Params } from '@angular/router'; import { CoreContentLinksAction } from '../services/contentlinks-delegate'; +import { CoreNavigator } from '@services/navigator'; /** * Handler to handle URLs pointing to a list of a certain type of modules. @@ -55,16 +56,15 @@ export class CoreContentLinksModuleListHandler extends CoreContentLinksHandlerBa getActions(siteIds: string[], url: string, params: Params): CoreContentLinksAction[] | Promise { return [{ - // eslint-disable-next-line @typescript-eslint/no-unused-vars action: (siteId): void => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const stateParams = { - courseId: params.id, - modName: this.modName, - title: this.title || Translate.instance.instant('addon.mod_' + this.modName + '.modulenameplural'), - }; - - // @todo CoreNavigator.instance.goInSite('CoreCourseListModTypePage', stateParams, siteId); + CoreNavigator.instance.navigateToSitePath('course/list-mod-type', { + params: { + courseId: params.id, + modName: this.modName, + title: this.title || Translate.instance.instant('addon.mod_' + this.modName + '.modulenameplural'), + }, + siteId, + }); }, }]; } diff --git a/src/core/features/contentlinks/pages/choose-site/choose-site.ts b/src/core/features/contentlinks/pages/choose-site/choose-site.ts index 2a95d6e56..d04005b5c 100644 --- a/src/core/features/contentlinks/pages/choose-site/choose-site.ts +++ b/src/core/features/contentlinks/pages/choose-site/choose-site.ts @@ -13,13 +13,11 @@ // limitations under the License. import { Component, OnInit } from '@angular/core'; -import { NavController } from '@ionic/angular'; import { CoreSiteBasicInfo, CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; import { Translate } from '@singletons'; import { CoreContentLinksAction } from '../../services/contentlinks-delegate'; import { CoreContentLinksHelper } from '../../services/contentlinks-helper'; -import { ActivatedRoute } from '@angular/router'; import { CoreError } from '@classes/errors/error'; import { CoreNavigator } from '@services/navigator'; @@ -34,27 +32,22 @@ import { CoreNavigator } from '@services/navigator'; }) export class CoreContentLinksChooseSitePage implements OnInit { - url: string; + url!: string; sites: CoreSiteBasicInfo[] = []; loaded = false; protected action?: CoreContentLinksAction; protected isRootURL = false; - constructor( - route: ActivatedRoute, - protected navCtrl: NavController, - ) { - this.url = route.snapshot.queryParamMap.get('url')!; - } - /** * Component being initialized. */ async ngOnInit(): Promise { - if (!this.url) { + const url = CoreNavigator.instance.getRouteParam('url'); + if (!url) { return this.leaveView(); } + this.url = url; let siteIds: string[] | undefined = []; try { @@ -115,7 +108,7 @@ export class CoreContentLinksChooseSitePage implements OnInit { try { await CoreSites.instance.logout(); } finally { - await this.navCtrl.navigateRoot('/login/sites'); + await CoreNavigator.instance.navigate('/login/sites', { reset: true }); } } diff --git a/src/core/features/contentlinks/services/contentlinks-helper.ts b/src/core/features/contentlinks/services/contentlinks-helper.ts index f789767d7..a198a1fa9 100644 --- a/src/core/features/contentlinks/services/contentlinks-helper.ts +++ b/src/core/features/contentlinks/services/contentlinks-helper.ts @@ -20,6 +20,7 @@ import { CoreContentLinksDelegate, CoreContentLinksAction } from './contentlinks import { CoreSite } from '@classes/site'; import { makeSingleton, Translate } from '@singletons'; import { CoreNavigator } from '@services/navigator'; +import { Params } from '@angular/router'; /** * Service that provides some features regarding content links. @@ -27,10 +28,6 @@ import { CoreNavigator } from '@services/navigator'; @Injectable({ providedIn: 'root' }) export class CoreContentLinksHelperProvider { - constructor( - protected navCtrl: NavController, - ) { } - /** * Check whether a link can be handled by the app. * @@ -93,8 +90,7 @@ export class CoreContentLinksHelperProvider { * @return Promise resolved when done. * @deprecated since 3.9.5. Use CoreNavigator.navigateToSitePath instead. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async goInSite(navCtrl: NavController, pageName: string, pageParams: any, siteId?: string): Promise { + async goInSite(navCtrl: NavController, pageName: string, pageParams: Params, siteId?: string): Promise { await CoreNavigator.instance.navigateToSitePath(pageName, { params: pageParams, siteId }); } @@ -105,7 +101,7 @@ export class CoreContentLinksHelperProvider { * @todo set correct root. */ async goToChooseSite(url: string): Promise { - await this.navCtrl.navigateRoot('CoreContentLinksChooseSitePage @todo', { queryParams: { url } }); + await CoreNavigator.instance.navigate('CoreContentLinksChooseSitePage @todo', { params: { url }, reset: true }); } /** diff --git a/src/core/features/course/classes/activity-prefetch-handler.ts b/src/core/features/course/classes/activity-prefetch-handler.ts new file mode 100644 index 000000000..1e0131086 --- /dev/null +++ b/src/core/features/course/classes/activity-prefetch-handler.ts @@ -0,0 +1,193 @@ +// (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 { CoreNetworkError } from '@classes/errors/network-error'; +import { CoreFilterHelper } from '@features/filter/services/filter-helper'; +import { CoreApp } from '@services/app'; +import { CoreFilepool } from '@services/filepool'; +import { CoreSites } from '@services/sites'; +import { CoreCourse, CoreCourseWSModule } from '../services/course'; +import { CoreCourseModulePrefetchHandlerBase } from './module-prefetch-handler'; + +/** + * Base prefetch handler to be registered in CoreCourseModulePrefetchDelegate. It is useful to minimize the amount of + * functions that handlers need to implement. It also provides some helper features like preventing a module to be + * downloaded twice at the same time. + * + * If your handler inherits from this service, you just need to override the functions that you want to change. + * + * This class should be used for ACTIVITIES. You must override the prefetch function, and it's recommended to call + * prefetchPackage in there since it handles the package status. + */ +export class CoreCourseActivityPrefetchHandlerBase extends CoreCourseModulePrefetchHandlerBase { + + /** + * Download the module. + * + * @param module The module object returned by WS. + * @param courseId Course ID. + * @param dirPath Path of the directory where to store all the content files. + * @return Promise resolved when all content is downloaded. + */ + download(module: CoreCourseWSModule, courseId: number, dirPath?: string): Promise { + // Same implementation for download and prefetch. + return this.prefetch(module, courseId, false, dirPath); + } + + /** + * Prefetch 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. + * @param dirPath Path of the directory where to store all the content files. + * @return Promise resolved when done. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async prefetch(module: CoreCourseWSModule, courseId?: number, single?: boolean, dirPath?: string): Promise { + // To be overridden. It should call prefetchPackage + return; + } + + /** + * Prefetch the module, setting package status at start and finish. + * + * Example usage from a child instance: + * return this.prefetchPackage(module, courseId, single, this.prefetchModule.bind(this, otherParam), siteId); + * + * Then the function "prefetchModule" will receive params: + * prefetchModule(module, courseId, single, siteId, someParam, anotherParam) + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @param downloadFn Function to perform the prefetch. Please check the documentation of prefetchFunction. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the module has been downloaded. Data returned is not reliable. + */ + async prefetchPackage( + module: CoreCourseWSModule, + courseId: number, + downloadFunction: () => Promise, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (!CoreApp.instance.isOnline()) { + // Cannot prefetch in offline. + throw new CoreNetworkError(); + } + + if (this.isDownloading(module.id, siteId)) { + // There's already a download ongoing for this module, return the promise. + return this.getOngoingDownload(module.id, siteId); + } + + const prefetchPromise = this.changeStatusAndPrefetch(module, courseId, downloadFunction, siteId); + + return this.addOngoingDownload(module.id, prefetchPromise, siteId); + } + + protected async changeStatusAndPrefetch( + module: CoreCourseWSModule, + courseId: number, + downloadFunction: () => Promise, + siteId?: string, + ): Promise { + try { + await this.setDownloading(module.id, siteId); + + // Package marked as downloading, get module info to be able to handle links. Get module filters too. + await Promise.all([ + CoreCourse.instance.getModuleBasicInfo(module.id, siteId), + CoreCourse.instance.getModule(module.id, courseId, undefined, false, true, siteId), + CoreFilterHelper.instance.getFilters('module', module.id, { courseId }), + ]); + + // Call the download function. + let extra = await downloadFunction(); + + // Only accept string types. + if (typeof extra != 'string') { + extra = ''; + } + + // Prefetch finished, mark as downloaded. + await this.setDownloaded(module.id, siteId, extra); + } catch (error) { + // Error prefetching, go back to previous status and reject the promise. + return this.setPreviousStatus(module.id, siteId); + + throw error; + } + } + + /** + * Mark the module as downloaded. + * + * @param id Unique identifier per component. + * @param siteId Site ID. If not defined, current site. + * @param extra Extra data to store. + * @return Promise resolved when done. + */ + setDownloaded(id: number, siteId?: string, extra?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + return CoreFilepool.instance.storePackageStatus(siteId, CoreConstants.DOWNLOADED, this.component, id, extra); + } + + /** + * Mark the module as downloading. + * + * @param id Unique identifier per component. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + setDownloading(id: number, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + return CoreFilepool.instance.storePackageStatus(siteId, CoreConstants.DOWNLOADING, this.component, id); + } + + /** + * Set previous status and return a rejected promise. + * + * @param id Unique identifier per component. + * @param siteId Site ID. If not defined, current site. + * @return Rejected promise. + */ + async setPreviousStatus(id: number, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + await CoreFilepool.instance.setPackagePreviousStatus(siteId, this.component, id); + } + + /** + * Set previous status and return a rejected promise. + * + * @param id Unique identifier per component. + * @param error Error to throw. + * @param siteId Site ID. If not defined, current site. + * @return Rejected promise. + * @deprecated since 3.9.5. Use setPreviousStatus instead. + */ + async setPreviousStatusAndReject(id: number, error?: Error, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + await CoreFilepool.instance.setPackagePreviousStatus(siteId, this.component, id); + + throw error; + } + +} diff --git a/src/core/features/course/classes/activity-sync.ts b/src/core/features/course/classes/activity-sync.ts new file mode 100644 index 000000000..e00a5dc8d --- /dev/null +++ b/src/core/features/course/classes/activity-sync.ts @@ -0,0 +1,57 @@ +// (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 { CoreSyncBaseProvider } from '@classes/base-sync'; +import { CoreCourseWSModule } from '../services/course'; +import { CoreCourseModulePrefetchDelegate } from '../services/module-prefetch-delegate'; +import { CoreCourseModulePrefetchHandlerBase } from './module-prefetch-handler'; + +/** + * Base class to create activity sync providers. It provides some common functions. + */ +export class CoreCourseActivitySyncBaseProvider extends CoreSyncBaseProvider { + + /** + * Conveniece function to prefetch data after an update. + * + * @param module Module. + * @param courseId Course ID. + * @param preventDownloadRegex If regex matches, don't download the data. Defaults to check files. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async prefetchAfterUpdate( + prefetchHandler: CoreCourseModulePrefetchHandlerBase, + module: CoreCourseWSModule, + courseId: number, + preventDownloadRegex?: RegExp, + siteId?: string, + ): Promise { + // Get the module updates to check if the data was updated or not. + const result = await CoreCourseModulePrefetchDelegate.instance.getModuleUpdates(module, courseId, true, siteId); + + if (!result?.updates.length) { + return; + } + + // Only prefetch if files haven't changed, to prevent downloading too much data automatically. + const regex = preventDownloadRegex || /^.*files$/; + const shouldDownload = !result.updates.find((entry) => entry.name.match(regex)); + + if (shouldDownload) { + return prefetchHandler.download(module, courseId); + } + } + +} diff --git a/src/core/features/course/classes/main-activity-component.ts b/src/core/features/course/classes/main-activity-component.ts new file mode 100644 index 000000000..197010660 --- /dev/null +++ b/src/core/features/course/classes/main-activity-component.ts @@ -0,0 +1,269 @@ +// (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 { Component, Inject, Input, OnDestroy, OnInit, Optional } from '@angular/core'; +import { IonContent } from '@ionic/angular'; + +import { CoreCourseModuleMainResourceComponent } from './main-resource-component'; +import { CoreEventObserver, CoreEvents } from '@singletons/events'; +import { Network, NgZone } from '@singletons'; +import { Subscription } from 'rxjs'; +import { CoreApp } from '@services/app'; +import { CoreCourse } from '../services/course'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreWSExternalWarning } from '@services/ws'; +import { CoreCourseContentsPage } from '../pages/contents/contents'; + +/** + * Template class to easily create CoreCourseModuleMainComponent of activities. + */ +@Component({ + template: '', +}) +export class CoreCourseModuleMainActivityComponent extends CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy { + + @Input() group?: number; // Group ID the component belongs to. + + moduleName?: string; // Raw module name to be translated. It will be translated on init. + + // Data for context menu. + syncIcon?: string; // Sync icon. + hasOffline?: boolean; // If it has offline data to be synced. + isOnline?: boolean; // If the app is online or not. + + protected syncObserver?: CoreEventObserver; // It will observe the sync auto event. + protected onlineSubscription: Subscription; // It will observe the status of the network connection. + protected syncEventName?: string; // Auto sync event name. + + constructor( + @Optional() @Inject('') loggerName: string = 'CoreCourseModuleMainResourceComponent', + protected content?: IonContent, + courseContentsPage?: CoreCourseContentsPage, + ) { + super(loggerName, courseContentsPage); + + // Refresh online status when changes. + this.onlineSubscription = Network.instance.onChange().subscribe(() => { + // Execute the callback in the Angular zone, so change detection doesn't stop working. + NgZone.instance.run(() => { + this.isOnline = CoreApp.instance.isOnline(); + }); + }); + } + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + super.ngOnInit(); + + this.hasOffline = false; + this.syncIcon = 'spinner'; + this.moduleName = CoreCourse.instance.translateModuleName(this.moduleName || ''); + + if (this.syncEventName) { + // Refresh data if this discussion is synchronized automatically. + this.syncObserver = CoreEvents.on(this.syncEventName, (data) => { + this.autoSyncEventReceived(data); + }, this.siteId); + } + } + + /** + * Compares sync event data with current data to check if refresh content is needed. + * + * @param syncEventData Data received on sync observer. + * @return True if refresh is needed, false otherwise. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected isRefreshSyncNeeded(syncEventData: unknown): boolean { + return false; + } + + /** + * An autosync event has been received, check if refresh is needed and update the view. + * + * @param syncEventData Data receiven on sync observer. + */ + protected autoSyncEventReceived(syncEventData: unknown): void { + if (this.isRefreshSyncNeeded(syncEventData)) { + // Refresh the data. + this.showLoadingAndRefresh(false); + } + } + + /** + * Perform the refresh content function. + * + * @param sync If the refresh needs syncing. + * @param showErrors Wether to show errors to the user or hide them. + * @return Resolved when done. + */ + protected async refreshContent(sync: boolean = false, showErrors: boolean = false): Promise { + if (!this.module) { + // This can happen if course format changes from single activity to weekly/topics. + return; + } + + this.refreshIcon = 'spinner'; + this.syncIcon = 'spinner'; + + try { + await CoreUtils.instance.ignoreErrors(this.invalidateContent()); + + await this.loadContent(true, sync, showErrors); + } finally { + this.refreshIcon = 'fas-redo'; + this.syncIcon = 'fas-sync'; + } + } + + /** + * Show loading and perform the load content function. + * + * @param sync If the fetch needs syncing. + * @param showErrors Wether to show errors to the user or hide them. + * @return Resolved when done. + */ + protected async showLoadingAndFetch(sync: boolean = false, showErrors: boolean = false): Promise { + this.refreshIcon = 'spinner'; + this.syncIcon = 'spinner'; + this.loaded = false; + this.content?.scrollToTop(); + + try { + await this.loadContent(false, sync, showErrors); + } finally { + this.refreshIcon = 'fas-redo'; + this.syncIcon = 'fas-sync'; + } + } + + /** + * Show loading and perform the refresh content function. + * + * @param sync If the refresh needs syncing. + * @param showErrors Wether to show errors to the user or hide them. + * @return Resolved when done. + */ + protected showLoadingAndRefresh(sync: boolean = false, showErrors: boolean = false): Promise { + this.refreshIcon = 'spinner'; + this.syncIcon = 'spinner'; + this.loaded = false; + this.content?.scrollToTop(); + + return this.refreshContent(sync, showErrors); + } + + /** + * Download the component contents. + * + * @param refresh Whether we're refreshing data. + * @param sync If the refresh needs syncing. + * @param showErrors Wether to show errors to the user or hide them. + * @return Promise resolved when done. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected async fetchContent(refresh: boolean = false, sync: boolean = false, showErrors: boolean = false): Promise { + return; + } + + /** + * Loads the component contents and shows the corresponding error. + * + * @param refresh Whether we're refreshing data. + * @param sync If the refresh needs syncing. + * @param showErrors Wether to show errors to the user or hide them. + * @return Promise resolved when done. + */ + protected async loadContent(refresh?: boolean, sync: boolean = false, showErrors: boolean = false): Promise { + this.isOnline = CoreApp.instance.isOnline(); + + if (!this.module) { + // This can happen if course format changes from single activity to weekly/topics. + return; + } + + try { + await this.fetchContent(refresh, sync, showErrors); + } catch (error) { + if (!refresh) { + // Some call failed, retry without using cache since it might be a new activity. + return await this.refreshContent(sync); + } + + CoreDomUtils.instance.showErrorModalDefault(error, this.fetchContentDefaultError, true); + } finally { + this.loaded = true; + this.refreshIcon = 'fas-redo'; + this.syncIcon = 'fas-sync'; + } + } + + /** + * Performs the sync of the activity. + * + * @return Promise resolved when done. + */ + protected async sync(): Promise { + return {}; + } + + /** + * Checks if sync has succeed from result sync data. + * + * @param result Data returned on the sync function. + * @return If suceed or not. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected hasSyncSucceed(result: unknown): boolean { + return true; + } + + /** + * Tries to synchronize the activity. + * + * @param showErrors If show errors to the user of hide them. + * @return Promise resolved with true if sync succeed, or false if failed. + */ + protected async syncActivity(showErrors: boolean = false): Promise { + try { + const result = <{warnings?: CoreWSExternalWarning[]}> await this.sync(); + + if (result?.warnings?.length) { + CoreDomUtils.instance.showErrorModal(result.warnings[0]); + } + + return this.hasSyncSucceed(result); + } catch (error) { + if (showErrors) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.errorsync', true); + } + + return false; + } + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + super.ngOnDestroy(); + + this.onlineSubscription?.unsubscribe(); + this.syncObserver?.off(); + } + +} diff --git a/src/core/features/course/classes/main-resource-component.ts b/src/core/features/course/classes/main-resource-component.ts new file mode 100644 index 000000000..9e802aea1 --- /dev/null +++ b/src/core/features/course/classes/main-resource-component.ts @@ -0,0 +1,412 @@ +// (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 { OnInit, OnDestroy, Input, Output, EventEmitter, Component, Optional, Inject } from '@angular/core'; +import { IonRefresher } from '@ionic/angular'; +import { CoreApp } from '@services/app'; +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; + +import { CoreTextErrorObject, CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { Translate } from '@singletons'; +import { CoreEventObserver, CoreEventPackageStatusChanged, CoreEvents } from '@singletons/events'; +import { CoreLogger } from '@singletons/logger'; +import { CoreCourseContentsPage } from '../pages/contents/contents'; +import { CoreCourse } from '../services/course'; +import { CoreCourseHelper, CoreCourseModule } from '../services/course-helper'; +import { CoreCourseModuleDelegate, CoreCourseModuleMainComponent } from '../services/module-delegate'; +import { CoreCourseModulePrefetchDelegate } from '../services/module-prefetch-delegate'; + +/** + * Result of a resource download. + */ +export type CoreCourseResourceDownloadResult = { + failed?: boolean; // Whether the download has failed. + error?: string | CoreTextErrorObject; // The error in case it failed. +}; + +/** + * Template class to easily create CoreCourseModuleMainComponent of resources (or activities without syncing). + */ +@Component({ + template: '', +}) +export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy, CoreCourseModuleMainComponent { + + @Input() module?: CoreCourseModule; // The module of the component. + @Input() courseId?: number; // Course ID the component belongs to. + @Output() dataRetrieved = new EventEmitter(); // Called to notify changes the index page from the main component. + + loaded = false; // If the component has been loaded. + component?: string; // Component name. + componentId?: number; // Component ID. + blog?: boolean; // If blog is avalaible. + + // Data for context menu. + externalUrl?: string; // External URL to open in browser. + description?: string; // Module description. + refreshIcon = 'spinner'; // Refresh icon, normally spinner or refresh. + prefetchStatusIcon?: string; // Used when calling fillContextMenu. + prefetchStatus?: string; // Used when calling fillContextMenu. + prefetchText?: string; // Used when calling fillContextMenu. + size?: string; // Used when calling fillContextMenu. + isDestroyed?: boolean; // Whether the component is destroyed, used when calling fillContextMenu. + contextMenuStatusObserver?: CoreEventObserver; // Observer of package status, used when calling fillContextMenu. + contextFileStatusObserver?: CoreEventObserver; // Observer of file status, used when calling fillContextMenu. + + protected fetchContentDefaultError = 'core.course.errorgetmodule'; // Default error to show when loading contents. + protected isCurrentView?: boolean; // Whether the component is in the current view. + protected siteId?: string; // Current Site ID. + protected statusObserver?: CoreEventObserver; // Observer of package status. Only if setStatusListener is called. + protected currentStatus?: string; // The current status of the module. Only if setStatusListener is called. + protected logger: CoreLogger; + + constructor( + @Optional() @Inject('') loggerName: string = 'CoreCourseModuleMainResourceComponent', + protected courseContentsPage?: CoreCourseContentsPage, + ) { + this.logger = CoreLogger.getInstance(loggerName); + } + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + this.siteId = CoreSites.instance.getCurrentSiteId(); + this.description = this.module?.description; + this.componentId = this.module?.id; + this.externalUrl = this.module?.url; + this.courseId = this.courseId || this.module?.course; + // @todo this.blog = await this.blogProvider.isPluginEnabled(); + } + + /** + * Refresh the data. + * + * @param refresher Refresher. + * @param done Function to call when done. + * @param showErrors If show errors to the user of hide them. + * @return Promise resolved when done. + */ + async doRefresh(refresher?: CustomEvent, done?: () => void, showErrors: boolean = false): Promise { + if (!this.loaded || !this.module) { + return; + } + + // If it's a single activity course and the refresher is displayed within the component, + // call doRefresh on the section page to refresh the course data. + if (this.courseContentsPage && !CoreCourseModuleDelegate.instance.displayRefresherInSingleActivity(this.module.modname)) { + await CoreUtils.instance.ignoreErrors(this.courseContentsPage.doRefresh()); + } + + await CoreUtils.instance.ignoreErrors(this.refreshContent(true, showErrors)); + + refresher?.detail.complete(); + done && done(); + } + + /** + * Perform the refresh content function. + * + * @param sync If the refresh needs syncing. + * @param showErrors Wether to show errors to the user or hide them. + * @return Resolved when done. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected async refreshContent(sync: boolean = false, showErrors: boolean = false): Promise { + if (!this.module) { + // This can happen if course format changes from single activity to weekly/topics. + return; + } + + this.refreshIcon = 'spinner'; + + try { + await CoreUtils.instance.ignoreErrors(this.invalidateContent()); + + await this.loadContent(true); + } finally { + this.refreshIcon = 'fas-redo'; + } + } + + /** + * Perform the invalidate content function. + * + * @return Resolved when done. + */ + protected async invalidateContent(): Promise { + return; + } + + /** + * Download the component contents. + * + * @param refresh Whether we're refreshing data. + * @return Promise resolved when done. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected async fetchContent(refresh?: boolean): Promise { + return; + } + + /** + * Loads the component contents and shows the corresponding error. + * + * @param refresh Whether we're refreshing data. + * @return Promise resolved when done. + */ + protected async loadContent(refresh?: boolean): Promise { + if (!this.module) { + // This can happen if course format changes from single activity to weekly/topics. + return; + } + + try { + await this.fetchContent(refresh); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, this.fetchContentDefaultError, true); + } finally { + this.loaded = true; + this.refreshIcon = 'fas-redo'; + } + } + + /** + * Fill the context menu options + */ + protected fillContextMenu(refresh: boolean = false): void { + if (!this.module) { + return; + } + + // All data obtained, now fill the context menu. + CoreCourseHelper.instance.fillContextMenu(this, this.module, this.courseId!, refresh, this.component); + } + + /** + * Check if the module is prefetched or being prefetched. To make it faster, just use the data calculated by fillContextMenu. + * This means that you need to call fillContextMenu to make this work. + */ + protected isPrefetched(): boolean { + return this.prefetchStatus != CoreConstants.NOT_DOWNLOADABLE && this.prefetchStatus != CoreConstants.NOT_DOWNLOADED; + } + + /** + * Expand the description. + */ + expandDescription(): void { + CoreTextUtils.instance.viewText(Translate.instance.instant('core.description'), this.description!, { + component: this.component, + componentId: this.module?.id, + filter: true, + contextLevel: 'module', + instanceId: this.module?.id, + courseId: this.courseId, + }); + } + + /** + * Go to blog posts. + */ + async gotoBlog(): Promise { + // @todo return this.linkHelper.goInSite(this.navCtrl, 'AddonBlogEntriesPage', { cmId: this.module.id }); + } + + /** + * Prefetch the module. + * + * @param done Function to call when done. + */ + prefetch(done?: () => void): void { + if (!this.module) { + return; + } + + CoreCourseHelper.instance.contextMenuPrefetch(this, this.module, this.courseId!, done); + } + + /** + * Confirm and remove downloaded files. + * + * @param done Function to call when done. + */ + removeFiles(done?: () => void): void { + if (!this.module) { + return; + } + + if (this.prefetchStatus == CoreConstants.DOWNLOADING) { + CoreDomUtils.instance.showAlertTranslated(undefined, 'core.course.cannotdeletewhiledownloading'); + + return; + } + + CoreCourseHelper.instance.confirmAndRemoveFiles(this.module, this.courseId!, done); + } + + /** + * Get message about an error occurred while downloading files. + * + * @param error The specific error. + * @param multiLine Whether to put each message in a different paragraph or in a single line. + */ + protected getErrorDownloadingSomeFilesMessage(error: string | CoreTextErrorObject, multiLine?: boolean): string { + if (multiLine) { + return CoreTextUtils.instance.buildSeveralParagraphsMessage([ + Translate.instance.instant('core.errordownloadingsomefiles'), + error, + ]); + } else { + error = CoreTextUtils.instance.getErrorMessageFromError(error) || error; + + return Translate.instance.instant('core.errordownloadingsomefiles') + (error ? ' ' + error : ''); + } + } + + /** + * Show an error occurred while downloading files. + * + * @param error The specific error. + */ + protected showErrorDownloadingSomeFiles(error: string | CoreTextErrorObject): void { + CoreDomUtils.instance.showErrorModal(this.getErrorDownloadingSomeFilesMessage(error, true)); + } + + /** + * Displays some data based on the current status. + * + * @param status The current status. + * @param previousStatus The previous status. If not defined, there is no previous status. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + protected showStatus(status: string, previousStatus?: string): void { + // To be overridden. + } + + /** + * Watch for changes on the status. + * + * @return Promise resolved when done. + */ + protected async setStatusListener(): Promise { + if (typeof this.statusObserver != 'undefined' || !this.module) { + return; + } + + // Listen for changes on this module status. + this.statusObserver = CoreEvents.on(CoreEvents.PACKAGE_STATUS_CHANGED, (data) => { + if (!this.module || data.componentId != this.module.id || data.component != this.component) { + return; + } + + // The status has changed, update it. + const previousStatus = this.currentStatus; + this.currentStatus = data.status; + + this.showStatus(this.currentStatus, previousStatus); + }, this.siteId); + + // Also, get the current status. + const status = await CoreCourseModulePrefetchDelegate.instance.getModuleStatus(this.module, this.courseId!); + + this.currentStatus = status; + this.showStatus(status); + } + + /** + * Download a resource if needed. + * If the download call fails the promise won't be rejected, but the error will be included in the returned object. + * If module.contents cannot be loaded then the Promise will be rejected. + * + * @param refresh Whether we're refreshing data. + * @return Promise resolved when done. + */ + protected async downloadResourceIfNeeded( + refresh?: boolean, + contentsAlreadyLoaded?: boolean, + ): Promise { + + const result: CoreCourseResourceDownloadResult = { + failed: false, + }; + + if (!this.module) { + return result; + } + + // Get module status to determine if it needs to be downloaded. + await this.setStatusListener(); + + if (this.currentStatus != CoreConstants.DOWNLOADED) { + // Download content. This function also loads module contents if needed. + try { + await CoreCourseModulePrefetchDelegate.instance.downloadModule(this.module, this.courseId!); + + // If we reach here it means the download process already loaded the contents, no need to do it again. + contentsAlreadyLoaded = true; + } catch (error) { + // Mark download as failed but go on since the main files could have been downloaded. + result.failed = true; + result.error = error; + } + } + + if (!this.module.contents.length || (refresh && !contentsAlreadyLoaded)) { + // Try to load the contents. + const ignoreCache = refresh && CoreApp.instance.isOnline(); + + try { + await CoreCourse.instance.loadModuleContents(this.module, this.courseId, undefined, false, ignoreCache); + } catch (error) { + // Error loading contents. If we ignored cache, try to get the cached value. + if (ignoreCache && !this.module.contents) { + await CoreCourse.instance.loadModuleContents(this.module, this.courseId); + } else if (!this.module.contents) { + // Not able to load contents, throw the error. + throw error; + } + } + } + + return result; + } + + /** + * Component being destroyed. + */ + ngOnDestroy(): void { + this.isDestroyed = true; + this.contextMenuStatusObserver?.off(); + this.contextFileStatusObserver?.off(); + this.statusObserver?.off(); + } + + /** + * User entered the page that contains the component. This function should be called by the page that contains this component. + */ + ionViewDidEnter(): void { + this.isCurrentView = true; + } + + /** + * User left the page that contains the component. This function should be called by the page that contains this component. + */ + ionViewDidLeave(): void { + this.isCurrentView = false; + } + +} diff --git a/src/core/features/course/classes/module-prefetch-handler.ts b/src/core/features/course/classes/module-prefetch-handler.ts new file mode 100644 index 000000000..7b5e10654 --- /dev/null +++ b/src/core/features/course/classes/module-prefetch-handler.ts @@ -0,0 +1,344 @@ +// (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 { CoreFilepool } from '@services/filepool'; +import { CoreFileSizeSum, CorePluginFileDelegate } from '@services/plugin-file-delegate'; +import { CoreSites } from '@services/sites'; +import { CoreWSExternalFile } from '@services/ws'; +import { CoreCourse, CoreCourseModuleContentFile, CoreCourseWSModule } from '../services/course'; +import { CoreCourseModulePrefetchHandler } from '../services/module-prefetch-delegate'; + +/** + * Base prefetch handler to be registered in CoreCourseModulePrefetchDelegate. Prefetch handlers should inherit either + * from CoreCourseModuleActivityPrefetchHandlerBase or CoreCourseModuleResourcePrefetchHandlerBase, depending on whether + * they are an activity or a resource. It's not recommended to inherit from this class directly. + */ +export class CoreCourseModulePrefetchHandlerBase implements CoreCourseModulePrefetchHandler { + + /** + * Name of the handler. + */ + name = 'CoreCourseModulePrefetchHandler'; + + /** + * Name of the module. It should match the "modname" of the module returned in core_course_get_contents. + */ + modName = 'default'; + + /** + * The handler's component. + */ + component = 'core_module'; + + /** + * The RegExp to check updates. If a module has an update whose name matches this RegExp, the module will be marked + * as outdated. This RegExp is ignored if hasUpdates function is defined. + */ + updatesNames = /^.*files$/; + + /** + * If true, this module will be ignored when determining the status of a list of modules. The module will + * still be downloaded when downloading the section/course, it only affects whether the button should be displayed. + */ + skipListStatus = false; + + /** + * List of download promises to prevent downloading the module twice at the same time. + */ + protected downloadPromises: { [s: string]: { [s: string]: Promise } } = {}; + + /** + * Add an ongoing download to the downloadPromises list. When the promise finishes it will be removed. + * + * @param id Unique identifier per component. + * @param promise Promise to add. + * @param siteId Site ID. If not defined, current site. + * @return Promise of the current download. + */ + async addOngoingDownload(id: number, promise: Promise, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const uniqueId = this.getUniqueId(id); + + if (!this.downloadPromises[siteId]) { + this.downloadPromises[siteId] = {}; + } + + this.downloadPromises[siteId][uniqueId] = promise; + + try { + return await this.downloadPromises[siteId][uniqueId]; + } finally { + delete this.downloadPromises[siteId][uniqueId]; + } + } + + /** + * Download the module. + * + * @param module The module object returned by WS. + * @param courseId Course ID. + * @param dirPath Path of the directory where to store all the content files. + * @return Promise resolved when all content is downloaded. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async download(module: CoreCourseWSModule, courseId: number, dirPath?: string): Promise { + // To be overridden. + return; + } + + /** + * Returns a list of content files that can be downloaded. + * + * @param module The module object returned by WS. + * @return List of files. + */ + getContentDownloadableFiles(module: CoreCourseWSModule): CoreCourseModuleContentFile[] { + if (!module.contents?.length) { + return []; + } + + return module.contents.filter((content) => this.isFileDownloadable(content)); + } + + /** + * 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. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getDownloadSize(module: CoreCourseWSModule, courseId: number, single?: boolean): Promise { + try { + const files = await this.getFiles(module, courseId); + + return await CorePluginFileDelegate.instance.getFilesDownloadSize(files); + } catch { + return { size: -1, total: false }; + } + } + + /** + * Get the downloaded size of a module. If not defined, we'll use getFiles to calculate it (it can be slow). + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Size, or promise resolved with the size. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getDownloadedSize(module: CoreCourseWSModule, courseId: number): Promise { + const siteId = CoreSites.instance.getCurrentSiteId(); + + return CoreFilepool.instance.getFilesSizeByComponent(siteId, this.component, module.id); + } + + /** + * Get list of files. If not defined, we'll assume they're in module.contents. + * + * @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 list of files. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getFiles(module: CoreCourseWSModule, courseId: number, single?: boolean): Promise { + // To be overridden. + return []; + } + + /** + * Returns module intro files. + * + * @param module The module object returned by WS. + * @param courseId Course ID. + * @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down). + * @return Promise resolved with list of intro files. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getIntroFiles(module: CoreCourseWSModule, courseId: number, ignoreCache?: boolean): Promise { + return this.getIntroFilesFromInstance(module); + } + + /** + * Returns module intro files from instance. + * + * @param module The module object returned by WS. + * @param instance The instance to get the intro files (book, assign, ...). If not defined, module will be used. + * @return List of intro files. + */ + getIntroFilesFromInstance(module: CoreCourseWSModule, instance?: ModuleInstance): CoreWSExternalFile[] { + if (instance) { + if (typeof instance.introfiles != 'undefined') { + return instance.introfiles; + } else if (instance.intro) { + return CoreFilepool.instance.extractDownloadableFilesFromHtmlAsFakeFileObjects(instance.intro); + } + } + + if (module.description) { + return CoreFilepool.instance.extractDownloadableFilesFromHtmlAsFakeFileObjects(module.description); + } + + return []; + } + + /** + * If there's an ongoing download for a certain identifier return it. + * + * @param id Unique identifier per component. + * @param siteId Site ID. If not defined, current site. + * @return Promise of the current download. + */ + async getOngoingDownload(id: number, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (this.isDownloading(id, siteId)) { + // There's already a download ongoing, return the promise. + return this.downloadPromises[siteId][this.getUniqueId(id)]; + } + } + + /** + * Create unique identifier using component and id. + * + * @param id Unique ID inside component. + * @return Unique ID. + */ + getUniqueId(id: number): string { + return this.component + '#' + id; + } + + /** + * 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. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async invalidateContent(moduleId: number, courseId: number): Promise { + // To be overridden. + return; + } + + /** + * Invalidate WS calls needed to determine module status (usually, to check if module is downloadable). + * It doesn't need to invalidate check updates. It should NOT invalidate files nor all the prefetched data. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Promise resolved when invalidated. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + invalidateModule(module: CoreCourseWSModule, courseId: number): Promise { + return CoreCourse.instance.invalidateModule(module.id); + } + + /** + * 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. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async isDownloadable(module: CoreCourseWSModule, courseId: number): Promise { + // By default, mark all instances as downloadable. + return true; + } + + /** + * Check if a there's an ongoing download for the given identifier. + * + * @param id Unique identifier per component. + * @param siteId Site ID. If not defined, current site. + * @return True if downloading, false otherwise. + */ + isDownloading(id: number, siteId?: string): boolean { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + return !!(this.downloadPromises[siteId] && this.downloadPromises[siteId][this.getUniqueId(id)]); + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return A boolean, or a promise resolved with a boolean, indicating if the handler is enabled. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Check if a file is downloadable. + * + * @param file File to check. + * @return Whether the file is downloadable. + */ + isFileDownloadable(file: CoreCourseModuleContentFile): boolean { + return file.type === 'file'; + } + + /** + * Load module contents into module.contents if they aren't loaded already. + * + * @param module Module to load the contents. + * @param courseId The course ID. Recommended to speed up the process and minimize data usage. + * @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down). + * @return Promise resolved when loaded. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async loadContents(module: CoreCourseWSModule, courseId: number, ignoreCache?: boolean): Promise { + // To be overridden. + return; + } + + /** + * Prefetch 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. + * @param dirPath Path of the directory where to store all the content files. + * @return Promise resolved when done. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async prefetch(module: CoreCourseWSModule, courseId?: number, single?: boolean, dirPath?: string): Promise { + // To be overridden. + return; + } + + /** + * Remove module downloaded files. If not defined, we'll use getFiles to remove them (slow). + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Promise resolved when done. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + removeFiles(module: CoreCourseWSModule, courseId: number): Promise { + return CoreFilepool.instance.removeFilesByComponent(CoreSites.instance.getCurrentSiteId(), this.component, module.id); + } + +} + +/** + * Properties a module instance should have to be able to retrieve its intro files. + */ +type ModuleInstance = { + introfiles?: CoreWSExternalFile[]; + intro?: string; +}; diff --git a/src/core/features/course/classes/resource-prefetch-handler.ts b/src/core/features/course/classes/resource-prefetch-handler.ts new file mode 100644 index 000000000..899fb729f --- /dev/null +++ b/src/core/features/course/classes/resource-prefetch-handler.ts @@ -0,0 +1,203 @@ +// (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 { CoreError } from '@classes/errors/error'; +import { CoreNetworkError } from '@classes/errors/network-error'; +import { CoreFilterHelper } from '@features/filter/services/filter-helper'; +import { CoreApp } from '@services/app'; +import { CoreFilepool } from '@services/filepool'; +import { CoreSites } from '@services/sites'; +import { CoreWSExternalFile } from '@services/ws'; +import { CoreCourse, CoreCourseWSModule } from '../services/course'; +import { CoreCourseModulePrefetchHandlerBase } from './module-prefetch-handler'; + +/** + * Base prefetch handler to be registered in CoreCourseModulePrefetchDelegate. It is useful to minimize the amount of + * functions that handlers need to implement. It also provides some helper features like preventing a module to be + * downloaded twice at the same time. + * + * If your handler inherits from this service, you just need to override the functions that you want to change. + * + * This class should be used for RESOURCES whose main purpose is downloading files present in module.contents. + */ +export class CoreCourseResourcePrefetchHandlerBase extends CoreCourseModulePrefetchHandlerBase { + + /** + * Download the module. + * + * @param module The module object returned by WS. + * @param courseId Course ID. + * @param dirPath Path of the directory where to store all the content files. + * @return Promise resolved when all content is downloaded. + */ + download(module: CoreCourseWSModule, courseId: number, dirPath?: string): Promise { + return this.downloadOrPrefetch(module, courseId, false, dirPath); + } + + /** + * Download or prefetch the content. + * + * @param module The module object returned by WS. + * @param courseId Course ID. + * @param prefetch True to prefetch, false to download right away. + * @param dirPath Path of the directory where to store all the content files. This is to keep the files + * relative paths and make the package work in an iframe. Undefined to download the files + * in the filepool root folder. + * @return Promise resolved when all content is downloaded. + */ + async downloadOrPrefetch(module: CoreCourseWSModule, courseId: number, prefetch?: boolean, dirPath?: string): Promise { + if (!CoreApp.instance.isOnline()) { + // Cannot download in offline. + throw new CoreNetworkError(); + } + + const siteId = CoreSites.instance.getCurrentSiteId(); + + if (this.isDownloading(module.id, siteId)) { + // There's already a download ongoing for this module, return the promise. + return this.getOngoingDownload(module.id, siteId); + } + + // Get module info to be able to handle links. + const prefetchPromise = this.performDownloadOrPrefetch(siteId, module, courseId, !!prefetch, dirPath); + + return this.addOngoingDownload(module.id, prefetchPromise, siteId); + } + + /** + * Download or prefetch the content. + * + * @param module The module object returned by WS. + * @param courseId Course ID. + * @param prefetch True to prefetch, false to download right away. + * @param dirPath Path of the directory where to store all the content files. + * @return Promise resolved when all content is downloaded. + */ + protected async performDownloadOrPrefetch( + siteId: string, + module: CoreCourseWSModule, + courseId: number, + prefetch: boolean, + dirPath?: string, + ): Promise { + // Get module info to be able to handle links. + await CoreCourse.instance.getModuleBasicInfo(module.id, siteId); + + // Load module contents (ignore cache so we always have the latest data). + await this.loadContents(module, courseId, true); + + // Get the intro files. + const introFiles = await this.getIntroFiles(module, courseId, true); + + const contentFiles = this.getContentDownloadableFiles(module); + const promises: Promise[] = []; + + if (dirPath) { + // Download intro files in filepool root folder. + promises.push( + CoreFilepool.instance.downloadOrPrefetchFiles(siteId, introFiles, prefetch, false, this.component, module.id), + ); + + // Download content files inside dirPath. + promises.push(CoreFilepool.instance.downloadOrPrefetchPackage( + siteId, + contentFiles, + prefetch, + this.component, + module.id, + undefined, + dirPath, + )); + } else { + // No dirPath, download everything in filepool root folder. + promises.push(CoreFilepool.instance.downloadOrPrefetchPackage( + siteId, + introFiles.concat(contentFiles), + prefetch, + this.component, + module.id, + )); + } + + promises.push(CoreFilterHelper.instance.getFilters('module', module.id, { courseId })); + + await Promise.all(promises); + } + + /** + * Get list of files. If not defined, we'll assume they're in module.contents. + * + * @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 list of files. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getFiles(module: CoreCourseWSModule, courseId: number, single?: boolean): Promise { + // Load module contents if needed. + await this.loadContents(module, courseId); + + const files = await this.getIntroFiles(module, courseId); + + return files.concat(this.getContentDownloadableFiles(module)); + } + + /** + * 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. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async invalidateContent(moduleId: number, courseId: number): Promise { + const siteId = CoreSites.instance.getCurrentSiteId(); + + await Promise.all([ + CoreCourse.instance.invalidateModule(moduleId), + CoreFilepool.instance.invalidateFilesByComponent(siteId, this.component, moduleId), + ]); + } + + /** + * Load module contents into module.contents if they aren't loaded already. + * + * @param module Module to load the contents. + * @param courseId The course ID. Recommended to speed up the process and minimize data usage. + * @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down). + * @return Promise resolved when loaded. + */ + loadContents(module: CoreCourseWSModule, courseId: number, ignoreCache?: boolean): Promise { + return CoreCourse.instance.loadModuleContents(module, courseId, undefined, false, ignoreCache); + } + + /** + * Prefetch 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. + * @param dirPath Path of the directory where to store all the content files. + * @return Promise resolved when done. + */ + prefetch(module: CoreCourseWSModule, courseId?: number, single?: boolean, dirPath?: string): Promise { + courseId = courseId || module.course; + if (!courseId) { + throw new CoreError('Course ID not supplied.'); + } + + return this.downloadOrPrefetch(module, courseId, true, dirPath); + } + +} diff --git a/src/core/features/course/components/components.module.ts b/src/core/features/course/components/components.module.ts new file mode 100644 index 000000000..866296a27 --- /dev/null +++ b/src/core/features/course/components/components.module.ts @@ -0,0 +1,57 @@ +// (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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreBlockComponentsModule } from '@features/block/components/components.module'; +import { CoreCourseFormatComponent } from './format/format'; +import { CoreCourseModuleComponent } from './module/module'; +import { CoreCourseModuleCompletionComponent } from './module-completion/module-completion'; +import { CoreCourseModuleDescriptionComponent } from './module-description/module-description'; +import { CoreCourseSectionSelectorComponent } from './section-selector/section-selector'; +import { CoreCourseTagAreaComponent } from './tag-area/tag-area'; +import { CoreCourseUnsupportedModuleComponent } from './unsupported-module/unsupported-module'; + +@NgModule({ + declarations: [ + CoreCourseFormatComponent, + CoreCourseModuleComponent, + CoreCourseModuleCompletionComponent, + CoreCourseModuleDescriptionComponent, + CoreCourseSectionSelectorComponent, + CoreCourseTagAreaComponent, + CoreCourseUnsupportedModuleComponent, + ], + imports: [ + CoreBlockComponentsModule, + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreSharedModule, + ], + exports: [ + CoreCourseFormatComponent, + CoreCourseModuleComponent, + CoreCourseModuleCompletionComponent, + CoreCourseModuleDescriptionComponent, + CoreCourseSectionSelectorComponent, + CoreCourseTagAreaComponent, + CoreCourseUnsupportedModuleComponent, + ], +}) +export class CoreCourseComponentsModule {} diff --git a/src/core/features/course/components/format/core-course-format.html b/src/core/features/course/components/format/core-course-format.html new file mode 100644 index 000000000..74738d626 --- /dev/null +++ b/src/core/features/course/components/format/core-course-format.html @@ -0,0 +1,168 @@ + + + + + + + + + + + + + + + + +
+ + + + + + {{ 'core.course.sections' | translate }} + +
+
+ + +
+
+ + + + +
+ +
+ + + + + + + {{ 'core.course.hiddenfromstudents' | translate }} + + + {{ 'core.notavailable' | translate }} + + + + + + + +
+
+ + +
+ + + + + +
+ + +
+ + + + + + + + + +
+ + + + + + + + + + + + + +
+
+
+ + + +
+ + + + + + +

+ + {{ 'core.course.hiddenfromstudents' | translate }} + + + {{ 'core.notavailable' | translate }} + + + + + +

+
+
+ + + + + + + + + +
+
+ + + +
+ + + {{section.count}} / {{section.total}} + + + + +
+
diff --git a/src/core/features/course/components/format/format.scss b/src/core/features/course/components/format/format.scss new file mode 100644 index 000000000..bd62cd001 --- /dev/null +++ b/src/core/features/course/components/format/format.scss @@ -0,0 +1,77 @@ +// ion-app.app-root ion-badge.core-course-download-section-progress { +// display: block; +// @include float(start); +// @include margin(12px, 12px, null, 12px); +// } + +:host { + + .core-format-progress-list { + margin-bottom: 0; + + .item { + background: transparent; + + .label { + margin-top: 0; + margin-bottom: 0; + } + + progress { + .progress-bar-fallback, + &[value]::-webkit-progress-bar { + background-color: var(--white); + } + } + } + } + + .core-course-thumb { + display: none; + height: 150px; + width: 100%; + overflow: hidden; + cursor: pointer; + pointer-events: auto; + position: relative; + background: white; + + img { + position: absolute; + top: 0; + bottom: 0; + margin: auto; + width: 100%; + } + } + +// @todo +// .item-divider { +// .label { +// margin-top: 0; +// margin-bottom: 0; +// } + +// core-format-text { +// line-height: 44px; +// } + +// ion-badge core-format-text { +// line-height: normal; +// margin-bottom: 9px; +// } + +// &.core-section-download .label{ +// @include margin(null, 0, null, null); +// } +// } + +// div.core-section-download { +// @include padding(null, 0, null, null); +// } + +// .core-button-selector-row { +// @include safe-area-padding-start($content-padding !important, $content-padding); +// } + +} diff --git a/src/core/features/course/components/format/format.ts b/src/core/features/course/components/format/format.ts new file mode 100644 index 000000000..59e5c6d85 --- /dev/null +++ b/src/core/features/course/components/format/format.ts @@ -0,0 +1,633 @@ +// (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 { + Component, + Input, + OnInit, + OnChanges, + OnDestroy, + SimpleChange, + Output, + EventEmitter, + ViewChildren, + QueryList, + Type, + ViewChild, +} from '@angular/core'; + +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-component'; +import { CoreCourseAnyCourseData } from '@features/courses/services/courses'; +import { + CoreCourse, + CoreCourseProvider, +} from '@features/course/services/course'; +import { + CoreCourseHelper, + CoreCourseModule, + CoreCourseModuleCompletionData, + CoreCourseSection, + CoreCourseSectionWithStatus, +} from '@features/course/services/course-helper'; +import { CoreCourseFormatDelegate } from '@features/course/services/format-delegate'; +import { CoreEventObserver, CoreEvents, CoreEventSectionStatusChangedData, CoreEventSelectCourseTabData } from '@singletons/events'; +import { IonContent, IonRefresher } from '@ionic/angular'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; +import { CoreBlockCourseBlocksComponent } from '@features/block/components/course-blocks/course-blocks'; +import { ModalController } from '@singletons'; +import { CoreCourseSectionSelectorComponent } from '../section-selector/section-selector'; + +/** + * Component to display course contents using a certain format. If the format isn't found, use default one. + * + * The inputs of this component will be shared with the course format components. Please use CoreCourseFormatDelegate + * to register your handler for course formats. + * + * Example usage: + * + * + */ +@Component({ + selector: 'core-course-format', + templateUrl: 'core-course-format.html', + styleUrls: ['format.scss'], +}) +export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { + + static readonly LOAD_MORE_ACTIVITIES = 20; // How many activities should load each time showMoreActivities is called. + + @Input() course?: CoreCourseAnyCourseData; // The course to render. + @Input() sections?: CoreCourseSectionWithStatus[]; // List of course sections. The status will be calculated in this component. + @Input() downloadEnabled?: boolean; // Whether the download of sections and modules is enabled. + @Input() initialSectionId?: number; // The section to load first (by ID). + @Input() initialSectionNumber?: number; // The section to load first (by number). + @Input() moduleId?: number; // The module ID to scroll to. Must be inside the initial selected section. + @Output() completionChanged = new EventEmitter(); // Notify when any module completion changes. + + @ViewChildren(CoreDynamicComponent) dynamicComponents?: QueryList; + @ViewChild(CoreBlockCourseBlocksComponent) courseBlocksComponent?: CoreBlockCourseBlocksComponent; + + // All the possible component classes. + courseFormatComponent?: Type; + courseSummaryComponent?: Type; + sectionSelectorComponent?: Type; + singleSectionComponent?: Type; + allSectionsComponent?: Type; + + canLoadMore = false; + showSectionId = 0; + sectionSelectorExpanded = false; + data: Record = {}; // Data to pass to the components. + + displaySectionSelector?: boolean; + displayBlocks?: boolean; + selectedSection?: CoreCourseSection; + previousSection?: CoreCourseSection; + nextSection?: CoreCourseSection; + allSectionsId: number = CoreCourseProvider.ALL_SECTIONS_ID; + stealthModulesSectionId: number = CoreCourseProvider.STEALTH_MODULES_SECTION_ID; + loaded = false; + hasSeveralSections?: boolean; + imageThumb?: string; + progress?: number; + + protected sectionStatusObserver?: CoreEventObserver; + protected selectTabObserver?: CoreEventObserver; + protected lastCourseFormat?: string; + + constructor( + protected content: IonContent, + ) { + // Pass this instance to all components so they can use its methods and properties. + this.data.coreCourseFormatComponent = this; + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + // 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.course?.id) { + 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.instance.getSectionDownloadId({ id: data.sectionId }); + if (CoreCourseModulePrefetchDelegate.instance.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.instance.calculateSectionStatus(section, this.course.id, false); + + if (section.isDownloading && !CoreCourseModulePrefetchDelegate.instance.isBeingDownloaded(downloadId)) { + // All the modules are now downloading, set a download all promise. + this.prefetch(section); + } + }, + CoreSites.instance.getCurrentSiteId(), + ); + + // Listen for select course tab events to select the right section if needed. + this.selectTabObserver = CoreEvents.on(CoreEvents.SELECT_COURSE_TAB, (data) => { + if (data.name) { + return; + } + + let section: CoreCourseSection | undefined; + + if (typeof data.sectionId != 'undefined' && data.sectionId != null && this.sections) { + section = this.sections.find((section) => section.id == data.sectionId); + } else if (typeof data.sectionNumber != 'undefined' && data.sectionNumber != null && this.sections) { + section = this.sections.find((section) => section.section == data.sectionNumber); + } + + if (section) { + this.sectionChanged(section); + } + }); + } + + /** + * Detect changes on input properties. + */ + ngOnChanges(changes: { [name: string]: SimpleChange }): void { + this.setInputData(); + + if (changes.course && this.course) { + // Course has changed, try to get the components. + this.getComponents(); + + this.displaySectionSelector = CoreCourseFormatDelegate.instance.displaySectionSelector(this.course); + this.displayBlocks = CoreCourseFormatDelegate.instance.displayBlocks(this.course); + this.progress = 'progress' in this.course && this.course.progress !== undefined && this.course.progress >= 0 && + this.course.completionusertracked !== false ? this.course.progress : undefined; + if ('overviewfiles' in this.course) { + this.imageThumb = this.course.overviewfiles?.[0]?.fileurl; + } + } + + if (changes.sections && this.sections) { + this.treatSections(this.sections); + } + + if (this.downloadEnabled && (changes.downloadEnabled || changes.sections)) { + this.calculateSectionsStatus(false); + } + } + + /** + * Set the input data for components. + */ + protected setInputData(): void { + this.data.course = this.course; + this.data.sections = this.sections; + this.data.initialSectionId = this.initialSectionId; + this.data.initialSectionNumber = this.initialSectionNumber; + this.data.downloadEnabled = this.downloadEnabled; + this.data.moduleId = this.moduleId; + this.data.completionChanged = this.completionChanged; + } + + /** + * Get the components classes. + */ + protected async getComponents(): Promise { + if (!this.course || this.course.format == this.lastCourseFormat) { + return; + } + + // Format has changed or it's the first time, load all the components. + this.lastCourseFormat = this.course.format; + + await Promise.all([ + this.loadCourseFormatComponent(), + this.loadCourseSummaryComponent(), + this.loadSectionSelectorComponent(), + this.loadSingleSectionComponent(), + this.loadAllSectionsComponent(), + ]); + } + + /** + * Load course format component. + * + * @return Promise resolved when done. + */ + protected async loadCourseFormatComponent(): Promise { + this.courseFormatComponent = await CoreCourseFormatDelegate.instance.getCourseFormatComponent(this.course!); + } + + /** + * Load course summary component. + * + * @return Promise resolved when done. + */ + protected async loadCourseSummaryComponent(): Promise { + this.courseSummaryComponent = await CoreCourseFormatDelegate.instance.getCourseSummaryComponent(this.course!); + } + + /** + * Load section selector component. + * + * @return Promise resolved when done. + */ + protected async loadSectionSelectorComponent(): Promise { + this.sectionSelectorComponent = await CoreCourseFormatDelegate.instance.getSectionSelectorComponent(this.course!); + } + + /** + * Load single section component. + * + * @return Promise resolved when done. + */ + protected async loadSingleSectionComponent(): Promise { + this.singleSectionComponent = await CoreCourseFormatDelegate.instance.getSingleSectionComponent(this.course!); + } + + /** + * Load all sections component. + * + * @return Promise resolved when done. + */ + protected async loadAllSectionsComponent(): Promise { + this.allSectionsComponent = await CoreCourseFormatDelegate.instance.getAllSectionsComponent(this.course!); + } + + /** + * Treat received sections. + * + * @param sections Sections to treat. + * @return Promise resolved when done. + */ + protected async treatSections(sections: CoreCourseSection[]): Promise { + const hasAllSections = sections[0].id == CoreCourseProvider.ALL_SECTIONS_ID; + this.hasSeveralSections = sections.length > 2 || (sections.length == 2 && !hasAllSections); + + if (this.selectedSection) { + // We have a selected section, but the list has changed. Search the section in the list. + let newSection = sections.find(section => this.compareSections(section, this.selectedSection!)); + + if (!newSection) { + // Section not found, calculate which one to use. + newSection = await CoreCourseFormatDelegate.instance.getCurrentSection(this.course!, sections); + } + + this.sectionChanged(newSection); + + return; + } + + // There is no selected section yet, calculate which one to load. + if (!this.hasSeveralSections) { + // Always load "All sections" to display the section title. If it isn't there just load the section. + this.loaded = true; + this.sectionChanged(sections[0]); + } else if (this.initialSectionId || this.initialSectionNumber) { + // We have an input indicating the section ID to load. Search the section. + const section = sections.find((section) => { + if (section.id != this.initialSectionId && (!section.section || section.section != this.initialSectionNumber)) { + return false; + } + }); + + // Don't load the section if it cannot be viewed by the user. + if (section && this.canViewSection(section)) { + this.loaded = true; + this.sectionChanged(section); + } + } + + if (!this.loaded) { + // No section specified, not found or not visible, get current section. + const section = await CoreCourseFormatDelegate.instance.getCurrentSection(this.course!, sections); + + this.loaded = true; + this.sectionChanged(section); + } + + return; + } + + /** + * Display the section selector modal. + */ + async showSectionSelector(): Promise { + if (this.sectionSelectorExpanded) { + return; + } + + this.sectionSelectorExpanded = true; + + const modal = await ModalController.instance.create({ + component: CoreCourseSectionSelectorComponent, + componentProps: { + course: this.course, + sections: this.sections, + selected: this.selectedSection, + }, + }); + await modal.present(); + + const result = await modal.onWillDismiss(); + + this.sectionSelectorExpanded = false; + if (result?.data) { + this.sectionChanged(result?.data); + } + } + + /** + * Function called when selected section changes. + * + * @param newSection The new selected section. + */ + sectionChanged(newSection: CoreCourseSection): void { + const previousValue = this.selectedSection; + this.selectedSection = newSection; + this.data.section = this.selectedSection; + + if (newSection.id != this.allSectionsId) { + // Select next and previous sections to show the arrows. + const i = this.sections!.findIndex((value) => this.compareSections(value, this.selectedSection!)); + + let j: number; + for (j = i - 1; j >= 1; j--) { + if (this.canViewSection(this.sections![j])) { + break; + } + } + this.previousSection = j >= 1 ? this.sections![j] : undefined; + + for (j = i + 1; j < this.sections!.length; j++) { + if (this.canViewSection(this.sections![j])) { + break; + } + } + this.nextSection = j < this.sections!.length ? this.sections![j] : undefined; + } else { + this.previousSection = undefined; + this.nextSection = undefined; + this.canLoadMore = false; + this.showSectionId = 0; + this.showMoreActivities(); + if (this.downloadEnabled) { + this.calculateSectionsStatus(false); + } + } + + if (this.moduleId && typeof previousValue == 'undefined') { + setTimeout(() => { + CoreDomUtils.instance.scrollToElementBySelector(this.content, '#core-course-module-' + this.moduleId); + }, 200); + } else { + this.content.scrollToTop(0); + } + + if (!previousValue || previousValue.id != newSection.id) { + // First load or section changed, add log in Moodle. + CoreUtils.instance.ignoreErrors( + CoreCourse.instance.logView(this.course!.id, newSection.section, undefined, this.course!.fullname), + ); + } + } + + /** + * Compare if two sections are equal. + * + * @param section1 First section. + * @param section2 Second section. + * @return Whether they're equal. + */ + compareSections(section1: CoreCourseSection, section2: CoreCourseSection): boolean { + return section1 && section2 ? section1.id === section2.id : section1 === section2; + } + + /** + * Calculate the status of sections. + * + * @param refresh If refresh or not. + */ + protected calculateSectionsStatus(refresh?: boolean): void { + if (!this.sections || !this.course) { + return; + } + + CoreUtils.instance.ignoreErrors(CoreCourseHelper.instance.calculateSectionsStatus(this.sections, this.course.id, refresh)); + } + + /** + * Confirm and prefetch a section. If the section is "all sections", prefetch all the sections. + * + * @param section Section to download. + * @param refresh Refresh clicked (not used). + */ + async prefetch(section: CoreCourseSectionWithStatus): Promise { + section.isCalculating = true; + + try { + await CoreCourseHelper.instance.confirmDownloadSizeSection(this.course!.id, section, this.sections); + + await this.prefetchSection(section, true); + } catch (error) { + // User cancelled or there was an error calculating the size. + if (error) { + CoreDomUtils.instance.showErrorModal(error); + } + } finally { + section.isCalculating = false; + } + } + + /** + * Prefetch a section. + * + * @param section The section to download. + * @param manual Whether the prefetch was started manually or it was automatically started because all modules + * are being downloaded. + */ + protected async prefetchSection(section: CoreCourseSectionWithStatus, manual?: boolean): Promise { + try { + await CoreCourseHelper.instance.prefetchSection(section, this.course!.id, this.sections); + } catch (error) { + // Don't show error message if it's an automatic download. + if (!manual) { + return; + } + + CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.errordownloadingsection', true); + } + } + + /** + * Refresh the data. + * + * @param refresher Refresher. + * @param done Function to call when done. + * @param afterCompletionChange Whether the refresh is due to a completion change. + * @return Promise resolved when done. + */ + async doRefresh(refresher?: CustomEvent, done?: () => void, afterCompletionChange?: boolean): Promise { + const promises = this.dynamicComponents?.map(async (component) => { + await component.callComponentFunction('doRefresh', [refresher, done, afterCompletionChange]); + }) || []; + + if (this.courseBlocksComponent) { + promises.push(this.courseBlocksComponent.doRefresh()); + } + + await Promise.all(promises); + } + + /** + * Show more activities (only used when showing all the sections at the same time). + * + * @param infiniteComplete Infinite scroll complete function. Only used from core-infinite-loading. + */ + showMoreActivities(infiniteComplete?: () => void): void { + this.canLoadMore = false; + + const sections = this.sections || []; + let modulesLoaded = 0; + let i: number; + for (i = this.showSectionId + 1; i < sections.length; i++) { + if (!sections[i].hasContent || !sections[i].modules) { + continue; + } + + modulesLoaded += sections[i].modules.reduce((total, module) => module.visibleoncoursepage !== 0 ? total + 1 : total, 0); + + if (modulesLoaded >= CoreCourseFormatComponent.LOAD_MORE_ACTIVITIES) { + break; + } + } + + this.showSectionId = i; + this.canLoadMore = i < sections.length; + + if (this.canLoadMore) { + // Check if any of the following sections have any content. + let thereAreMore = false; + for (i++; i < sections.length; i++) { + if (sections[i].hasContent && sections[i].modules && sections[i].modules?.length > 0) { + thereAreMore = true; + break; + } + } + this.canLoadMore = thereAreMore; + } + + infiniteComplete && infiniteComplete(); + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.sectionStatusObserver && this.sectionStatusObserver.off(); + this.selectTabObserver && this.selectTabObserver.off(); + } + + /** + * User entered the page that contains the component. + */ + ionViewDidEnter(): void { + this.dynamicComponents?.forEach((component) => { + component.callComponentFunction('ionViewDidEnter'); + }); + + if (!this.downloadEnabled || !this.course || !this.sections) { + return; + } + + // The download status of a section might have been changed from within a module page. + if (this.selectedSection && this.selectedSection.id !== CoreCourseProvider.ALL_SECTIONS_ID) { + CoreCourseHelper.instance.calculateSectionStatus(this.selectedSection, this.course.id, false, false); + } else { + CoreCourseHelper.instance.calculateSectionsStatus(this.sections, this.course.id, false, false); + } + } + + /** + * User left the page that contains the component. + */ + ionViewDidLeave(): void { + this.dynamicComponents?.forEach((component) => { + component.callComponentFunction('ionViewDidLeave'); + }); + } + + /** + * Check whether a section can be viewed. + * + * @param section The section to check. + * @return Whether the section can be viewed. + */ + canViewSection(section: CoreCourseSection): boolean { + return section.uservisible !== false && !section.hiddenbynumsections && + section.id != CoreCourseProvider.STEALTH_MODULES_SECTION_ID; + } + + /** + * The completion of any of the modules have changed. + */ + onCompletionChange(completionData: CoreCourseModuleCompletionData): void { + // Emit a new event for other components. + this.completionChanged.emit(completionData); + + if (completionData.valueused !== false || !this.course || !('progress' in this.course) || + typeof this.course.progress == 'undefined') { + return; + } + + // If the completion value is not used, the page won't be reloaded, so update the progress bar. + const completionModules = ( []) + .concat(...this.sections!.map((section) => section.modules)) + .map((module) => module.completion && module.completion > 0 ? 1 : module.completion) + .reduce((accumulator, currentValue) => (accumulator || 0) + (currentValue || 0)); + + const moduleProgressPercent = 100 / (completionModules || 1); + // Use min/max here to avoid floating point rounding errors over/under-flowing the progress bar. + if (completionData.state === CoreCourseProvider.COMPLETION_COMPLETE) { + this.course.progress = Math.min(100, this.course.progress + moduleProgressPercent); + } else { + this.course.progress = Math.max(0, this.course.progress - moduleProgressPercent); + } + } + + /** + * Recalculate the download status of each section, in response to a module being downloaded. + */ + onModuleStatusChange(): void { + if (!this.downloadEnabled || !this.sections || !this.course) { + return; + } + + CoreCourseHelper.instance.calculateSectionsStatus(this.sections, this.course.id, false, false); + } + +} diff --git a/src/core/features/course/components/module-completion/core-course-module-completion.html b/src/core/features/course/components/module-completion/core-course-module-completion.html new file mode 100644 index 000000000..ea6f994ee --- /dev/null +++ b/src/core/features/course/components/module-completion/core-course-module-completion.html @@ -0,0 +1,3 @@ + diff --git a/src/core/features/course/components/module-completion/module-completion.scss b/src/core/features/course/components/module-completion/module-completion.scss new file mode 100644 index 000000000..664cb4eef --- /dev/null +++ b/src/core/features/course/components/module-completion/module-completion.scss @@ -0,0 +1,13 @@ +:host { + button { + display: block; + background-color: transparent; + + img { + padding: 5px; + width: 30px; + vertical-align: middle; + max-width: none; + } + } +} diff --git a/src/core/features/course/components/module-completion/module-completion.ts b/src/core/features/course/components/module-completion/module-completion.ts new file mode 100644 index 000000000..8f195d0f0 --- /dev/null +++ b/src/core/features/course/components/module-completion/module-completion.ts @@ -0,0 +1,176 @@ +// (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 { Component, Input, Output, EventEmitter, OnChanges, SimpleChange } from '@angular/core'; + +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUser } from '@features/user/services/user'; +import { CoreCourse, CoreCourseProvider } from '@features/course/services/course'; +import { CoreFilterHelper } from '@features/filter/services/filter-helper'; +import { CoreCourseModuleCompletionData } from '@features/course/services/course-helper'; +import { Translate } from '@singletons'; + +/** + * Component to handle activity completion. It shows a checkbox with the current status, and allows manually changing + * the completion if it's allowed. + * + * Example usage: + * + * + */ +@Component({ + selector: 'core-course-module-completion', + templateUrl: 'core-course-module-completion.html', + styleUrls: ['module-completion.scss'], +}) +export class CoreCourseModuleCompletionComponent implements OnChanges { + + @Input() completion?: CoreCourseModuleCompletionData; // The completion status. + @Input() moduleId?: number; // The name of the module this completion affects. + @Input() moduleName?: string; // The name of the module this completion affects. + @Output() completionChanged = new EventEmitter(); // Notify when completion changes. + + completionImage?: string; + completionDescription?: string; + + /** + * Detect changes on input properties. + */ + ngOnChanges(changes: { [name: string]: SimpleChange }): void { + if (changes.completion && this.completion) { + this.showStatus(); + } + } + + /** + * Completion clicked. + * + * @param e The click event. + */ + async completionClicked(e: Event): Promise { + if (!this.completion) { + return; + } + + if (typeof this.completion.cmid == 'undefined' || this.completion.tracking !== 1) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + const modal = await CoreDomUtils.instance.showModalLoading(); + this.completion.state = this.completion.state === 1 ? 0 : 1; + + try { + const response = await CoreCourse.instance.markCompletedManually( + this.completion.cmid, + this.completion.state === 1, + this.completion.courseId!, + this.completion.courseName, + ); + + if (this.completion.valueused === false) { + this.showStatus(); + if (response.offline) { + this.completion.offline = true; + } + } + this.completionChanged.emit(this.completion); + } catch (error) { + this.completion.state = this.completion.state === 1 ? 0 : 1; + CoreDomUtils.instance.showErrorModalDefault(error, 'core.errorchangecompletion', true); + } finally { + modal.dismiss(); + } + } + + /** + * Set image and description to show as completion icon. + */ + protected async showStatus(): Promise { + if (!this.completion) { + return; + } + + const moduleName = this.moduleName || ''; + let langKey: string | undefined; + let image: string | undefined; + + if (this.completion.tracking === CoreCourseProvider.COMPLETION_TRACKING_MANUAL && + this.completion.state === CoreCourseProvider.COMPLETION_INCOMPLETE) { + image = 'completion-manual-n'; + langKey = 'core.completion-alt-manual-n'; + } else if (this.completion.tracking === CoreCourseProvider.COMPLETION_TRACKING_MANUAL && + this.completion.state === CoreCourseProvider.COMPLETION_COMPLETE) { + image = 'completion-manual-y'; + langKey = 'core.completion-alt-manual-y'; + } else if (this.completion.tracking === CoreCourseProvider.COMPLETION_TRACKING_AUTOMATIC && + this.completion.state === CoreCourseProvider.COMPLETION_INCOMPLETE) { + image = 'completion-auto-n'; + langKey = 'core.completion-alt-auto-n'; + } else if (this.completion.tracking === CoreCourseProvider.COMPLETION_TRACKING_AUTOMATIC && + this.completion.state === CoreCourseProvider.COMPLETION_COMPLETE) { + image = 'completion-auto-y'; + langKey = 'core.completion-alt-auto-y'; + } else if (this.completion.tracking === CoreCourseProvider.COMPLETION_TRACKING_AUTOMATIC && + this.completion.state === CoreCourseProvider.COMPLETION_COMPLETE_PASS) { + image = 'completion-auto-pass'; + langKey = 'core.completion-alt-auto-pass'; + } else if (this.completion.tracking === CoreCourseProvider.COMPLETION_TRACKING_AUTOMATIC && + this.completion.state === CoreCourseProvider.COMPLETION_COMPLETE_FAIL) { + image = 'completion-auto-fail'; + langKey = 'core.completion-alt-auto-fail'; + } + + if (image) { + if (this.completion.overrideby > 0) { + image += '-override'; + } + this.completionImage = 'assets/img/completion/' + image + '.svg'; + } + + if (!moduleName || !this.moduleId || !langKey) { + return; + } + + const result = await CoreFilterHelper.instance.getFiltersAndFormatText( + moduleName, + 'module', + this.moduleId, + { clean: true, singleLine: true, shortenLength: 50, courseId: this.completion.courseId }, + ); + + let translateParams: Record = { + $a: result.text, + }; + + if (this.completion.overrideby > 0) { + langKey += '-override'; + + const profile = await CoreUser.instance.getProfile(this.completion.overrideby, this.completion.courseId, true); + + translateParams = { + $a: { + overrideuser: profile.fullname, + modname: result.text, + }, + }; + } + + this.completionDescription = Translate.instance.instant(langKey, translateParams); + } + +} diff --git a/src/core/features/course/components/module-description/core-course-module-description.html b/src/core/features/course/components/module-description/core-course-module-description.html new file mode 100644 index 000000000..e07546c5a --- /dev/null +++ b/src/core/features/course/components/module-description/core-course-module-description.html @@ -0,0 +1,15 @@ + + + + + + + + + + {{ note }} + + + \ No newline at end of file diff --git a/src/core/features/course/components/module-description/course-module-description.scss b/src/core/features/course/components/module-description/course-module-description.scss new file mode 100644 index 000000000..7ba1e696e --- /dev/null +++ b/src/core/features/course/components/module-description/course-module-description.scss @@ -0,0 +1,17 @@ +// @todo Review commented styles. +// ion-app.app-root { +// .safe-area-page, +// .safe-padding-horizontal { +// core-course-module-description { +// padding-left: 0 !important; +// padding-right: 0 !important; +// .item-ios.item-block { +// @include safe-area-padding-horizontal($item-ios-padding-end / 2, null); + +// .item-inner { +// @include safe-area-padding-horizontal(null, $item-ios-padding-end / 2); +// } +// } +// } +// } +// } diff --git a/src/core/features/course/components/module-description/module-description.ts b/src/core/features/course/components/module-description/module-description.ts new file mode 100644 index 000000000..d5fcf1139 --- /dev/null +++ b/src/core/features/course/components/module-description/module-description.ts @@ -0,0 +1,48 @@ +// (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 { Component, Input } from '@angular/core'; + +/** + * Component to display the description of a module. + * + * This directive is meant to display a module description in a similar way throughout all the app. + * + * You can add a note at the right side of the description by using the 'note' attribute. + * + * You can also pass a component and componentId to be used in format-text. + * + * Module descriptions are shortened by default, allowing the user to see the full description by clicking in it. + * If you want the whole description to be shown you can use the 'showFull' attribute. + * + * Example usage: + * + * + + + + +
+ + + + +
+ + + + +
+ + + + + + +
+
+
+ +
+ + + + + {{ 'core.course.hiddenfromstudents' | translate }} + + + {{ 'core.course.hiddenoncoursepage' | translate }} + +
+ {{ 'core.restricted' | translate }} + + +
+ + {{ 'core.course.manualcompletionnotsynced' | translate }} + +
+ + + +
+ + + + + + diff --git a/src/core/features/course/components/module/module.scss b/src/core/features/course/components/module/module.scss new file mode 100644 index 000000000..5ded99475 --- /dev/null +++ b/src/core/features/course/components/module/module.scss @@ -0,0 +1,164 @@ +:host { + // @todo Review commented styles. + + background: white; + display: block; + + .item.core-course-module-handler { + align-items: flex-start; + min-height: 52px; + cursor: pointer; + +// &.item .item-inner { +// @include safe-area-padding(null, 0px, null, null); +// } +// .label { +// @include margin(0, 0, 0, null); +// } + .core-module-icon { + align-items: flex-start; + width: 24px; + height: 24px; + margin-top: 11px; + } + +// &.item-ios:active, +// &.item-ios.activated { +// background-color: $list-ios-activated-background-color; +// } +// &.item-md:active, +// &.item-md.activated { +// background-color: $list-md-activated-background-color; +// } + } + + .core-module-title { + display: flex; + flex-flow: row; + align-items: flex-start; + + core-format-text { + flex-grow: 2; + } + .core-module-buttons, + .buttons.core-module-buttons { + margin: 0; + } + + .core-module-buttons, + .core-module-buttons-more { + display: flex; + flex-flow: row; + align-items: center; + z-index: 1; + justify-content: space-around; + align-content: center; + } + + .core-module-buttons core-course-module-completion, + .core-module-buttons-more button { + cursor: pointer; + pointer-events: auto; + } + + .core-module-buttons core-course-module-completion { + text-align: center; + } + } + + .core-module-more-info { + // ion-badge { + // @include text-align('start'); + // } + + .core-module-availabilityinfo { + font-size: 90%; + ul { + margin-block-start: 0.5em; + } + } + } + + .core-not-clickable { + cursor: initial; + +// &:active, +// &.activated { +// background-color: $list-background-color; +// } + } + + .core-module-loading { + width: 100%; + text-align: center; + padding-top: 10px; + clear: both; +// @include darkmode() { +// color: $core-dark-text-color; +// } + } + +// @include darkmode() { +// .item.core-course-module-handler { +// background: $core-dark-item-bg-color; +// &.item-ios:active, +// &.item-ios.activated, +// &.item-md:active, +// &.item-md.activated { +// background-color: $core-dark-background-color; +// } +// } + +// .core-not-clickable:active, +// .core-not-clickable.activated { +// background-color: $core-dark-item-bg-color; +// } +// } +} + +// ion-app.app-root.md core-course-module { +// .core-module-description { +// @include padding(null, $label-md-margin-end, null, null); +// margin-bottom: $label-md-margin-bottom; + +// .core-show-more { +// @include padding(null, $label-md-margin-end, null, null); +// } +// } + +// .core-module-title core-format-text { +// padding-top: $label-md-margin-top + 3; +// } +// .button-md { +// margin-top: 8px; +// margin-bottom: 8px; +// } +// .core-module-buttons-more { +// min-height: 52px; +// min-width: 53px; +// } +// } + +// ion-app.app-root.ios core-course-module { +// .core-module-description { +// @include padding(null, $label-ios-margin-end, null, null); +// margin-bottom: $label-md-margin-bottom; + +// .core-show-more { +// @include padding(null, $label-ios-margin-end, null, null); +// } +// } + +// .core-module-title core-format-text { +// padding-top: $label-ios-margin-top + 3; +// } + +// .core-module-buttons-more { +// min-height: 53px; +// min-width: 58px; +// } +// } + +// ion-app.app-root .core-course-module-handler.item [item-start] + .item-inner { +// @include margin-horizontal(4px, null); +// } diff --git a/src/core/features/course/components/module/module.ts b/src/core/features/course/components/module/module.ts new file mode 100644 index 000000000..d3450472b --- /dev/null +++ b/src/core/features/course/components/module/module.ts @@ -0,0 +1,234 @@ +// (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 { Component, Input, Output, EventEmitter, OnInit, OnDestroy } from '@angular/core'; + +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreEventObserver, CoreEventPackageStatusChanged, CoreEvents } from '@singletons/events'; +import { + CoreCourseHelper, + CoreCourseModule, + CoreCourseModuleCompletionData, + CoreCourseSection, +} from '@features/course/services/course-helper'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreCourseModuleHandlerButton } from '@features/course/services/module-delegate'; +import { + CoreCourseModulePrefetchDelegate, + CoreCourseModulePrefetchHandler, +} from '@features/course/services/module-prefetch-delegate'; + +/** + * Component to display a module entry in a list of modules. + * + * Example usage: + * + * + */ +@Component({ + selector: 'core-course-module', + templateUrl: 'core-course-module.html', + styleUrls: ['module.scss'], +}) +export class CoreCourseModuleComponent implements OnInit, OnDestroy { + + @Input() module?: CoreCourseModule; // The module to render. + @Input() courseId?: number; // The course the module belongs to. + @Input() section?: CoreCourseSection; // The section the module belongs to. + // eslint-disable-next-line @angular-eslint/no-input-rename + @Input('downloadEnabled') set enabled(value: boolean) { + this.downloadEnabled = value; + + if (!this.module?.handlerData?.showDownloadButton || !this.downloadEnabled || this.statusCalculated) { + return; + } + + // First time that the download is enabled. Initialize the data. + this.statusCalculated = true; + this.spinner = true; // Show spinner while calculating the status. + + // Get current status to decide which icon should be shown. + this.calculateAndShowStatus(); + }; + + @Output() completionChanged = new EventEmitter(); // Notify when module completion changes. + @Output() statusChanged = new EventEmitter(); // Notify when the download status changes. + + downloadStatus?: string; + canCheckUpdates?: boolean; + spinner?: boolean; // Whether to display a loading spinner. + downloadEnabled?: boolean; // Whether the download of sections and modules is enabled. + modNameTranslated = ''; + + protected prefetchHandler?: CoreCourseModulePrefetchHandler; + protected statusObserver?: CoreEventObserver; + protected statusCalculated = false; + protected isDestroyed = false; + + /** + * Component being initialized. + */ + ngOnInit(): void { + if (!this.module) { + return; + } + + this.courseId = this.courseId || this.module.course; + this.modNameTranslated = CoreCourse.instance.translateModuleName(this.module.modname) || ''; + + if (!this.module.handlerData) { + return; + } + + this.module.handlerData.a11yTitle = this.module.handlerData.a11yTitle ?? this.module.handlerData.title; + + if (this.module.handlerData.showDownloadButton) { + // Listen for changes on this module status, even if download isn't enabled. + this.prefetchHandler = CoreCourseModulePrefetchDelegate.instance.getPrefetchHandlerFor(this.module); + this.canCheckUpdates = CoreCourseModulePrefetchDelegate.instance.canCheckUpdates(); + + this.statusObserver = CoreEvents.on(CoreEvents.PACKAGE_STATUS_CHANGED, (data) => { + if (!this.module || data.componentId != this.module.id || !this.prefetchHandler || + data.component != this.prefetchHandler.component) { + return; + } + + // Call determineModuleStatus to get the right status to display. + const status = CoreCourseModulePrefetchDelegate.instance.determineModuleStatus(this.module, data.status); + + if (this.downloadEnabled) { + // Download is enabled, show the status. + this.showStatus(status); + } else if (this.module.handlerData?.updateStatus) { + // Download isn't enabled but the handler defines a updateStatus function, call it anyway. + this.module.handlerData.updateStatus(status); + } + }, CoreSites.instance.getCurrentSiteId()); + } + } + + /** + * Function called when the module is clicked. + * + * @param event Click event. + */ + moduleClicked(event: Event): void { + if (this.module?.uservisible !== false && this.module?.handlerData?.action) { + this.module.handlerData.action(event, this.module, this.courseId!); + } + } + + /** + * Function called when a button is clicked. + * + * @param event Click event. + * @param button The clicked button. + */ + buttonClicked(event: Event, button: CoreCourseModuleHandlerButton): void { + if (!button || !button.action) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + button.action(event, this.module!, this.courseId!); + } + + /** + * Download the module. + * + * @param refresh Whether it's refreshing. + * @return Promise resolved when done. + */ + async download(refresh: boolean): Promise { + if (!this.prefetchHandler || !this.module) { + return; + } + + // Show spinner since this operation might take a while. + this.spinner = true; + + try { + // Get download size to ask for confirm if it's high. + const size = await this.prefetchHandler.getDownloadSize(this.module, this.courseId!, true); + + await CoreCourseHelper.instance.prefetchModule(this.prefetchHandler, this.module, size, this.courseId!, refresh); + + const eventData = { + sectionId: this.section?.id, + moduleId: this.module.id, + courseId: this.courseId!, + }; + this.statusChanged.emit(eventData); + } catch (error) { + // Error, hide spinner. + this.spinner = false; + if (!this.isDestroyed) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.errordownloading', true); + } + } + } + + /** + * Show download buttons according to module status. + * + * @param status Module status. + */ + protected showStatus(status: string): void { + if (!status) { + return; + } + + this.spinner = false; + this.downloadStatus = status; + + this.module?.handlerData?.updateStatus?.(status); + } + + /** + * Calculate and show module status. + * + * @return Promise resolved when done. + */ + protected async calculateAndShowStatus(): Promise { + if (!this.module || !this.courseId) { + return; + } + + const status = await CoreCourseModulePrefetchDelegate.instance.getModuleStatus(this.module, this.courseId); + + this.showStatus(status); + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + // this.statusObserver?.off(); + this.module?.handlerData?.onDestroy?.(); + this.isDestroyed = true; + } + +} + +/** + * Data sent to the status changed output. + */ +export type CoreCourseModuleStatusChangedData = { + moduleId: number; + courseId: number; + sectionId?: number; +}; diff --git a/src/core/features/course/components/section-selector/section-selector.html b/src/core/features/course/components/section-selector/section-selector.html new file mode 100644 index 000000000..f0997453a --- /dev/null +++ b/src/core/features/course/components/section-selector/section-selector.html @@ -0,0 +1,39 @@ + + + {{ 'core.course.sections' | translate }} + + + + + + + + + + + + + + +

+

+ + + + {{ 'core.course.hiddenfromstudents' | translate }} + + + {{ 'core.notavailable' | translate }} + + + + + +
+
+
+
+
diff --git a/src/core/features/course/components/section-selector/section-selector.scss b/src/core/features/course/components/section-selector/section-selector.scss new file mode 100644 index 000000000..429076451 --- /dev/null +++ b/src/core/features/course/components/section-selector/section-selector.scss @@ -0,0 +1,17 @@ +:host { + core-progress-bar { + .core-progress-text { + line-height: 24px; + position: absolute; + top: -8px; + right: 10px; + } + progress { + margin: 8px 0 4px 0; + } + } + + ion-badge { + text-align: start; + } +} \ No newline at end of file diff --git a/src/core/features/course/components/section-selector/section-selector.ts b/src/core/features/course/components/section-selector/section-selector.ts new file mode 100644 index 000000000..623d4473b --- /dev/null +++ b/src/core/features/course/components/section-selector/section-selector.ts @@ -0,0 +1,98 @@ +// (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 { Component, Input, OnInit } from '@angular/core'; + +import { CoreCourseSection } from '@features/course/services/course-helper'; +import { CoreCourseProvider } from '@features/course/services/course'; +import { CoreCourseAnyCourseData } from '@features/courses/services/courses'; +import { CoreUtils } from '@services/utils/utils'; +import { ModalController } from '@singletons'; + +/** + * Component to display course section selector in a modal. + */ +@Component({ + selector: 'core-course-section-selector', + templateUrl: 'section-selector.html', +}) +export class CoreCourseSectionSelectorComponent implements OnInit { + + @Input() sections?: SectionWithProgress[]; + @Input() selected?: CoreCourseSection; + @Input() course?: CoreCourseAnyCourseData; + + stealthModulesSectionId = CoreCourseProvider.STEALTH_MODULES_SECTION_ID; + + /** + * Component being initialized. + */ + ngOnInit(): void { + + if (!this.course || !this.sections || !this.course.enablecompletion || !('courseformatoptions' in this.course) || + !this.course.courseformatoptions) { + return; + } + + const formatOptions = CoreUtils.instance.objectToKeyValueMap(this.course.courseformatoptions, 'name', 'value'); + + if (!formatOptions || formatOptions.coursedisplay != 1 || formatOptions.completionusertracked === false) { + return; + } + + this.sections.forEach((section) => { + let complete = 0; + let total = 0; + section.modules.forEach((module) => { + if (!module.uservisible || module.completiondata === undefined || module.completiondata.tracking === undefined || + module.completiondata.tracking <= CoreCourseProvider.COMPLETION_TRACKING_NONE) { + return; + } + + total++; + if (module.completiondata.state == CoreCourseProvider.COMPLETION_COMPLETE || + module.completiondata.state == CoreCourseProvider.COMPLETION_COMPLETE_PASS) { + complete++; + } + }); + + if (total > 0) { + section.progress = complete / total * 100; + } + }); + } + + /** + * Close the modal. + */ + closeModal(): void { + ModalController.instance.dismiss(); + } + + /** + * Select a section. + * + * @param section Selected section object. + */ + selectSection(section: SectionWithProgress): void { + if (section.uservisible !== false) { + ModalController.instance.dismiss(section); + } + } + +} + +type SectionWithProgress = CoreCourseSection & { + progress?: number; +}; diff --git a/src/core/features/course/components/tag-area/core-course-tag-area.html b/src/core/features/course/components/tag-area/core-course-tag-area.html new file mode 100644 index 000000000..d7863d9e8 --- /dev/null +++ b/src/core/features/course/components/tag-area/core-course-tag-area.html @@ -0,0 +1,7 @@ + + + +

{{ item.courseName }}

+

{{ 'core.category' | translate }}: {{ item.categoryName }}

+
+
\ No newline at end of file diff --git a/src/core/features/course/components/tag-area/tag-area.ts b/src/core/features/course/components/tag-area/tag-area.ts new file mode 100644 index 000000000..401ba9b64 --- /dev/null +++ b/src/core/features/course/components/tag-area/tag-area.ts @@ -0,0 +1,42 @@ +// (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 { Component, Input } from '@angular/core'; + +import { CoreCourseHelper } from '@features/course/services/course-helper'; +import { CoreCouseTagItems } from '@features/course/services/handlers/course-tag-area'; + +/** + * Component that renders the course tag area. + */ +@Component({ + selector: 'core-course-tag-area', + templateUrl: 'core-course-tag-area.html', +}) +export class CoreCourseTagAreaComponent { + + @Input() items?: CoreCouseTagItems[]; // Area items to render. + + /** + * Open a course. + * + * @param courseId The course to open. + */ + openCourse(courseId: number): void { + // @todo If this component is inside a split view, use the master nav to open it. + // const navCtrl = this.splitviewCtrl ? this.splitviewCtrl.getMasterNav() : this.navCtrl; + CoreCourseHelper.instance.getAndOpenCourse(courseId); + } + +} diff --git a/src/core/features/course/components/unsupported-module/core-course-unsupported-module.html b/src/core/features/course/components/unsupported-module/core-course-unsupported-module.html new file mode 100644 index 000000000..2344ab316 --- /dev/null +++ b/src/core/features/course/components/unsupported-module/core-course-unsupported-module.html @@ -0,0 +1,25 @@ +
+ + + +

{{ 'core.whoops' | translate }}

+

{{ 'core.uhoh' | translate }}

+ +

{{ 'core.course.activitydisabled' | translate }}

+

+ {{ 'core.course.activitynotyetviewablesiteupgradeneeded' | translate }} +

+

+ {{ 'core.course.activitynotyetviewableremoteaddon' | translate }} +

+

{{ 'core.course.askadmintosupport' | translate }}

+ +
+

{{ 'core.course.useactivityonbrowser' | translate }}

+ + {{ 'core.openinbrowser' | translate }} + + +
+
\ No newline at end of file diff --git a/src/core/features/course/components/unsupported-module/unsupported-module.ts b/src/core/features/course/components/unsupported-module/unsupported-module.ts new file mode 100644 index 000000000..b03e6709b --- /dev/null +++ b/src/core/features/course/components/unsupported-module/unsupported-module.ts @@ -0,0 +1,49 @@ +// (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 { Component, Input, OnInit } from '@angular/core'; + +import { CoreCourse, CoreCourseWSModule } from '@features/course/services/course'; +import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate'; + +/** + * Component that displays info about an unsupported module. + */ +@Component({ + selector: 'core-course-unsupported-module', + templateUrl: 'core-course-unsupported-module.html', +}) +export class CoreCourseUnsupportedModuleComponent implements OnInit { + + @Input() courseId?: number; // The course to module belongs to. + @Input() module?: CoreCourseWSModule; // The module to render. + + isDisabledInSite?: boolean; + isSupportedByTheApp?: boolean; + moduleName?: string; + + /** + * Component being initialized. + */ + ngOnInit(): void { + if (!this.module) { + return; + } + + this.isDisabledInSite = CoreCourseModuleDelegate.instance.isModuleDisabledInSite(this.module.modname); + this.isSupportedByTheApp = CoreCourseModuleDelegate.instance.hasHandler(this.module.modname); + this.moduleName = CoreCourse.instance.translateModuleName(this.module.modname); + } + +} diff --git a/src/core/features/course/course-lazy.module.ts b/src/core/features/course/course-lazy.module.ts new file mode 100644 index 000000000..fa42b82c0 --- /dev/null +++ b/src/core/features/course/course-lazy.module.ts @@ -0,0 +1,42 @@ +// (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 { NgModule } from '@angular/core'; +import { RouterModule, Routes } from '@angular/router'; + +const routes: Routes = [ + { + path: '', + redirectTo: 'index', + pathMatch: 'full', + }, + { + path: 'index', + loadChildren: () => import('./pages/index/index.module').then( m => m.CoreCourseIndexPageModule), + }, + { + path: 'unsupported-module', + loadChildren: () => import('./pages/unsupported-module/unsupported-module.module') + .then( m => m.CoreCourseUnsupportedModulePageModule), + }, + { + path: 'list-mod-type', + loadChildren: () => import('./pages/list-mod-type/list-mod-type').then( m => m.CoreCourseListModTypePage), + }, +]; + +@NgModule({ + imports: [RouterModule.forChild(routes)], +}) +export class CoreCourseLazyModule {} diff --git a/src/core/features/course/course.module.ts b/src/core/features/course/course.module.ts index 728272224..4d1563d58 100644 --- a/src/core/features/course/course.module.ts +++ b/src/core/features/course/course.module.ts @@ -12,20 +12,70 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { NgModule } from '@angular/core'; +import { APP_INITIALIZER, NgModule } from '@angular/core'; +import { Routes } from '@angular/router'; +import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module'; import { CORE_SITE_SCHEMAS } from '@services/sites'; - +import { CoreCourseComponentsModule } from './components/components.module'; +import { CoreCourseDirectivesModule } from './directives/directives.module'; +import { CoreCourseFormatModule } from './format/formats.module'; import { SITE_SCHEMA, OFFLINE_SITE_SCHEMA } from './services/database/course'; +import { SITE_SCHEMA as LOG_SITE_SCHEMA } from './services/database/log'; +import { SITE_SCHEMA as PREFETCH_SITE_SCHEMA } from './services/database/module-prefetch'; +import { CoreCourseIndexRoutingModule } from './pages/index/index-routing.module'; +import { CoreCourseModulePrefetchDelegate } from './services/module-prefetch-delegate'; +import { CoreCronDelegate } from '@services/cron'; +import { CoreCourseLogCronHandler } from './services/handlers/log-cron'; +import { CoreCourseSyncCronHandler } from './services/handlers/sync-cron'; +import { CoreTagAreaDelegate } from '@features/tag/services/tag-area-delegate'; +import { CoreCourseTagAreaHandler } from './services/handlers/course-tag-area'; +import { CoreCourseModulesTagAreaHandler } from './services/handlers/modules-tag-area'; +import { CoreCourse } from './services/course'; + +const routes: Routes = [ + { + path: 'course', + loadChildren: () => import('@features/course/course-lazy.module').then(m => m.CoreCourseLazyModule), + }, +]; + +const courseIndexRoutes: Routes = [ + { + path: 'contents', + loadChildren: () => import('./pages/contents/contents.module').then(m => m.CoreCourseContentsPageModule), + }, +]; @NgModule({ + imports: [ + CoreCourseIndexRoutingModule.forChild({ children: courseIndexRoutes }), + CoreMainMenuTabRoutingModule.forChild(routes), + CoreCourseFormatModule, + CoreCourseComponentsModule, + CoreCourseDirectivesModule, + ], + exports: [CoreCourseIndexRoutingModule], providers: [ { provide: CORE_SITE_SCHEMAS, - useValue: [SITE_SCHEMA, OFFLINE_SITE_SCHEMA], + useValue: [SITE_SCHEMA, OFFLINE_SITE_SCHEMA, LOG_SITE_SCHEMA, PREFETCH_SITE_SCHEMA], multi: true, }, + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreCronDelegate.instance.register(CoreCourseSyncCronHandler.instance); + CoreCronDelegate.instance.register(CoreCourseLogCronHandler.instance); + CoreTagAreaDelegate.instance.registerHandler(CoreCourseTagAreaHandler.instance); + CoreTagAreaDelegate.instance.registerHandler(CoreCourseModulesTagAreaHandler.instance); + + CoreCourse.instance.initialize(); + CoreCourseModulePrefetchDelegate.instance.initialize(); + }, + }, ], }) -export class CoreCourseModule { -} +export class CoreCourseModule {} diff --git a/src/core/features/course/directives/directives.module.ts b/src/core/features/course/directives/directives.module.ts new file mode 100644 index 000000000..41dfe1b61 --- /dev/null +++ b/src/core/features/course/directives/directives.module.ts @@ -0,0 +1,28 @@ +// (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 { NgModule } from '@angular/core'; + +import { CoreCourseDownloadModuleMainFileDirective } from './download-module-main-file'; + +@NgModule({ + declarations: [ + CoreCourseDownloadModuleMainFileDirective, + ], + imports: [], + exports: [ + CoreCourseDownloadModuleMainFileDirective, + ], +}) +export class CoreCourseDirectivesModule {} diff --git a/src/core/features/course/directives/download-module-main-file.ts b/src/core/features/course/directives/download-module-main-file.ts new file mode 100644 index 000000000..3d99485de --- /dev/null +++ b/src/core/features/course/directives/download-module-main-file.ts @@ -0,0 +1,85 @@ +// (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 { Directive, Input, OnInit, ElementRef } from '@angular/core'; + +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreCourse, CoreCourseModuleContentFile, CoreCourseWSModule } from '@features/course/services/course'; +import { CoreCourseHelper } from '@features/course/services/course-helper'; + +/** + * Directive to allow downloading and open the main file of a module. + * When the item with this directive is clicked, the module will be downloaded (if needed) and opened. + * This is meant for modules like mod_resource. + * + * This directive must receive either a module or a moduleId. If no files are provided, it will use module.contents. + */ +@Directive({ + selector: '[core-course-download-module-main-file]', +}) +export class CoreCourseDownloadModuleMainFileDirective implements OnInit { + + @Input() module?: CoreCourseWSModule; // The module. + @Input() moduleId?: string | number; // The module ID. Required if module is not supplied. + @Input() courseId?: string | number; // The course ID. + @Input() component?: string; // Component to link the file to. + @Input() componentId?: string | number; // Component ID to use in conjunction with the component. If not defined, use moduleId. + @Input() files?: CoreCourseModuleContentFile[]; // List of files of the module. If not provided, use module.contents. + + protected element: HTMLElement; + + constructor(element: ElementRef) { + this.element = element.nativeElement; + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.element.addEventListener('click', async (ev: Event) => { + if (!this.module && !this.moduleId) { + return; + } + + ev.preventDefault(); + ev.stopPropagation(); + + const modal = await CoreDomUtils.instance.showModalLoading(); + const courseId = typeof this.courseId == 'string' ? parseInt(this.courseId, 10) : this.courseId; + + try { + if (!this.module) { + // Try to get the module from cache. + this.moduleId = typeof this.moduleId == 'string' ? parseInt(this.moduleId, 10) : this.moduleId; + this.module = await CoreCourse.instance.getModule(this.moduleId!, courseId); + } + + const componentId = this.componentId || module.id; + + await CoreCourseHelper.instance.downloadModuleAndOpenFile( + this.module, + courseId ?? this.module.course!, + this.component, + componentId, + this.files, + ); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.errordownloading', true); + } finally { + modal.dismiss(); + } + }); + } + +} diff --git a/src/core/features/course/format/formats.module.ts b/src/core/features/course/format/formats.module.ts new file mode 100644 index 000000000..7da1f733a --- /dev/null +++ b/src/core/features/course/format/formats.module.ts @@ -0,0 +1,33 @@ +// (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 { NgModule } from '@angular/core'; + +import { CoreCourseFormatSingleActivityModule } from './singleactivity/singleactivity.module'; +import { CoreCourseFormatSocialModule } from './social/social.module'; +import { CoreCourseFormatTopicsModule } from './topics/topics.module'; +import { CoreCourseFormatWeeksModule } from './weeks/weeks.module'; + +@NgModule({ + declarations: [], + imports: [ + CoreCourseFormatSingleActivityModule, + CoreCourseFormatSocialModule, + CoreCourseFormatTopicsModule, + CoreCourseFormatWeeksModule, + ], + providers: [], + exports: [], +}) +export class CoreCourseFormatModule { } diff --git a/src/core/features/course/format/singleactivity/components/core-course-format-single-activity.html b/src/core/features/course/format/singleactivity/components/core-course-format-single-activity.html new file mode 100644 index 000000000..1f3a37007 --- /dev/null +++ b/src/core/features/course/format/singleactivity/components/core-course-format-single-activity.html @@ -0,0 +1 @@ + diff --git a/src/core/features/course/format/singleactivity/components/singleactivity.ts b/src/core/features/course/format/singleactivity/components/singleactivity.ts new file mode 100644 index 000000000..3cb1f4e42 --- /dev/null +++ b/src/core/features/course/format/singleactivity/components/singleactivity.ts @@ -0,0 +1,104 @@ +// (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 { Component, Input, OnChanges, SimpleChange, ViewChild, Output, EventEmitter, Type } from '@angular/core'; + +import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate'; +import { CoreCourseUnsupportedModuleComponent } from '@features/course/components/unsupported-module/unsupported-module'; +import { CoreDynamicComponent } from '@components/dynamic-component/dynamic-component'; +import { CoreCourseAnyCourseData } from '@features/courses/services/courses'; +import { IonRefresher } from '@ionic/angular'; +import { CoreCourseModuleCompletionData, CoreCourseSectionWithStatus } from '@features/course/services/course-helper'; + +/** + * Component to display single activity format. It will determine the right component to use and instantiate it. + * + * The instantiated component will receive the course and the module as inputs. + */ +@Component({ + selector: 'core-course-format-single-activity', + templateUrl: 'core-course-format-single-activity.html', +}) +export class CoreCourseFormatSingleActivityComponent implements OnChanges { + + @Input() course?: CoreCourseAnyCourseData; // The course to render. + @Input() sections?: CoreCourseSectionWithStatus[]; // List of course sections. + @Input() downloadEnabled?: boolean; // Whether the download of sections and modules is enabled. + @Input() initialSectionId?: number; // The section to load first (by ID). + @Input() initialSectionNumber?: number; // The section to load first (by number). + @Input() moduleId?: number; // The module ID to scroll to. Must be inside the initial selected section. + @Output() completionChanged = new EventEmitter(); // Notify when any module completion changes. + + @ViewChild(CoreDynamicComponent) dynamicComponent?: CoreDynamicComponent; + + componentClass?: Type; // The class of the component to render. + data: Record = {}; // Data to pass to the component. + + /** + * Detect changes on input properties. + */ + async ngOnChanges(changes: { [name: string]: SimpleChange }): Promise { + if (!changes.course || !changes.sections) { + return; + } + + if (!this.course || !this.sections || !this.sections.length) { + return; + } + + // In single activity the module should only have 1 section and 1 module. Get the module. + const module = this.sections?.[0].modules?.[0]; + + this.data.courseId = this.course.id; + this.data.module = module; + + if (module && !this.componentClass) { + // We haven't obtained the class yet. Get it now. + const component = await CoreCourseModuleDelegate.instance.getMainComponent(this.course, module); + this.componentClass = component || CoreCourseUnsupportedModuleComponent; + } + } + + /** + * Refresh the data. + * + * @param refresher Refresher. + * @param done Function to call when done. + * @param afterCompletionChange Whether the refresh is due to a completion change. + * @return Promise resolved when done. + */ + async doRefresh(refresher?: CustomEvent, done?: () => void, afterCompletionChange?: boolean): Promise { + if (afterCompletionChange) { + // Don't refresh the view after a completion change since completion isn't displayed. + return; + } + + await this.dynamicComponent?.callComponentFunction('doRefresh', [refresher, done]); + } + + /** + * User entered the page that contains the component. + */ + ionViewDidEnter(): void { + this.dynamicComponent?.callComponentFunction('ionViewDidEnter'); + } + + /** + * User left the page that contains the component. + */ + ionViewDidLeave(): void { + this.dynamicComponent?.callComponentFunction('ionViewDidLeave'); + } + +} diff --git a/src/core/features/course/format/singleactivity/services/handlers/singleactivity-format.ts b/src/core/features/course/format/singleactivity/services/handlers/singleactivity-format.ts new file mode 100644 index 000000000..4cc6d6189 --- /dev/null +++ b/src/core/features/course/format/singleactivity/services/handlers/singleactivity-format.ts @@ -0,0 +1,153 @@ +// (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, Type } from '@angular/core'; + +import { CoreCourseWSSection } from '@features/course/services/course'; +import { CoreCourseFormatHandler } from '@features/course/services/format-delegate'; +import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate'; +import { CoreCourseAnyCourseData } from '@features/courses/services/courses'; +import { CoreCourseFormatSingleActivityComponent } from '../../components/singleactivity'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to support singleactivity course format. + */ +@Injectable({ providedIn: 'root' }) +export class CoreCourseFormatSingleActivityHandlerService implements CoreCourseFormatHandler { + + name = 'CoreCourseFormatSingleActivity'; + format = 'singleactivity'; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return True or promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Whether it allows seeing all sections at the same time. Defaults to true. + * + * @param course The course to check. + * @return Whether it can view all sections. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + canViewAllSections(course: CoreCourseAnyCourseData): boolean { + return false; + } + + /** + * Whether the option blocks should be displayed. Defaults to true. + * + * @param course The course to check. + * @return Whether it can display blocks. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + displayBlocks(course: CoreCourseAnyCourseData): boolean { + return false; + } + + /** + * Get the title to use in course page. If not defined, course displayname or fullname. + * This function will be called without sections first, and then call it again when the sections are retrieved. + * + * @param course The course. + * @param sections List of sections. + * @return Title. + */ + getCourseTitle(course: CoreCourseAnyCourseData, sections?: CoreCourseWSSection[]): string { + if (sections?.[0]?.modules?.[0]) { + return sections[0].modules[0].name; + } + + if (course.displayname) { + return course.displayname; + } else if (course.fullname) { + return course.fullname; + } + + return ''; + } + + /** + * Whether the option to enable section/module download should be displayed. Defaults to true. + * + * @param course The course to check. + * @return Whether the option to enable section/module download should be displayed + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + displayEnableDownload(course: CoreCourseAnyCourseData): boolean { + return false; + } + + /** + * Whether the default section selector should be displayed. Defaults to true. + * + * @param course The course to check. + * @return Whether the default section selector should be displayed. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + displaySectionSelector(course: CoreCourseAnyCourseData): boolean { + return false; + } + + /** + * Whether the course refresher should be displayed. If it returns false, a refresher must be included in the course format, + * and the doRefresh method of CoreCourseSectionPage must be called on refresh. Defaults to true. + * + * @param course The course to check. + * @param sections List of course sections. + * @return Whether the refresher should be displayed. + */ + displayRefresher(course: CoreCourseAnyCourseData, sections: CoreCourseWSSection[]): boolean { + if (sections?.[0]?.modules?.[0]) { + return CoreCourseModuleDelegate.instance.displayRefresherInSingleActivity(sections[0].modules[0].modname); + } else { + return true; + } + } + + /** + * Return the Component to use to display the course format instead of using the default one. + * Use it if you want to display a format completely different from the default one. + * If you want to customize the default format there are several methods to customize parts of it. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param injector Injector. + * @param course The course to render. + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getCourseFormatComponent(course: CoreCourseAnyCourseData): Promise> { + return CoreCourseFormatSingleActivityComponent; + } + + /** + * Whether the view should be refreshed when completion changes. If your course format doesn't display + * activity completion then you should return false. + * + * @param course The course. + * @return Whether course view should be refreshed when an activity completion changes. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async shouldRefreshWhenCompletionChanges(course: CoreCourseAnyCourseData): Promise { + return false; + } + +} + +export class CoreCourseFormatSingleActivityHandler extends makeSingleton(CoreCourseFormatSingleActivityHandlerService) {} diff --git a/src/core/features/course/format/singleactivity/singleactivity.module.ts b/src/core/features/course/format/singleactivity/singleactivity.module.ts new file mode 100644 index 000000000..be619a5ab --- /dev/null +++ b/src/core/features/course/format/singleactivity/singleactivity.module.ts @@ -0,0 +1,43 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreCourseFormatDelegate } from '@features/course/services/format-delegate'; +import { CoreCourseFormatSingleActivityComponent } from './components/singleactivity'; +import { CoreCourseFormatSingleActivityHandler } from './services/handlers/singleactivity-format'; + +@NgModule({ + declarations: [ + CoreCourseFormatSingleActivityComponent, + ], + imports: [ + CoreSharedModule, + ], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreCourseFormatDelegate.instance.registerHandler(CoreCourseFormatSingleActivityHandler.instance); + }, + }, + ], + exports: [ + CoreCourseFormatSingleActivityComponent, + ], +}) +export class CoreCourseFormatSingleActivityModule {} diff --git a/src/core/features/course/format/social/services/handlers/social-format.ts b/src/core/features/course/format/social/services/handlers/social-format.ts new file mode 100644 index 000000000..dde6c8711 --- /dev/null +++ b/src/core/features/course/format/social/services/handlers/social-format.ts @@ -0,0 +1,32 @@ +// (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 { makeSingleton } from '@singletons'; + +import { CoreCourseFormatSingleActivityHandlerService } from '../../../singleactivity/services/handlers/singleactivity-format'; + +/** + * Handler to support social course format. + * This format is like singleactivity in the mobile app, so we'll use the same implementation for both. + */ +@Injectable({ providedIn: 'root' }) +export class CoreCourseFormatSocialHandlerService extends CoreCourseFormatSingleActivityHandlerService { + + name = 'CoreCourseFormatSocial'; + format = 'social'; + +} + +export class CoreCourseFormatSocialHandler extends makeSingleton(CoreCourseFormatSocialHandlerService) {} diff --git a/src/core/features/course/format/social/social.module.ts b/src/core/features/course/format/social/social.module.ts new file mode 100644 index 000000000..7455034b7 --- /dev/null +++ b/src/core/features/course/format/social/social.module.ts @@ -0,0 +1,34 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreCourseFormatDelegate } from '@features/course/services/format-delegate'; +import { CoreCourseFormatSocialHandler } from './services/handlers/social-format'; + +@NgModule({ + declarations: [], + imports: [], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreCourseFormatDelegate.instance.registerHandler(CoreCourseFormatSocialHandler.instance); + }, + }, + ], +}) +export class CoreCourseFormatSocialModule {} diff --git a/src/core/features/course/format/topics/services/handlers/topics-format.ts b/src/core/features/course/format/topics/services/handlers/topics-format.ts new file mode 100644 index 000000000..78d305273 --- /dev/null +++ b/src/core/features/course/format/topics/services/handlers/topics-format.ts @@ -0,0 +1,40 @@ +// (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 { CoreCourseFormatHandler } from '@features/course/services/format-delegate'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to support topics course format. + */ +@Injectable({ providedIn: 'root' }) +export class CoreCourseFormatTopicsHandlerService implements CoreCourseFormatHandler { + + name = 'CoreCourseFormatTopics'; + format = 'topics'; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return True or promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return true; + } + +} + +export class CoreCourseFormatTopicsHandler extends makeSingleton(CoreCourseFormatTopicsHandlerService) {} diff --git a/src/core/features/course/format/topics/topics.module.ts b/src/core/features/course/format/topics/topics.module.ts new file mode 100644 index 000000000..01c8518c4 --- /dev/null +++ b/src/core/features/course/format/topics/topics.module.ts @@ -0,0 +1,34 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreCourseFormatDelegate } from '@features/course/services/format-delegate'; +import { CoreCourseFormatTopicsHandler } from './services/handlers/topics-format'; + +@NgModule({ + declarations: [], + imports: [], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreCourseFormatDelegate.instance.registerHandler(CoreCourseFormatTopicsHandler.instance); + }, + }, + ], +}) +export class CoreCourseFormatTopicsModule {} diff --git a/src/core/features/course/format/weeks/services/handlers/weeks-format.ts b/src/core/features/course/format/weeks/services/handlers/weeks-format.ts new file mode 100644 index 000000000..6f6b96752 --- /dev/null +++ b/src/core/features/course/format/weeks/services/handlers/weeks-format.ts @@ -0,0 +1,96 @@ +// (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 { CoreTimeUtils } from '@services/utils/time'; +import { CoreCourseFormatHandler } from '@features/course/services/format-delegate'; +import { makeSingleton } from '@singletons'; +import { CoreCourseAnyCourseData } from '@features/courses/services/courses'; +import { CoreCourseWSSection } from '@features/course/services/course'; +import { CoreConstants } from '@/core/constants'; +import { CoreCourseSection } from '@features/course/services/course-helper'; + +/** + * Handler to support weeks course format. + */ +@Injectable({ providedIn: 'root' }) +export class CoreCourseFormatWeeksHandlerService implements CoreCourseFormatHandler { + + name = 'CoreCourseFormatWeeks'; + format = 'weeks'; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return True or promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Given a list of sections, get the "current" section that should be displayed first. + * + * @param course The course to get the title. + * @param sections List of sections. + * @return Current section (or promise resolved with current section). + */ + async getCurrentSection(course: CoreCourseAnyCourseData, sections: CoreCourseSection[]): Promise { + const now = CoreTimeUtils.instance.timestamp(); + + if ((course.startdate && now < course.startdate) || (course.enddate && now > course.enddate)) { + // Course hasn't started yet or it has ended already. Return all sections. + return sections[0]; + } + + for (let i = 0; i < sections.length; i++) { + const section = sections[i]; + if (typeof section.section == 'undefined' || section.section < 1) { + continue; + } + + const dates = this.getSectionDates(section, course.startdate || 0); + if (now >= dates.start && now < dates.end) { + return section; + } + } + + // The section wasn't found, return all sections. + return sections[0]; + } + + /** + * Return the start and end date of a section. + * + * @param section The section to treat. + * @param startDate The course start date (in seconds). + * @return An object with the start and end date of the section. + */ + protected getSectionDates(section: CoreCourseWSSection, startDate: number): { start: number; end: number } { + // Hack alert. We add 2 hours to avoid possible DST problems. (e.g. we go into daylight savings and the date changes). + startDate = startDate + 7200; + + const dates = { + start: startDate + (CoreConstants.SECONDS_WEEK * (section.section! - 1)), + end: 0, + }; + dates.end = dates.start + CoreConstants.SECONDS_WEEK; + + return dates; + } + +} + +export class CoreCourseFormatWeeksHandler extends makeSingleton(CoreCourseFormatWeeksHandlerService) {} diff --git a/src/core/features/course/format/weeks/weeks.module.ts b/src/core/features/course/format/weeks/weeks.module.ts new file mode 100644 index 000000000..3ad1b0f19 --- /dev/null +++ b/src/core/features/course/format/weeks/weeks.module.ts @@ -0,0 +1,34 @@ +// (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 { APP_INITIALIZER, NgModule } from '@angular/core'; + +import { CoreCourseFormatDelegate } from '@features/course/services/format-delegate'; +import { CoreCourseFormatWeeksHandler } from './services/handlers/weeks-format'; + +@NgModule({ + declarations: [], + imports: [], + providers: [ + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreCourseFormatDelegate.instance.registerHandler(CoreCourseFormatWeeksHandler.instance); + }, + }, + ], +}) +export class CoreCourseFormatWeeksModule {} diff --git a/src/core/features/course/pages/contents/contents.html b/src/core/features/course/pages/contents/contents.html new file mode 100644 index 000000000..a779fc7fd --- /dev/null +++ b/src/core/features/course/pages/contents/contents.html @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/core/features/course/pages/contents/contents.module.ts b/src/core/features/course/pages/contents/contents.module.ts new file mode 100644 index 000000000..0c386db0e --- /dev/null +++ b/src/core/features/course/pages/contents/contents.module.ts @@ -0,0 +1,46 @@ +// (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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule, Routes } from '@angular/router'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreCourseContentsPage } from './contents'; +import { CoreCourseComponentsModule } from '@features/course/components/components.module'; + +const routes: Routes = [ + { + path: '', + component: CoreCourseContentsPage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreSharedModule, + CoreCourseComponentsModule, + ], + declarations: [ + CoreCourseContentsPage, + ], + exports: [RouterModule], +}) +export class CoreCourseContentsPageModule {} diff --git a/src/core/features/course/pages/contents/contents.ts b/src/core/features/course/pages/contents/contents.ts new file mode 100644 index 000000000..c67cabec1 --- /dev/null +++ b/src/core/features/course/pages/contents/contents.ts @@ -0,0 +1,501 @@ +// (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 { Component, ViewChild, OnInit, OnDestroy } from '@angular/core'; +import { IonContent, IonRefresher } from '@ionic/angular'; + +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreCourses, CoreCourseAnyCourseData } from '@features/courses/services/courses'; +import { + CoreCourse, + CoreCourseCompletionActivityStatus, + CoreCourseProvider, +} from '@features/course/services/course'; +import { + CoreCourseHelper, + CoreCourseModuleCompletionData, + CoreCourseSection, + CorePrefetchStatusInfo, +} from '@features/course/services/course-helper'; +import { CoreCourseFormatDelegate } from '@features/course/services/format-delegate'; +import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; +import { + CoreCourseOptionsDelegate, + CoreCourseOptionsMenuHandlerToDisplay, +} from '@features/course/services/course-options-delegate'; +import { CoreCourseAutoSyncData, CoreCourseSync, CoreCourseSyncProvider } from '@features/course/services/sync'; +import { CoreCourseFormatComponent } from '../../components/format/format'; +import { + CoreEvents, + CoreEventObserver, + CoreEventCourseStatusChanged, + CoreEventCompletionModuleViewedData, +} from '@singletons/events'; +import { CoreNavigator } from '@services/navigator'; + +/** + * Page that displays the contents of a course. + */ +@Component({ + selector: 'page-core-course-contents', + templateUrl: 'contents.html', +}) +export class CoreCourseContentsPage implements OnInit, OnDestroy { + + @ViewChild(IonContent) content?: IonContent; + @ViewChild(CoreCourseFormatComponent) formatComponent?: CoreCourseFormatComponent; + + course!: CoreCourseAnyCourseData; + sections?: CoreCourseSection[]; + sectionId?: number; + sectionNumber?: number; + courseMenuHandlers: CoreCourseOptionsMenuHandlerToDisplay[] = []; + dataLoaded = false; + downloadEnabled = false; + downloadEnabledIcon = 'far-square'; // Disabled by default. + downloadCourseEnabled = false; + moduleId?: number; + displayEnableDownload = false; + displayRefresher = false; + prefetchCourseData: CorePrefetchStatusInfo = { + icon: 'spinner', + statusTranslatable: 'core.course.downloadcourse', + status: '', + loading: true, + }; + + protected formatOptions?: Record; + protected completionObserver?: CoreEventObserver; + protected courseStatusObserver?: CoreEventObserver; + protected syncObserver?: CoreEventObserver; + protected isDestroyed = false; + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + const course = CoreNavigator.instance.getRouteParam('course'); + + if (!course) { + CoreDomUtils.instance.showErrorModal('Missing required course parameter.'); + CoreNavigator.instance.back(); + + return; + } + + this.course = course; + this.sectionId = CoreNavigator.instance.getRouteNumberParam('sectionId'); + this.sectionNumber = CoreNavigator.instance.getRouteNumberParam('sectionNumber'); + this.moduleId = CoreNavigator.instance.getRouteNumberParam('moduleId'); + + this.displayEnableDownload = !CoreSites.instance.getCurrentSite()?.isOfflineDisabled() && + CoreCourseFormatDelegate.instance.displayEnableDownload(this.course); + this.downloadCourseEnabled = !CoreCourses.instance.isDownloadCourseDisabledInSite(); + + this.initListeners(); + + await this.loadData(false, true); + + this.dataLoaded = true; + + this.initPrefetch(); + } + + /** + * Init listeners. + * + * @return Promise resolved when done. + */ + protected async initListeners(): Promise { + if (this.downloadCourseEnabled) { + // Listen for changes in course status. + this.courseStatusObserver = CoreEvents.on(CoreEvents.COURSE_STATUS_CHANGED, (data) => { + if (data.courseId == this.course.id || data.courseId == CoreCourseProvider.ALL_COURSES_CLEARED) { + this.updateCourseStatus(data.status); + } + }, CoreSites.instance.getCurrentSiteId()); + } + + // Check if the course format requires the view to be refreshed when completion changes. + const shouldRefresh = await CoreCourseFormatDelegate.instance.shouldRefreshWhenCompletionChanges(this.course); + if (!shouldRefresh) { + return; + } + + this.completionObserver = CoreEvents.on( + CoreEvents.COMPLETION_MODULE_VIEWED, + (data) => { + if (data && data.courseId == this.course.id) { + this.refreshAfterCompletionChange(true); + } + }, + ); + + this.syncObserver = CoreEvents.on(CoreCourseSyncProvider.AUTO_SYNCED, (data) => { + if (!data || data.courseId != this.course.id) { + return; + } + + this.refreshAfterCompletionChange(false); + + if (data.warnings && data.warnings[0]) { + CoreDomUtils.instance.showErrorModal(data.warnings[0]); + } + }); + } + + /** + * Init prefetch data if needed. + * + * @return Promise resolved when done. + */ + protected async initPrefetch(): Promise { + if (!this.downloadCourseEnabled) { + // Cannot download the whole course, stop. + return; + } + + // Determine the course prefetch status. + await this.determineCoursePrefetchIcon(); + + if (this.prefetchCourseData.icon != 'spinner') { + return; + } + + // Course is being downloaded. Get the download promise. + const promise = CoreCourseHelper.instance.getCourseDownloadPromise(this.course.id); + if (promise) { + // There is a download promise. Show an error if it fails. + promise.catch((error) => { + if (!this.isDestroyed) { + CoreDomUtils.instance.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.instance.setCoursePreviousStatus(this.course.id); + + this.updateCourseStatus(status); + } + } + + /** + * Fetch and load all the data required for the view. + * + * @param refresh If it's refreshing content. + * @param sync If it should try to sync. + * @return Promise resolved when done. + */ + protected async loadData(refresh?: boolean, sync?: boolean): Promise { + // First of all, get the course because the data might have changed. + const result = await CoreUtils.instance.ignoreErrors(CoreCourseHelper.instance.getCourse(this.course.id)); + + if (result) { + if (this.course.id === result.course.id && 'displayname' in this.course && !('displayname' in result.course)) { + result.course.displayname = this.course.displayname; + } + this.course = result.course; + } + + if (sync) { + // Try to synchronize the course data. + // For now we don't allow manual syncing, so ignore errors. + const result = await CoreUtils.instance.ignoreErrors(CoreCourseSync.instance.syncCourse(this.course.id)); + if (result?.warnings?.length) { + CoreDomUtils.instance.showErrorModal(result.warnings[0]); + } + } + + try { + await Promise.all([ + this.loadSections(refresh), + this.loadMenuHandlers(refresh), + this.loadCourseFormatOptions(), + ]); + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.couldnotloadsectioncontent', true); + } + } + + /** + * Load course sections. + * + * @param refresh If it's refreshing content. + * @return Promise resolved when done. + */ + protected async loadSections(refresh?: boolean): Promise { + // Get all the sections. + const sections = await CoreCourse.instance.getSections(this.course.id, false, true); + + if (refresh) { + // Invalidate the recently downloaded module list. To ensure info can be prefetched. + const modules = CoreCourse.instance.getSectionsModules(sections); + + await CoreCourseModulePrefetchDelegate.instance.invalidateModules(modules, this.course.id); + } + + let completionStatus: Record = {}; + + // Get the completion status. + if (this.course.enablecompletion !== false) { + const sectionWithModules = sections.find((section) => section.modules.length > 0); + + if (sectionWithModules && typeof sectionWithModules.modules[0].completion != 'undefined') { + // The module already has completion (3.6 onwards). Load the offline completion. + await CoreUtils.instance.ignoreErrors(CoreCourseHelper.instance.loadOfflineCompletion(this.course.id, sections)); + } else { + const fetchedData = await CoreUtils.instance.ignoreErrors( + CoreCourse.instance.getActivitiesCompletionStatus(this.course.id), + ); + + completionStatus = fetchedData || completionStatus; + } + } + + // Add handlers + const result = CoreCourseHelper.instance.addHandlerDataForModules( + sections, + this.course.id, + completionStatus, + this.course.fullname, + true, + ); + this.sections = result.sections; + + if (CoreCourseFormatDelegate.instance.canViewAllSections(this.course)) { + // Add a fake first section (all sections). + this.sections.unshift(CoreCourseHelper.instance.createAllSectionsSection()); + } + + // Get whether to show the refresher now that we have sections. + this.displayRefresher = CoreCourseFormatDelegate.instance.displayRefresher(this.course, this.sections); + } + + /** + * Load the course menu handlers. + * + * @param refresh If it's refreshing content. + * @return Promise resolved when done. + */ + protected async loadMenuHandlers(refresh?: boolean): Promise { + this.courseMenuHandlers = await CoreCourseOptionsDelegate.instance.getMenuHandlersToDisplay(this.course, refresh); + } + + /** + * Load course format options if needed. + * + * @return Promise resolved when done. + */ + protected async loadCourseFormatOptions(): Promise { + + // Load the course format options when course completion is enabled to show completion progress on sections. + if (!this.course.enablecompletion || !CoreCourses.instance.isGetCoursesByFieldAvailable()) { + return; + } + + if ('courseformatoptions' in this.course && this.course.courseformatoptions) { + // Already loaded. + this.formatOptions = CoreUtils.instance.objectToKeyValueMap(this.course.courseformatoptions, 'name', 'value'); + + return; + } + + const course = await CoreUtils.instance.ignoreErrors(CoreCourses.instance.getCourseByField('id', this.course.id)); + + course && Object.assign(this.course, course); + + if (course?.courseformatoptions) { + this.formatOptions = CoreUtils.instance.objectToKeyValueMap(course.courseformatoptions, 'name', 'value'); + } + } + + /** + * Refresh the data. + * + * @param refresher Refresher. + * @return Promise resolved when done. + */ + async doRefresh(refresher?: CustomEvent): Promise { + await CoreUtils.instance.ignoreErrors(this.invalidateData()); + + try { + await this.loadData(true, true); + } finally { + // Do not call doRefresh on the format component if the refresher is defined in the format component + // to prevent an inifinite loop. + if (this.displayRefresher && this.formatComponent) { + await CoreUtils.instance.ignoreErrors(this.formatComponent.doRefresh(refresher)); + } + + refresher?.detail.complete(); + } + } + + /** + * The completion of any of the modules has changed. + * + * @param completionData Completion data. + * @return Promise resolved when done. + */ + async onCompletionChange(completionData: CoreCourseModuleCompletionData): Promise { + const shouldReload = typeof completionData.valueused == 'undefined' || completionData.valueused; + + if (!shouldReload) { + return; + } + + await CoreUtils.instance.ignoreErrors(this.invalidateData()); + + await this.refreshAfterCompletionChange(true); + } + + /** + * Invalidate the data. + * + * @return Promise resolved when done. + */ + protected async invalidateData(): Promise { + const promises: Promise[] = []; + + promises.push(CoreCourse.instance.invalidateSections(this.course.id)); + promises.push(CoreCourses.instance.invalidateUserCourses()); + promises.push(CoreCourseFormatDelegate.instance.invalidateData(this.course, this.sections || [])); + + if (this.sections) { + promises.push(CoreCourseModulePrefetchDelegate.instance.invalidateCourseUpdates(this.course.id)); + } + + await Promise.all(promises); + } + + /** + * Refresh list after a completion change since there could be new activities. + * + * @param sync If it should try to sync. + * @return Promise resolved when done. + */ + protected async refreshAfterCompletionChange(sync?: boolean): Promise { + // Save scroll position to restore it once done. + const scrollElement = await this.content?.getScrollElement(); + const scrollTop = scrollElement?.scrollTop || 0; + const scrollLeft = scrollElement?.scrollLeft || 0; + + this.dataLoaded = false; + this.content?.scrollToTop(0); // Scroll top so the spinner is seen. + + try { + await this.loadData(true, sync); + + await this.formatComponent?.doRefresh(undefined, undefined, true); + } finally { + this.dataLoaded = true; + + // Wait for new content height to be calculated and scroll without animation. + setTimeout(() => { + this.content?.scrollToPoint(scrollLeft, scrollTop, 0); + }); + } + } + + /** + * Determines the prefetch icon of the course. + * + * @return Promise resolved when done. + */ + protected async determineCoursePrefetchIcon(): Promise { + this.prefetchCourseData = await CoreCourseHelper.instance.getCourseStatusIconAndTitle(this.course.id); + } + + /** + * Prefetch the whole course. + */ + async prefetchCourse(): Promise { + try { + await CoreCourseHelper.instance.confirmAndPrefetchCourse( + this.prefetchCourseData, + this.course, + this.sections, + undefined, + this.courseMenuHandlers, + ); + } catch (error) { + if (this.isDestroyed) { + return; + } + + CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.errordownloadingcourse', true); + } + } + + /** + * Toggle download enabled. + */ + toggleDownload(): void { + this.downloadEnabled = !this.downloadEnabled; + this.downloadEnabledIcon = this.downloadEnabled ? 'far-check-square' : 'far-square'; + } + + /** + * Update the course status icon and title. + * + * @param status Status to show. + */ + protected updateCourseStatus(status: string): void { + this.prefetchCourseData = CoreCourseHelper.instance.getCourseStatusIconAndTitleFromStatus(status); + } + + /** + * Open the course summary + */ + openCourseSummary(): void { + CoreNavigator.instance.navigateToSitePath('/courses/preview', { params: { course: this.course, avoidOpenCourse: true } }); + } + + /** + * Opens a menu item registered to the delegate. + * + * @param item Item to open + */ + openMenuItem(item: CoreCourseOptionsMenuHandlerToDisplay): void { + const params = Object.assign({ course: this.course }, item.data.pageParams); + CoreNavigator.instance.navigateToSitePath(item.data.page, { params }); + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.isDestroyed = true; + this.completionObserver?.off(); + this.courseStatusObserver?.off(); + this.syncObserver?.off(); + } + + /** + * User entered the page. + */ + ionViewDidEnter(): void { + this.formatComponent?.ionViewDidEnter(); + } + + /** + * User left the page. + */ + ionViewDidLeave(): void { + this.formatComponent?.ionViewDidLeave(); + } + +} diff --git a/src/core/features/course/pages/index/index-routing.module.ts b/src/core/features/course/pages/index/index-routing.module.ts new file mode 100644 index 000000000..993f6f662 --- /dev/null +++ b/src/core/features/course/pages/index/index-routing.module.ts @@ -0,0 +1,33 @@ +// (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 { InjectionToken, ModuleWithProviders, NgModule } from '@angular/core'; + +import { ModuleRoutesConfig } from '@/app/app-routing.module'; + +export const COURSE_INDEX_ROUTES = new InjectionToken('COURSE_INDEX_ROUTES'); + +@NgModule() +export class CoreCourseIndexRoutingModule { + + static forChild(routes: ModuleRoutesConfig): ModuleWithProviders { + return { + ngModule: CoreCourseIndexRoutingModule, + providers: [ + { provide: COURSE_INDEX_ROUTES, multi: true, useValue: routes }, + ], + }; + } + +} diff --git a/src/core/features/course/pages/index/index.html b/src/core/features/course/pages/index/index.html new file mode 100644 index 000000000..221b8b63d --- /dev/null +++ b/src/core/features/course/pages/index/index.html @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/core/features/course/pages/index/index.module.ts b/src/core/features/course/pages/index/index.module.ts new file mode 100644 index 000000000..b21a450c2 --- /dev/null +++ b/src/core/features/course/pages/index/index.module.ts @@ -0,0 +1,54 @@ +// (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 { Injector, NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule, ROUTES, Routes } from '@angular/router'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { resolveModuleRoutes } from '@/app/app-routing.module'; +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreCourseIndexPage } from './index'; +import { COURSE_INDEX_ROUTES } from './index-routing.module'; + +function buildRoutes(injector: Injector): Routes { + const routes = resolveModuleRoutes(injector, COURSE_INDEX_ROUTES); + + return [ + { + path: '', + component: CoreCourseIndexPage, + children: routes.children, + }, + ...routes.siblings, + ]; +} + +@NgModule({ + providers: [ + { provide: ROUTES, multi: true, useFactory: buildRoutes, deps: [Injector] }, + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreSharedModule, + ], + declarations: [ + CoreCourseIndexPage, + ], + exports: [RouterModule], +}) +export class CoreCourseIndexPageModule {} diff --git a/src/core/features/course/pages/index/index.ts b/src/core/features/course/pages/index/index.ts new file mode 100644 index 000000000..27f0a6b42 --- /dev/null +++ b/src/core/features/course/pages/index/index.ts @@ -0,0 +1,188 @@ +// (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 { Component, ViewChild, OnDestroy, OnInit } from '@angular/core'; +import { Params } from '@angular/router'; + +import { CoreTab, CoreTabsComponent } from '@components/tabs/tabs'; +import { CoreCourseFormatDelegate } from '../../services/format-delegate'; +import { CoreCourseOptionsDelegate } from '../../services/course-options-delegate'; +import { CoreCourseAnyCourseData } from '@features/courses/services/courses'; +import { CoreEventObserver, CoreEvents, CoreEventSelectCourseTabData } from '@singletons/events'; +import { CoreCourse, CoreCourseWSModule } from '@features/course/services/course'; +import { CoreCourseHelper } from '@features/course/services/course-helper'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreNavigator } from '@services/navigator'; + +/** + * Page that displays the list of courses the user is enrolled in. + */ +@Component({ + selector: 'page-core-course-index', + templateUrl: 'index.html', +}) +export class CoreCourseIndexPage implements OnInit, OnDestroy { + + @ViewChild(CoreTabsComponent) tabsComponent?: CoreTabsComponent; + + title?: string; + course?: CoreCourseAnyCourseData; + tabs: CourseTab[] = []; + loaded = false; + + protected currentPagePath = ''; + protected selectTabObserver: CoreEventObserver; + protected firstTabName?: string; + protected contentsTab: CoreTab = { + page: 'contents', + title: 'core.course.contents', + pageParams: {}, + }; + + constructor() { + this.selectTabObserver = CoreEvents.on(CoreEvents.SELECT_COURSE_TAB, (data) => { + if (!data.name) { + // If needed, set sectionId and sectionNumber. They'll only be used if the content tabs hasn't been loaded yet. + if (data.sectionId) { + this.contentsTab.pageParams!.sectionId = data.sectionId; + } + if (data.sectionNumber) { + this.contentsTab.pageParams!.sectionNumber = data.sectionNumber; + } + + // Select course contents. + this.tabsComponent?.selectByIndex(0); + } else if (this.tabs) { + const index = this.tabs.findIndex((tab) => tab.name == data.name); + + if (index >= 0) { + this.tabsComponent?.selectByIndex(index + 1); + } + } + }); + } + + /** + * Component being initialized. + */ + async ngOnInit(): Promise { + // Get params. + this.course = CoreNavigator.instance.getRouteParam('course'); + this.firstTabName = CoreNavigator.instance.getRouteParam('selectedTab'); + const module = CoreNavigator.instance.getRouteParam('module'); + const modParams = CoreNavigator.instance.getRouteParam('modParams'); + + this.currentPagePath = CoreNavigator.instance.getCurrentPath(); + this.contentsTab.page = CoreTextUtils.instance.concatenatePaths(this.currentPagePath, this.contentsTab.page); + this.contentsTab.pageParams = { + course: this.course, + sectionId: CoreNavigator.instance.getRouteNumberParam('sectionId'), + sectionNumber: CoreNavigator.instance.getRouteNumberParam('sectionNumber'), + }; + + if (module) { + this.contentsTab.pageParams!.moduleId = module.id; + CoreCourseHelper.instance.openModule(module, this.course!.id, this.contentsTab.pageParams!.sectionId, modParams); + } + + this.tabs.push(this.contentsTab); + this.loaded = true; + + await Promise.all([ + this.loadCourseHandlers(), + this.loadTitle(), + ]); + } + + /** + * Load course option handlers. + * + * @return Promise resolved when done. + */ + protected async loadCourseHandlers(): Promise { + // Load the course handlers. + const handlers = await CoreCourseOptionsDelegate.instance.getHandlersToDisplay(this.course!, false, false); + + this.tabs.concat(handlers.map(handler => handler.data)); + + let tabToLoad: number | undefined; + + // Add the courseId to the handler component data. + handlers.forEach((handler, index) => { + handler.data.page = CoreTextUtils.instance.concatenatePaths(this.currentPagePath, handler.data.page); + handler.data.pageParams = handler.data.pageParams || {}; + handler.data.pageParams.courseId = this.course!.id; + + // Check if this handler should be the first selected tab. + if (this.firstTabName && handler.name == this.firstTabName) { + tabToLoad = index + 1; + } + }); + + // Select the tab if needed. + this.firstTabName = undefined; + if (tabToLoad) { + setTimeout(() => { + this.tabsComponent?.selectByIndex(tabToLoad!); + }); + } + } + + /** + * Load title for the page. + * + * @return Promise resolved when done. + */ + protected async loadTitle(): Promise { + // Get the title to display initially. + this.title = CoreCourseFormatDelegate.instance.getCourseTitle(this.course!); + + // Load sections. + const sections = await CoreUtils.instance.ignoreErrors(CoreCourse.instance.getSections(this.course!.id, false, true)); + + if (!sections) { + return; + } + + // Get the title again now that we have sections. + this.title = CoreCourseFormatDelegate.instance.getCourseTitle(this.course!, sections); + } + + /** + * Page destroyed. + */ + ngOnDestroy(): void { + this.selectTabObserver?.off(); + } + + /** + * User entered the page. + */ + ionViewDidEnter(): void { + this.tabsComponent?.ionViewDidEnter(); + } + + /** + * User left the page. + */ + ionViewDidLeave(): void { + this.tabsComponent?.ionViewDidLeave(); + } + +} + +type CourseTab = CoreTab & { + name?: string; +}; diff --git a/src/core/features/course/pages/list-mod-type/list-mod-type.html b/src/core/features/course/pages/list-mod-type/list-mod-type.html new file mode 100644 index 000000000..7c2ebffed --- /dev/null +++ b/src/core/features/course/pages/list-mod-type/list-mod-type.html @@ -0,0 +1,28 @@ + + + + + + {{ title }} + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/core/features/course/pages/list-mod-type/list-mod-type.module.ts b/src/core/features/course/pages/list-mod-type/list-mod-type.module.ts new file mode 100644 index 000000000..8efce4a18 --- /dev/null +++ b/src/core/features/course/pages/list-mod-type/list-mod-type.module.ts @@ -0,0 +1,46 @@ +// (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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule, Routes } from '@angular/router'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreCourseListModTypePage } from './list-mod-type'; +import { CoreCourseComponentsModule } from '@features/course/components/components.module'; + +const routes: Routes = [ + { + path: '', + component: CoreCourseListModTypePage, + }, +]; + +@NgModule({ + declarations: [ + CoreCourseListModTypePage, + ], + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreSharedModule, + CoreCourseComponentsModule, + ], + exports: [RouterModule], +}) +export class CoreCourseListModTypePageModule {} diff --git a/src/core/features/course/pages/list-mod-type/list-mod-type.ts b/src/core/features/course/pages/list-mod-type/list-mod-type.ts new file mode 100644 index 000000000..319b75b38 --- /dev/null +++ b/src/core/features/course/pages/list-mod-type/list-mod-type.ts @@ -0,0 +1,132 @@ +// (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 { Component, OnInit } from '@angular/core'; + +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate'; +import { CoreCourseHelper, CoreCourseSection } from '@features/course/services/course-helper'; +import { CoreNavigator } from '@services/navigator'; +import { CoreConstants } from '@/core/constants'; +import { IonRefresher } from '@ionic/angular'; +import { CoreUtils } from '@services/utils/utils'; + +/** + * Page that displays all modules of a certain type in a course. + */ +@Component({ + selector: 'page-core-course-list-mod-type', + templateUrl: 'list-mod-type.html', +}) +export class CoreCourseListModTypePage implements OnInit { + + sections: CoreCourseSection[] = []; + title = ''; + loaded = false; + downloadEnabled = false; + courseId?: number; + + protected modName?: string; + protected archetypes: Record = {}; // To speed up the check of modules. + + /** + * @inheritdoc + */ + async ngOnInit(): Promise { + this.title = CoreNavigator.instance.getRouteParam('title') || ''; + this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId'); + this.modName = CoreNavigator.instance.getRouteParam('modName'); + this.downloadEnabled = !CoreSites.instance.getCurrentSite()?.isOfflineDisabled(); + + try { + await this.fetchData(); + } finally { + this.loaded = true; + } + } + + /** + * Fetches the data. + * + * @return Resolved when done. + */ + protected async fetchData(): Promise { + if (!this.courseId) { + return; + } + + try { + // Get all the modules in the course. + let sections = await CoreCourse.instance.getSections(this.courseId, false, true); + + sections = sections.filter((section) => { + if (!section.modules) { + return false; + } + + section.modules = section.modules.filter((mod) => { + if (mod.uservisible === false || !CoreCourse.instance.moduleHasView(mod)) { + // Ignore this module. + return false; + } + + if (this.modName === 'resources') { + // Check that the module is a resource. + if (typeof this.archetypes[mod.modname] == 'undefined') { + this.archetypes[mod.modname] = CoreCourseModuleDelegate.instance.supportsFeature( + mod.modname, + CoreConstants.FEATURE_MOD_ARCHETYPE, + CoreConstants.MOD_ARCHETYPE_OTHER, + ); + } + + if (this.archetypes[mod.modname] == CoreConstants.MOD_ARCHETYPE_RESOURCE) { + return true; + } + + } else if (mod.modname == this.modName) { + return true; + } + }); + + return section.modules.length > 0; + }); + + const result = CoreCourseHelper.instance.addHandlerDataForModules(sections, this.courseId); + + this.sections = result.sections; + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'Error getting data'); + } + } + + /** + * Refresh the data. + * + * @param refresher Refresher. + * @return Promise resolved when done. + */ + async refreshData(refresher: CustomEvent): Promise { + await CoreUtils.instance.ignoreErrors(CoreCourse.instance.invalidateSections(this.courseId || 0)); + + try { + await this.fetchData(); + } finally { + refresher.detail.complete(); + } + } + +} diff --git a/src/core/features/course/pages/unsupported-module/unsupported-module.html b/src/core/features/course/pages/unsupported-module/unsupported-module.html new file mode 100644 index 000000000..976173620 --- /dev/null +++ b/src/core/features/course/pages/unsupported-module/unsupported-module.html @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/core/features/course/pages/unsupported-module/unsupported-module.module.ts b/src/core/features/course/pages/unsupported-module/unsupported-module.module.ts new file mode 100644 index 000000000..6a5c9cfdd --- /dev/null +++ b/src/core/features/course/pages/unsupported-module/unsupported-module.module.ts @@ -0,0 +1,46 @@ +// (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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { RouterModule, Routes } from '@angular/router'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { CoreCourseUnsupportedModulePage } from './unsupported-module'; +import { CoreCourseComponentsModule } from '@features/course/components/components.module'; + +const routes: Routes = [ + { + path: '', + component: CoreCourseUnsupportedModulePage, + }, +]; + +@NgModule({ + imports: [ + RouterModule.forChild(routes), + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreSharedModule, + CoreCourseComponentsModule, + ], + declarations: [ + CoreCourseUnsupportedModulePage, + ], + exports: [RouterModule], +}) +export class CoreCourseUnsupportedModulePageModule {} diff --git a/src/core/features/course/pages/unsupported-module/unsupported-module.ts b/src/core/features/course/pages/unsupported-module/unsupported-module.ts new file mode 100644 index 000000000..92c489dc4 --- /dev/null +++ b/src/core/features/course/pages/unsupported-module/unsupported-module.ts @@ -0,0 +1,54 @@ +// (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 { Component, OnInit } from '@angular/core'; + +import { CoreCourseWSModule } from '@features/course/services/course'; +import { CoreNavigator } from '@services/navigator'; +import { CoreTextUtils } from '@services/utils/text'; +import { Translate } from '@singletons'; + +/** + * Page that displays info about an unsupported module. + */ +@Component({ + selector: 'page-core-course-unsupported-module', + templateUrl: 'unsupported-module.html', +}) +export class CoreCourseUnsupportedModulePage implements OnInit { + + module?: CoreCourseWSModule; + courseId?: number; + + /** + * @inheritDoc + */ + ngOnInit(): void { + this.module = CoreNavigator.instance.getRouteParam('module'); + this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId'); + } + + /** + * Expand the description. + */ + expandDescription(): void { + CoreTextUtils.instance.viewText(Translate.instance.instant('core.description'), this.module!.description!, { + filter: true, + contextLevel: 'module', + instanceId: this.module!.id, + courseId: this.courseId, + }); + } + +} diff --git a/src/core/features/course/services/course-helper.ts b/src/core/features/course/services/course-helper.ts index 01dbf096a..417ea149e 100644 --- a/src/core/features/course/services/course-helper.ts +++ b/src/core/features/course/services/course-helper.ts @@ -14,21 +14,28 @@ import { Injectable } from '@angular/core'; import { Params } from '@angular/router'; +import moment from 'moment'; + import { CoreSites } from '@services/sites'; -import { CoreCourse, CoreCourseSection } from './course'; +import { + CoreCourse, + CoreCourseCompletionActivityStatus, + CoreCourseModuleWSCompletionData, + CoreCourseModuleContentFile, + CoreCourseWSModule, + CoreCourseProvider, + CoreCourseWSSection, +} from './course'; import { CoreConstants } from '@/core/constants'; import { CoreLogger } from '@singletons/logger'; import { makeSingleton, Translate } from '@singletons'; -import { CoreFilepool } from '@services/filepool'; +import { CoreFilepool, CoreFilepoolComponentFileEventData } from '@services/filepool'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreUtils } from '@services/utils/utils'; import { + CoreCourseAnyCourseData, CoreCourseBasicData, - CoreCourseGetCoursesData, CoreCourses, - CoreCourseSearchedData, - CoreEnrolledCourseBasicData, - CoreEnrolledCourseData, } from '@features/courses/services/courses'; import { CoreEnrolledCourseDataWithExtraInfoAndOptions } from '@features/courses/services/courses-helper'; import { CoreArray } from '@singletons/array'; @@ -39,6 +46,24 @@ import { CoreCourseOptionsHandlerToDisplay, CoreCourseOptionsMenuHandlerToDisplay, } from './course-options-delegate'; +import { CoreCourseModuleDelegate, CoreCourseModuleHandlerData } from './module-delegate'; +import { CoreError } from '@classes/errors/error'; +import { + CoreCourseModulePrefetchDelegate, + CoreCourseModulePrefetchHandler, + CoreCourseModulesStatus, +} from './module-prefetch-delegate'; +import { CoreFileSizeSum } from '@services/plugin-file-delegate'; +import { CoreFileHelper } from '@services/file-helper'; +import { CoreApp } from '@services/app'; +import { CoreSite } from '@classes/site'; +import { CoreFile } from '@services/file'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreEventObserver, CoreEventPackageStatusChanged, CoreEvents } from '@singletons/events'; +import { CoreFilterHelper } from '@features/filter/services/filter-helper'; +import { CoreNetworkError } from '@classes/errors/network-error'; /** * Prefetch info of a module. @@ -106,6 +131,7 @@ export type CorePrefetchStatusInfo = { icon: string; // Icon based on the status. loading: boolean; // If it's a loading status. badge?: string; // Progress badge string if any. + downloadSucceeded?: boolean; // Whether download has succeeded (in case it's downloaded). }; /** @@ -118,7 +144,6 @@ export class CoreCourseHelperProvider { protected logger: CoreLogger; constructor() { - this.logger = CoreLogger.getInstance('CoreCourseHelperProvider'); } @@ -134,51 +159,65 @@ export class CoreCourseHelperProvider { * @return Whether the sections have content. */ addHandlerDataForModules( - sections: CoreCourseSection[], + sections: CoreCourseWSSection[], courseId: number, - completionStatus?: any, // eslint-disable-line @typescript-eslint/no-unused-vars - courseName?: string, // eslint-disable-line @typescript-eslint/no-unused-vars - forCoursePage = false, // eslint-disable-line @typescript-eslint/no-unused-vars - ): boolean { + completionStatus?: Record, + courseName?: string, + forCoursePage = false, + ): { hasContent: boolean; sections: CoreCourseSection[] } { + const formattedSections: CoreCourseSection[] = sections; let hasContent = false; - sections.forEach((section) => { - if (!section || !this.sectionHasContent(section) || !section.modules) { + formattedSections.forEach((section) => { + if (!section || !section.modules) { + return; + } + + section.hasContent = this.sectionHasContent(section); + + if (!section.hasContent) { return; } hasContent = true; - /* @todo section.modules.forEach((module) => { - module.handlerData = this.moduleDelegate.getModuleDataFor(module.modname, module, courseId, section.id, - forCoursePage); + module.handlerData = CoreCourseModuleDelegate.instance.getModuleDataFor( + module.modname, + module, + courseId, + section.id, + forCoursePage, + ); - if (module.completiondata && module.completion > 0) { + if (module.completiondata && module.completion && module.completion > 0) { module.completiondata.courseId = courseId; module.completiondata.courseName = courseName; module.completiondata.tracking = module.completion; module.completiondata.cmid = module.id; - - // Use of completionstatus is deprecated, use completiondata instead. - module.completionstatus = module.completiondata; } else if (completionStatus && typeof completionStatus[module.id] != 'undefined') { // Should not happen on > 3.6. Check if activity has completions and if it's marked. - module.completiondata = completionStatus[module.id]; - module.completiondata.courseId = courseId; - module.completiondata.courseName = courseName; + const activityStatus = completionStatus[module.id]; - // Use of completionstatus is deprecated, use completiondata instead. - module.completionstatus = module.completiondata; + module.completiondata = { + state: activityStatus.state, + timecompleted: activityStatus.timecompleted, + overrideby: activityStatus.overrideby || 0, + valueused: activityStatus.valueused, + tracking: activityStatus.tracking, + courseId, + courseName, + cmid: module.id, + }; } // Check if the module is stealth. - module.isStealth = module.visibleoncoursepage === 0 || (module.visible && !section.visible); - });*/ + module.isStealth = module.visibleoncoursepage === 0 || (!!module.visible && !section.visible); + }); }); - return hasContent; + return { hasContent, sections: formattedSections }; } /** @@ -190,8 +229,51 @@ export class CoreCourseHelperProvider { * @param checkUpdates Whether to use the WS to check updates. Defaults to true. * @return Promise resolved when the status is calculated. */ - calculateSectionStatus(): void { - // @todo params and logic + async calculateSectionStatus( + section: CoreCourseSection, + courseId: number, + refresh?: boolean, + checkUpdates: boolean = true, + ): Promise<{statusData: CoreCourseModulesStatus; section: CoreCourseSectionWithStatus}> { + if (section.id == CoreCourseProvider.ALL_SECTIONS_ID) { + throw new CoreError('Invalid section'); + } + + const sectionWithStatus = section; + + // Get the status of this section. + const result = await CoreCourseModulePrefetchDelegate.instance.getModulesStatus( + section.modules, + courseId, + section.id, + refresh, + true, + checkUpdates, + ); + + // Check if it's being downloaded. + const downloadId = this.getSectionDownloadId(section); + if (CoreCourseModulePrefetchDelegate.instance.isBeingDownloaded(downloadId)) { + result.status = CoreConstants.DOWNLOADING; + } + + sectionWithStatus.downloadStatus = result.status; + sectionWithStatus.canCheckUpdates = CoreCourseModulePrefetchDelegate.instance.canCheckUpdates(); + + // Set this section data. + if (result.status !== CoreConstants.DOWNLOADING) { + sectionWithStatus.isDownloading = false; + sectionWithStatus.total = 0; + } else { + // Section is being downloaded. + sectionWithStatus.isDownloading = true; + CoreCourseModulePrefetchDelegate.instance.setOnProgress(downloadId, (data) => { + sectionWithStatus.count = data.count; + sectionWithStatus.total = data.total; + }); + } + + return { statusData: result, section: sectionWithStatus }; } /** @@ -203,8 +285,51 @@ export class CoreCourseHelperProvider { * @param checkUpdates Whether to use the WS to check updates. Defaults to true. * @return Promise resolved when the states are calculated. */ - calculateSectionsStatus(): void { - // @todo params and logic + async calculateSectionsStatus( + sections: CoreCourseSection[], + courseId: number, + refresh?: boolean, + checkUpdates: boolean = true, + ): Promise { + let allSectionsSection: CoreCourseSectionWithStatus | undefined; + let allSectionsStatus = CoreConstants.NOT_DOWNLOADABLE; + + const promises = sections.map(async (section: CoreCourseSectionWithStatus) => { + section.isCalculating = true; + + if (section.id === CoreCourseProvider.ALL_SECTIONS_ID) { + // "All sections" section status is calculated using the status of the rest of sections. + allSectionsSection = section; + + return; + } + + try { + const result = await this.calculateSectionStatus(section, courseId, refresh, checkUpdates); + + // Calculate "All sections" status. + allSectionsStatus = CoreFilepool.instance.determinePackagesStatus(allSectionsStatus, result.statusData.status); + } finally { + section.isCalculating = false; + } + }); + + try { + await Promise.all(promises); + + if (allSectionsSection) { + // Set "All sections" data. + allSectionsSection.downloadStatus = allSectionsStatus; + allSectionsSection.canCheckUpdates = CoreCourseModulePrefetchDelegate.instance.canCheckUpdates(); + allSectionsSection.isDownloading = allSectionsStatus === CoreConstants.DOWNLOADING; + } + + return sections; + } finally { + if (allSectionsSection) { + allSectionsSection.isCalculating = false; + } + } } /** @@ -219,8 +344,50 @@ export class CoreCourseHelperProvider { * @param menuHandlers List of course menu handlers. * @return Promise resolved when the download finishes, rejected if an error occurs or the user cancels. */ - confirmAndPrefetchCourse(): void { - // @todo params and logic + async confirmAndPrefetchCourse( + data: CorePrefetchStatusInfo, + course: CoreCourseAnyCourseData, + sections?: CoreCourseWSSection[], + courseHandlers?: CoreCourseOptionsHandlerToDisplay[], + menuHandlers?: CoreCourseOptionsMenuHandlerToDisplay[], + ): Promise { + const initialIcon = data.icon; + const initialStatus = data.statusTranslatable; + const siteId = CoreSites.instance.getCurrentSiteId(); + + data.downloadSucceeded = false; + data.icon = 'spinner'; + data.statusTranslatable = 'core.downloading'; + + // Get the sections first if needed. + if (!sections) { + sections = await CoreCourse.instance.getSections(course.id, false, true); + } + + try { + // Confirm the download. + await this.confirmDownloadSizeSection(course.id, undefined, sections, true); + } catch (error) { + // User cancelled or there was an error calculating the size. + data.icon = initialIcon; + data.statusTranslatable = initialStatus; + + throw error; + } + + // User confirmed, get the course handlers if needed. + if (!courseHandlers) { + courseHandlers = await CoreCourseOptionsDelegate.instance.getHandlersToDisplay(course); + } + if (!menuHandlers) { + menuHandlers = await CoreCourseOptionsDelegate.instance.getMenuHandlersToDisplay(course); + } + + // Now we have all the data, download the course. + await this.prefetchCourse(course, sections, courseHandlers, menuHandlers, siteId); + + // Download successful. + data.downloadSucceeded = true; } /** @@ -244,7 +411,7 @@ export class CoreCourseHelperProvider { const promises = courses.map((course) => { const subPromises: Promise[] = []; - let sections: CoreCourseSection[]; + let sections: CoreCourseWSSection[]; let handlers: CoreCourseOptionsHandlerToDisplay[] = []; let menuHandlers: CoreCourseOptionsMenuHandlerToDisplay[] = []; let success = true; @@ -296,9 +463,8 @@ export class CoreCourseHelperProvider { * @param courseId Course ID the module belongs to. * @param done Function to call when done. It will close the context menu. * @return Promise resolved when done. - * @todo module type. */ - async confirmAndRemoveFiles(module: any, courseId: number, done?: () => void): Promise { + async confirmAndRemoveFiles(module: CoreCourseWSModule, courseId: number, done?: () => void): Promise { let modal: CoreIonLoadingElement | undefined; try { @@ -329,22 +495,106 @@ export class CoreCourseHelperProvider { * @param alwaysConfirm True to show a confirm even if the size isn't high, false otherwise. * @return Promise resolved if the user confirms or there's no need to confirm. */ - confirmDownloadSizeSection(): void { - // @todo params and logic + async confirmDownloadSizeSection( + courseId: number, + section?: CoreCourseWSSection, + sections?: CoreCourseWSSection[], + alwaysConfirm?: boolean, + ): Promise { + let hasEmbeddedFiles = false; + let sizeSum: CoreFileSizeSum = { + size: 0, + total: true, + }; + + if (!section && !sections) { + throw new CoreError('Either section or list of sections needs to be supplied.'); + } + + // Calculate the size of the download. + if (section && section.id != CoreCourseProvider.ALL_SECTIONS_ID) { + sizeSum = await CoreCourseModulePrefetchDelegate.instance.getDownloadSize(section.modules, courseId); + + // Check if the section has embedded files in the description. + hasEmbeddedFiles = CoreFilepool.instance.extractDownloadableFilesFromHtml(section.summary).length > 0; + } else { + await Promise.all(sections!.map(async (section) => { + if (section.id == CoreCourseProvider.ALL_SECTIONS_ID) { + return; + } + + const sectionSize = await CoreCourseModulePrefetchDelegate.instance.getDownloadSize(section.modules, courseId); + + sizeSum.total = sizeSum.total && sectionSize.total; + sizeSum.size += sectionSize.size; + + // Check if the section has embedded files in the description. + if (!hasEmbeddedFiles && CoreFilepool.instance.extractDownloadableFilesFromHtml(section.summary).length > 0) { + hasEmbeddedFiles = true; + } + })); + } + + if (hasEmbeddedFiles) { + sizeSum.total = false; + } + + // Show confirm modal if needed. + await CoreDomUtils.instance.confirmDownloadSize(sizeSum, undefined, undefined, undefined, undefined, alwaysConfirm); } /** * Helper function to prefetch a module, showing a confirmation modal if the size is big. * This function is meant to be called from a context menu option. It will also modify some data like the prefetch icon. * - * @param instance The component instance that has the context menu. It should have prefetchStatusIcon and isDestroyed. + * @param instance The component instance that has the context menu. * @param module Module to be prefetched * @param courseId Course ID the module belongs to. * @param done Function to call when done. It will close the context menu. * @return Promise resolved when done. */ - contextMenuPrefetch(): void { - // @todo params and logic + async contextMenuPrefetch( + instance: ComponentWithContextMenu, + module: CoreCourseWSModule, + courseId: number, + done?: () => void, + ): Promise { + const initialIcon = instance.prefetchStatusIcon; + instance.prefetchStatusIcon = 'spinner'; // Show spinner since this operation might take a while. + + try { + // We need to call getDownloadSize, the package might have been updated. + const size = await CoreCourseModulePrefetchDelegate.instance.getModuleDownloadSize(module, courseId, true); + + await CoreDomUtils.instance.confirmDownloadSize(size); + + await CoreCourseModulePrefetchDelegate.instance.prefetchModule(module, courseId, true); + + // Success, close menu. + done && done(); + } catch (error) { + instance.prefetchStatusIcon = initialIcon; + + if (!instance.isDestroyed) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.errordownloading', true); + } + } + } + + /** + * Create and return a section for "All sections". + * + * @return Created section. + */ + createAllSectionsSection(): CoreCourseSection { + return { + id: CoreCourseProvider.ALL_SECTIONS_ID, + name: Translate.instance.instant('core.course.allsections'), + hasContent: true, + summary: '', + summaryformat: 1, + modules: [], + }; } /** @@ -386,8 +636,125 @@ export class CoreCourseHelperProvider { * @param siteId The site ID. If not defined, current site. * @return Resolved on success. */ - downloadModuleAndOpenFile(): void { - // @todo params and logic + async downloadModuleAndOpenFile( + module: CoreCourseWSModule, + courseId: number, + component?: string, + componentId?: string | number, + files?: CoreCourseModuleContentFile[], + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (!files || !files.length) { + // Make sure that module contents are loaded. + await CoreCourse.instance.loadModuleContents(module, courseId); + + files = module.contents; + } + + if (!files || !files.length) { + throw new CoreError(Translate.instance.instant('core.filenotfound')); + } + + if (!CoreFileHelper.instance.isOpenableInApp(module.contents[0])) { + await CoreFileHelper.instance.showConfirmOpenUnsupportedFile(); + } + + const site = await CoreSites.instance.getSite(siteId); + + const mainFile = files[0]; + + // Check if the file should be opened in browser. + if (CoreFileHelper.instance.shouldOpenInBrowser(mainFile)) { + return this.openModuleFileInBrowser(mainFile.fileurl, site, module, courseId, component, componentId, files); + } + + // File shouldn't be opened in browser. Download the module if it needs to be downloaded. + const result = await this.downloadModuleWithMainFileIfNeeded(module, courseId, component || '', componentId, files, siteId); + + if (CoreUrlUtils.instance.isLocalFileUrl(result.path)) { + return CoreUtils.instance.openFile(result.path); + } + + /* In iOS, if we use the same URL in embedded browser and background download then the download only + downloads a few bytes (cached ones). Add a hash to the URL so both URLs are different. */ + result.path = result.path + '#moodlemobile-embedded'; + + try { + await CoreUtils.instance.openOnlineFile(result.path); + } catch (error) { + // Error opening the file, some apps don't allow opening online files. + if (!CoreFile.instance.isAvailable()) { + throw error; + } else if (result.status === CoreConstants.DOWNLOADING) { + throw new CoreError(Translate.instance.instant('core.erroropenfiledownloading')); + } + + let path: string | undefined; + if (result.status === CoreConstants.NOT_DOWNLOADED) { + // Not downloaded, download it now and return the local file. + await this.downloadModule(module, courseId, component, componentId, files, siteId); + + path = await CoreFilepool.instance.getInternalUrlByUrl(siteId, mainFile.fileurl); + } else { + // File is outdated or stale and can't be opened in online, return the local URL. + path = await CoreFilepool.instance.getInternalUrlByUrl(siteId, mainFile.fileurl); + } + + await CoreUtils.instance.openFile(path); + } + } + + /** + * Convenience function to open a module main file in case it needs to be opened in browser. + * + * @param fileUrl URL of the main file. + * @param site Site instance. + * @param module The module to download. + * @param courseId The course ID of the module. + * @param component The component to link the files to. + * @param componentId An ID to use in conjunction with the component. + * @param files List of files of the module. If not provided, use module.contents. + * @return Resolved on success. + */ + protected async openModuleFileInBrowser( + fileUrl: string, + site: CoreSite, + module: CoreCourseWSModule, + courseId: number, + component?: string, + componentId?: string | number, + files?: CoreCourseModuleContentFile[], + ): Promise { + if (!CoreApp.instance.isOnline()) { + // Not online, get the offline file. It will fail if not found. + let path: string | undefined; + try { + path = await CoreFilepool.instance.getInternalUrlByUrl(site.getId(), fileUrl); + } catch { + throw new CoreNetworkError(); + } + + return CoreUtils.instance.openFile(path); + } + + // Open in browser. + let fixedUrl = await site.checkAndFixPluginfileURL(fileUrl); + + fixedUrl = fixedUrl.replace('&offline=1', ''); + // Remove forcedownload when followed by another param. + fixedUrl = fixedUrl.replace(/forcedownload=\d+&/, ''); + // Remove forcedownload when not followed by any param. + fixedUrl = fixedUrl.replace(/[?|&]forcedownload=\d+/, ''); + + CoreUtils.instance.openInBrowser(fixedUrl); + + if (CoreFile.instance.isAvailable()) { + // Download the file if needed (file outdated or not downloaded). + // Download will be in background, don't return the promise. + this.downloadModule(module, courseId, component, componentId, files, site.getId()); + } } /** @@ -402,8 +769,61 @@ export class CoreCourseHelperProvider { * @param siteId The site ID. If not defined, current site. * @return Promise resolved when done. */ - downloadModuleWithMainFileIfNeeded(): void { - // @todo params and logic + async downloadModuleWithMainFileIfNeeded( + module: CoreCourseWSModule, + courseId: number, + component: string, + componentId?: string | number, + files?: CoreCourseModuleContentFile[], + siteId?: string, + ): Promise<{ fixedUrl: string; path: string; status?: string }> { + + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (!files || !files.length) { + // Module not valid, stop. + throw new CoreError('File list not supplied.'); + } + + const mainFile = files[0]; + const site = await CoreSites.instance.getSite(siteId); + + const fixedUrl = await site.checkAndFixPluginfileURL(mainFile.fileurl); + + if (!CoreFile.instance.isAvailable()) { + return { + path: fixedUrl, // Use the online URL. + fixedUrl, + }; + } + + // The file system is available. + const status = await CoreFilepool.instance.getPackageStatus(siteId, component, componentId); + + let path = ''; + + if (status === CoreConstants.DOWNLOADING) { + // Use the online URL. + path = fixedUrl; + } else if (status === CoreConstants.DOWNLOADED) { + try { + // Get the local file URL. + path = await CoreFilepool.instance.getInternalUrlByUrl(siteId, mainFile.fileurl); + } catch (error){ + // File not found, mark the module as not downloaded. + await CoreFilepool.instance.storePackageStatus(siteId, CoreConstants.NOT_DOWNLOADED, component, componentId); + } + } + + if (!path) { + path = await this.downloadModuleWithMainFile(module, courseId, fixedUrl, files, status, component, componentId, siteId); + } + + return { + path, + fixedUrl, + status, + }; } /** @@ -420,8 +840,57 @@ export class CoreCourseHelperProvider { * @param siteId The site ID. If not defined, current site. * @return Promise resolved when done. */ - protected downloadModuleWithMainFile(): void { - // @todo params and logic + protected async downloadModuleWithMainFile( + module: CoreCourseWSModule, + courseId: number, + fixedUrl: string, + files: CoreCourseModuleContentFile[], + status: string, + component?: string, + componentId?: string | number, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const isOnline = CoreApp.instance.isOnline(); + const mainFile = files[0]; + const timemodified = mainFile.timemodified || 0; + + if (!isOnline && status === CoreConstants.NOT_DOWNLOADED) { + // Not downloaded and we're offline, reject. + throw new CoreNetworkError(); + } + + const shouldDownloadFirst = await CoreFilepool.instance.shouldDownloadFileBeforeOpen(fixedUrl, mainFile.filesize); + + if (shouldDownloadFirst) { + // Download and then return the local URL. + await this.downloadModule(module, courseId, component, componentId, files, siteId); + + return CoreFilepool.instance.getInternalUrlByUrl(siteId, mainFile.fileurl); + } + + // Start the download if in wifi, but return the URL right away so the file is opened. + if (CoreApp.instance.isWifi()) { + this.downloadModule(module, courseId, component, componentId, files, siteId); + } + + if (!CoreFileHelper.instance.isStateDownloaded(status) || isOnline) { + // Not downloaded or online, return the online URL. + return fixedUrl; + } else { + // Outdated but offline, so we return the local URL. Use getUrlByUrl so it's added to the queue. + return CoreFilepool.instance.getUrlByUrl( + siteId, + mainFile.fileurl, + component, + componentId, + timemodified, + false, + false, + mainFile, + ); + } } /** @@ -435,8 +904,31 @@ export class CoreCourseHelperProvider { * @param siteId The site ID. If not defined, current site. * @return Promise resolved when done. */ - downloadModule(): void { - // @todo params and logic + async downloadModule( + module: CoreCourseWSModule, + courseId: number, + component?: string, + componentId?: string | number, + files?: CoreCourseModuleContentFile[], + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const prefetchHandler = CoreCourseModulePrefetchDelegate.instance.getPrefetchHandlerFor(module); + + if (prefetchHandler) { + // Use the prefetch handler to download the module. + if (prefetchHandler.download) { + return await prefetchHandler.download(module, courseId); + } + + return await prefetchHandler.prefetch(module, courseId, true); + } + + // There's no prefetch handler for the module, just download the files. + files = files || module.contents; + + await CoreFilepool.instance.downloadOrPrefetchFiles(siteId, files, false, false, component, componentId); } /** @@ -449,8 +941,74 @@ export class CoreCourseHelperProvider { * @param component Component of the module. * @return Promise resolved when done. */ - fillContextMenu(): void { - // @todo params and logic + async fillContextMenu( + instance: ComponentWithContextMenu, + module: CoreCourseWSModule, + courseId: number, + invalidateCache?: boolean, + component?: string, + ): Promise { + const siteId = CoreSites.instance.getCurrentSiteId(); + + const moduleInfo = await this.getModulePrefetchInfo(module, courseId, invalidateCache, component); + + instance.size = moduleInfo.size && moduleInfo.size > 0 ? moduleInfo.sizeReadable! : ''; + instance.prefetchStatusIcon = moduleInfo.statusIcon; + instance.prefetchStatus = moduleInfo.status; + + if (moduleInfo.status != CoreConstants.NOT_DOWNLOADABLE) { + // Module is downloadable, get the text to display to prefetch. + if (moduleInfo.downloadTime && moduleInfo.downloadTime > 0) { + instance.prefetchText = Translate.instance.instant('core.lastdownloaded') + ': ' + moduleInfo.downloadTimeReadable; + } else { + // Module not downloaded, show a default text. + instance.prefetchText = Translate.instance.instant('core.download'); + } + } + + if (moduleInfo.status == CoreConstants.DOWNLOADING) { + // Set this to empty to prevent "remove file" option showing up while downloading. + instance.size = ''; + } + + if (!instance.contextMenuStatusObserver && component) { + instance.contextMenuStatusObserver = CoreEvents.on( + CoreEvents.PACKAGE_STATUS_CHANGED, + (data) => { + if (data.componentId == module.id && data.component == component) { + this.fillContextMenu(instance, module, courseId, false, component); + } + }, + siteId, + ); + } + + if (!instance.contextFileStatusObserver && component) { + // Debounce the update size function to prevent too many calls when downloading or deleting a whole activity. + const debouncedUpdateSize = CoreUtils.instance.debounce(async () => { + const moduleSize = await CoreCourseModulePrefetchDelegate.instance.getModuleStoredSize(module, courseId); + + instance.size = moduleSize > 0 ? CoreTextUtils.instance.bytesToSize(moduleSize, 2) : ''; + }, 1000); + + instance.contextFileStatusObserver = CoreEvents.on( + CoreEvents.COMPONENT_FILE_ACTION, + (data) => { + if (data.component != component || data.componentId != module.id) { + // The event doesn't belong to this component, ignore. + return; + } + + if (!CoreFilepool.instance.isFileEventDownloadedOrDeleted(data)) { + return; + } + + // Update the module size. + debouncedUpdateSize(); + }, + siteId, + ); + } } /** @@ -463,10 +1021,10 @@ export class CoreCourseHelperProvider { async getCourse( courseId: number, siteId?: string, - ): Promise<{ enrolled: boolean; course: CoreEnrolledCourseData | CoreCourseSearchedData | CoreCourseGetCoursesData }> { + ): Promise<{ enrolled: boolean; course: CoreCourseAnyCourseData }> { siteId = siteId || CoreSites.instance.getCurrentSiteId(); - let course: CoreEnrolledCourseData | CoreCourseSearchedData | CoreCourseGetCoursesData; + let course: CoreCourseAnyCourseData; // Try with enrolled courses first. try { @@ -495,11 +1053,12 @@ export class CoreCourseHelperProvider { * @param courseId Course ID. * @param params Other params to pass to the course page. * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. */ - async getAndOpenCourse(courseId: number, params?: Params, siteId?: string): Promise { + async getAndOpenCourse(courseId: number, params?: Params, siteId?: string): Promise { const modal = await CoreDomUtils.instance.showModalLoading(); - let course: CoreEnrolledCourseData | CoreCourseSearchedData | CoreCourseGetCoursesData | { id: number }; + let course: CoreCourseAnyCourseData | { id: number }; try { const data = await this.getCourse(courseId, siteId); @@ -575,7 +1134,7 @@ export class CoreCourseHelperProvider { * @param siteId Site ID. If not defined, current site. * @return Promise resolved when done. */ - async loadOfflineCompletion(courseId: number, sections: any[], siteId?: string): Promise { + async loadOfflineCompletion(courseId: number, sections: CoreCourseSection[], siteId?: string): Promise { const offlineCompletions = await CoreCourseOffline.instance.getCourseManualCompletions(courseId, siteId); if (!offlineCompletions || !offlineCompletions.length) { @@ -724,9 +1283,8 @@ export class CoreCourseHelperProvider { * @param module Name of the module. E.g. 'glossary'. * @param siteId Site ID. If not defined, current site. * @return Promise resolved with the module's course ID. - * @todo module type. */ - async getModuleCourseIdByInstance(id: number, module: any, siteId?: string): Promise { + async getModuleCourseIdByInstance(id: number, module: string, siteId?: string): Promise { try { const cm = await CoreCourse.instance.getModuleBasicInfoByInstance(id, module, siteId); @@ -747,8 +1305,68 @@ export class CoreCourseHelperProvider { * @param component Component of the module. * @return Promise resolved with the info. */ - getModulePrefetchInfo(): void { - // @todo params and logic + async getModulePrefetchInfo( + module: CoreCourseWSModule, + courseId: number, + invalidateCache?: boolean, + component?: string, + ): Promise { + const moduleInfo: CoreCourseModulePrefetchInfo = {}; + const siteId = CoreSites.instance.getCurrentSiteId(); + + if (invalidateCache) { + CoreCourseModulePrefetchDelegate.instance.invalidateModuleStatusCache(module); + } + + const results = await Promise.all([ + CoreCourseModulePrefetchDelegate.instance.getModuleStoredSize(module, courseId), + CoreCourseModulePrefetchDelegate.instance.getModuleStatus(module, courseId), + CoreUtils.instance.ignoreErrors(CoreFilepool.instance.getPackageData(siteId, component || '', module.id)), + ]); + + // Treat stored size. + moduleInfo.size = results[0]; + moduleInfo.sizeReadable = CoreTextUtils.instance.bytesToSize(results[0], 2); + + // Treat module status. + moduleInfo.status = results[1]; + switch (results[1]) { + case CoreConstants.NOT_DOWNLOADED: + moduleInfo.statusIcon = 'fas-cloud-download-alt'; + break; + case CoreConstants.DOWNLOADING: + moduleInfo.statusIcon = 'spinner'; + break; + case CoreConstants.OUTDATED: + moduleInfo.statusIcon = 'fas-redo'; + break; + case CoreConstants.DOWNLOADED: + if (!CoreCourseModulePrefetchDelegate.instance.canCheckUpdates()) { + moduleInfo.statusIcon = 'fas-redo'; + } + break; + default: + moduleInfo.statusIcon = ''; + break; + } + + // Treat download time. + if (!results[2] || !results[2].downloadTime || !CoreFileHelper.instance.isStateDownloaded(results[2].status || '')) { + // Not downloaded. + moduleInfo.downloadTime = 0; + + return moduleInfo; + } + + const now = CoreTimeUtils.instance.timestamp(); + moduleInfo.downloadTime = results[2].downloadTime; + if (now - results[2].downloadTime < 7 * 86400) { + moduleInfo.downloadTimeReadable = moment(results[2].downloadTime * 1000).fromNow(); + } else { + moduleInfo.downloadTimeReadable = moment(results[2].downloadTime * 1000).calendar(); + } + + return moduleInfo; } /** @@ -756,9 +1374,8 @@ export class CoreCourseHelperProvider { * * @param section Section. * @return Section download ID. - * @todo section type. */ - getSectionDownloadId(section: any): string { + getSectionDownloadId(section: {id: number}): string { return 'Section-' + section.id; } @@ -773,8 +1390,6 @@ export class CoreCourseHelperProvider { * @param useModNameToGetModule If true, the app will retrieve all modules of this type with a single WS call. This reduces the * number of WS calls, but it isn't recommended for modules that can return a lot of contents. * @param modParams Params to pass to the module - * @param navCtrl NavController for adding new pages to the current history. Optional for legacy support, but - * generates a warning if omitted. * @return Promise resolved when done. */ navigateToModuleByInstance(): void { @@ -791,8 +1406,6 @@ export class CoreCourseHelperProvider { * @param modName If set, the app will retrieve all modules of this type with a single WS call. This reduces the * number of WS calls, but it isn't recommended for modules that can return a lot of contents. * @param modParams Params to pass to the module - * @param navCtrl NavController for adding new pages to the current history. Optional for legacy support, but - * generates a warning if omitted. * @return Promise resolved when done. */ navigateToModule(): void { @@ -802,15 +1415,30 @@ export class CoreCourseHelperProvider { /** * Open a module. * - * @param navCtrl The NavController to use. * @param module The module to open. * @param courseId The course ID of the module. * @param sectionId The section ID of the module. * @param modParams Params to pass to the module * @param True if module can be opened, false otherwise. */ - openModule(): void { - // @todo params and logic + openModule(module: CoreCourseModule, courseId: number, sectionId?: number, modParams?: Params): boolean { + if (!module.handlerData) { + module.handlerData = CoreCourseModuleDelegate.instance.getModuleDataFor( + module.modname, + module, + courseId, + sectionId, + false, + ); + } + + if (module.handlerData?.action) { + module.handlerData.action(new Event('click'), module, courseId, { animated: false }, modParams); + + return true; + } + + return false; } /** @@ -824,8 +1452,8 @@ export class CoreCourseHelperProvider { * @return Promise resolved when the download finishes. */ async prefetchCourse( - course: CoreEnrolledCourseDataWithExtraInfoAndOptions, - sections: CoreCourseSection[], + course: CoreCourseAnyCourseData, + sections: CoreCourseWSSection[], courseHandlers: CoreCourseOptionsHandlerToDisplay[], courseMenuHandlers: CoreCourseOptionsMenuHandlerToDisplay[], siteId?: string, @@ -848,13 +1476,12 @@ export class CoreCourseHelperProvider { const promises: Promise[] = []; - /* @todo // Prefetch all the sections. If the first section is "All sections", use it. Otherwise, use a fake "All sections". - let allSectionsSection: Partial = sections[0]; + let allSectionsSection: CoreCourseSection = sections[0]; if (sections[0].id != CoreCourseProvider.ALL_SECTIONS_ID) { - allSectionsSection = { id: CoreCourseProvider.ALL_SECTIONS_ID }; + allSectionsSection = this.createAllSectionsSection(); } - promises.push(this.prefetchSection(allSectionsSection, course.id, sections));*/ + promises.push(this.prefetchSection(allSectionsSection, course.id, sections)); // Prefetch course options. courseHandlers.forEach((handler) => { @@ -878,7 +1505,7 @@ export class CoreCourseHelperProvider { promises.push(CoreCourse.instance.getActivitiesCompletionStatus(course.id)); } - // @todo promises.push(this.filterHelper.getFilters('course', course.id)); + promises.push(CoreFilterHelper.instance.getFilters('course', course.id)); await CoreUtils.instance.allPromises(promises); @@ -900,15 +1527,29 @@ export class CoreCourseHelperProvider { * Helper function to prefetch a module, showing a confirmation modal if the size is big * and invalidating contents if refreshing. * - * @param handler Prefetch handler to use. Must implement 'prefetch' and 'invalidateContent'. + * @param handler Prefetch handler to use. * @param module Module to download. - * @param size Object containing size to download (in bytes) and a boolean to indicate if its totally calculated. + * @param size Size to download. * @param courseId Course ID of the module. * @param refresh True if refreshing, false otherwise. * @return Promise resolved when downloaded. */ - prefetchModule(): void { - // @todo params and logic + async prefetchModule( + handler: CoreCourseModulePrefetchHandler, + module: CoreCourseWSModule, + size: CoreFileSizeSum, + courseId: number, + refresh?: boolean, + ): Promise { + // Show confirmation if needed. + await CoreDomUtils.instance.confirmDownloadSize(size); + + // Invalidate content if refreshing and download the data. + if (refresh) { + await CoreUtils.instance.ignoreErrors(handler.invalidateContent(module.id, courseId)); + } + + await CoreCourseModulePrefetchDelegate.instance.prefetchModule(module, courseId, true); } /** @@ -920,8 +1561,57 @@ export class CoreCourseHelperProvider { * @param sections List of sections. Used when downloading all the sections. * @return Promise resolved when the prefetch is finished. */ - async prefetchSection(): Promise { - // @todo params and logic + async prefetchSection( + section: CoreCourseSectionWithStatus, + courseId: number, + sections?: CoreCourseSectionWithStatus[], + ): Promise { + if (section.id != CoreCourseProvider.ALL_SECTIONS_ID) { + try { + // Download only this section. + await this.prefetchSingleSectionIfNeeded(section, courseId); + } finally { + // Calculate the status of the section that finished. + await this.calculateSectionStatus(section, courseId, false, false); + } + + return; + } + + if (!sections) { + throw new CoreError('List of sections is required when downloading all sections.'); + } + + // Download all the sections except "All sections". + let allSectionsStatus = CoreConstants.NOT_DOWNLOADABLE; + + section.isDownloading = true; + const promises = sections.map(async (section) => { + if (section.id == CoreCourseProvider.ALL_SECTIONS_ID) { + return; + } + + try { + await this.prefetchSingleSectionIfNeeded(section, courseId); + } finally { + // Calculate the status of the section that finished. + const result = await this.calculateSectionStatus(section, courseId, false, false); + + // Calculate "All sections" status. + allSectionsStatus = CoreFilepool.instance.determinePackagesStatus(allSectionsStatus, result.statusData.status); + } + }); + + try { + await CoreUtils.instance.allPromises(promises); + + // Set "All sections" data. + section.downloadStatus = allSectionsStatus; + section.canCheckUpdates = CoreCourseModulePrefetchDelegate.instance.canCheckUpdates(); + section.isDownloading = allSectionsStatus === CoreConstants.DOWNLOADING; + } finally { + section.isDownloading = false; + } } /** @@ -932,8 +1622,52 @@ export class CoreCourseHelperProvider { * @param courseId Course ID the section belongs to. * @return Promise resolved when the section is prefetched. */ - protected prefetchSingleSectionIfNeeded(): void { - // @todo params and logic + protected async prefetchSingleSectionIfNeeded(section: CoreCourseSectionWithStatus, courseId: number): Promise { + if (section.id == CoreCourseProvider.ALL_SECTIONS_ID || section.hiddenbynumsections) { + return; + } + + const promises: Promise[] = []; + const siteId = CoreSites.instance.getCurrentSiteId(); + + section.isDownloading = true; + + // Download the modules. + promises.push(this.syncModulesAndPrefetchSection(section, courseId)); + + // Download the files in the section description. + const introFiles = CoreFilepool.instance.extractDownloadableFilesFromHtmlAsFakeFileObjects(section.summary); + promises.push(CoreUtils.instance.ignoreErrors( + CoreFilepool.instance.addFilesToQueue(siteId, introFiles, CoreCourseProvider.COMPONENT, courseId), + )); + + try { + await Promise.all(promises); + } finally { + section.isDownloading = false; + } + } + + /** + * Sync modules in a section and prefetch them. + * + * @param section Section to prefetch. + * @param courseId Course ID the section belongs to. + * @return Promise resolved when the section is prefetched. + */ + protected async syncModulesAndPrefetchSection(section: CoreCourseSectionWithStatus, courseId: number): Promise { + // Sync the modules first. + await CoreCourseModulePrefetchDelegate.instance.syncModules(section.modules, courseId); + + // Validate the section needs to be downloaded and calculate amount of modules that need to be downloaded. + const result = await CoreCourseModulePrefetchDelegate.instance.getModulesStatus(section.modules, courseId, section.id); + + if (result.status == CoreConstants.DOWNLOADED || result.status == CoreConstants.NOT_DOWNLOADABLE) { + // Section is downloaded or not downloadable, nothing to do. + return ; + } + + await this.prefetchSingleSection(section, result, courseId); } /** @@ -945,8 +1679,32 @@ export class CoreCourseHelperProvider { * @param courseId Course ID the section belongs to. * @return Promise resolved when the section has been prefetched. */ - protected prefetchSingleSection(): void { - // @todo params and logic + protected async prefetchSingleSection( + section: CoreCourseSectionWithStatus, + result: CoreCourseModulesStatus, + courseId: number, + ): Promise { + if (section.id == CoreCourseProvider.ALL_SECTIONS_ID) { + return; + } + + if (section.total && section.total > 0) { + // Already being downloaded. + return ; + } + + // We only download modules with status notdownloaded, downloading or outdated. + const modules = result[CoreConstants.OUTDATED].concat(result[CoreConstants.NOT_DOWNLOADED]) + .concat(result[CoreConstants.DOWNLOADING]); + const downloadId = this.getSectionDownloadId(section); + + section.isDownloading = true; + + // Prefetch all modules to prevent incoeherences in download count and to download stale data not marked as outdated. + await CoreCourseModulePrefetchDelegate.instance.prefetchModules(downloadId, modules, courseId, (data) => { + section.count = data.count; + section.total = data.total; + }); } /** @@ -954,9 +1712,8 @@ export class CoreCourseHelperProvider { * * @param section Section to check. * @return Whether the section has content. - * @todo section type. */ - sectionHasContent(section: CoreCourseSection): boolean { + sectionHasContent(section: CoreCourseWSSection): boolean { if (section.hiddenbynumsections) { return false; } @@ -978,7 +1735,7 @@ export class CoreCourseHelperProvider { * @param siteId Site ID. If not defined, current site. * @return Promise resolved when done. */ - async openCourse(course: CoreEnrolledCourseBasicData | { id: number }, params?: Params, siteId?: string): Promise { + async openCourse(course: CoreCourseAnyCourseData | { id: number }, params?: Params, siteId?: string): Promise { if (!siteId || siteId == CoreSites.instance.getCurrentSiteId()) { // Current site, we can open the course. return CoreCourse.instance.openCourse(course, params); @@ -1017,19 +1774,16 @@ export class CoreCourseHelperProvider { * @param courseId Course ID the module belongs to. * @return Promise resolved when done. */ - // @todo remove when done. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - async removeModuleStoredData(module: any, courseId: number): Promise { + async removeModuleStoredData(module: CoreCourseWSModule, courseId: number): Promise { const promises: Promise[] = []; - // @todo - // promises.push(this.prefetchDelegate.removeModuleFiles(module, courseId)); + promises.push(CoreCourseModulePrefetchDelegate.instance.removeModuleFiles(module, courseId)); - // @todo - // const handler = this.prefetchDelegate.getPrefetchHandlerFor(module); - // if (handler) { - // promises.push(CoreSites.instance.getCurrentSite().deleteComponentFromCache(handler.component, module.id)); - // } + const handler = CoreCourseModulePrefetchDelegate.instance.getPrefetchHandlerFor(module); + const site = CoreSites.instance.getCurrentSite(); + if (handler && site) { + promises.push(site.deleteComponentFromCache(handler.component, module.id)); + } await Promise.all(promises); } @@ -1037,3 +1791,53 @@ export class CoreCourseHelperProvider { } export class CoreCourseHelper extends makeSingleton(CoreCourseHelperProvider) {} + +/** + * Section with calculated data. + */ +export type CoreCourseSection = Omit & { + hasContent?: boolean; + modules: CoreCourseModule[]; +}; + +/** + * Section with data about prefetch. + */ +export type CoreCourseSectionWithStatus = CoreCourseSection & { + downloadStatus?: string; // Section status. + canCheckUpdates?: boolean; // Whether can check updates. + isDownloading?: boolean; // Whether section is being downloaded. + total?: number; // Total of modules being downloaded. + count?: number; // Number of downloaded modules. + isCalculating?: boolean; // Whether status is being calculated. +}; + +/** + * Module with calculated data. + */ +export type CoreCourseModule = Omit & { + isStealth?: boolean; + handlerData?: CoreCourseModuleHandlerData; + completiondata?: CoreCourseModuleCompletionData; +}; + +/** + * Module completion with calculated data. + */ +export type CoreCourseModuleCompletionData = CoreCourseModuleWSCompletionData & { + courseId?: number; + courseName?: string; + tracking?: number; + cmid?: number; + offline?: boolean; +}; + +type ComponentWithContextMenu = { + prefetchStatusIcon?: string; + isDestroyed?: boolean; + size?: string; + prefetchStatus?: string; + prefetchText?: string; + contextMenuStatusObserver?: CoreEventObserver; + contextFileStatusObserver?: CoreEventObserver; +}; diff --git a/src/core/features/course/services/course-options-delegate.ts b/src/core/features/course/services/course-options-delegate.ts index 2b13c9efe..cfb872ad3 100644 --- a/src/core/features/course/services/course-options-delegate.ts +++ b/src/core/features/course/services/course-options-delegate.ts @@ -11,14 +11,19 @@ // 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. -// @todo test delegate -import { Injectable, Type } from '@angular/core'; -import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { Injectable } from '@angular/core'; +import { CoreDelegate, CoreDelegateHandler, CoreDelegateToDisplay } from '@classes/delegate'; import { CoreEvents } from '@singletons/events'; import { CoreSites } from '@services/sites'; import { CoreUtils, PromiseDefer } from '@services/utils/utils'; -import { CoreCourses, CoreCoursesProvider, CoreCourseUserAdminOrNavOptionIndexed } from '@features/courses/services/courses'; +import { + CoreCourseAnyCourseData, + CoreCourseAnyCourseDataWithOptions, + CoreCourses, + CoreCoursesProvider, + CoreCourseUserAdminOrNavOptionIndexed, +} from '@features/courses/services/courses'; import { CoreCourseProvider } from './course'; import { Params } from '@angular/router'; import { makeSingleton } from '@singletons'; @@ -47,8 +52,9 @@ export interface CoreCourseOptionsHandler extends CoreDelegateHandler { * @param admOptions Course admin options for current user. See CoreCoursesProvider.getUserAdministrationOptions. * @return True or promise resolved with true if enabled. */ - isEnabledForCourse(courseId: number, - accessData: CoreCourseAccessData, + isEnabledForCourse( + courseId: number, + accessData: CoreCourseAccess, navOptions?: CoreCourseUserAdminOrNavOptionIndexed, admOptions?: CoreCourseUserAdminOrNavOptionIndexed, ): boolean | Promise; @@ -56,11 +62,11 @@ export interface CoreCourseOptionsHandler extends CoreDelegateHandler { /** * Returns the data needed to render the handler. * - * @param course The course. // @todo: define type in the whole file. + * @param course The course. * @return Data or promise resolved with the data. */ getDisplayData?( - course: CoreEnrolledCourseDataWithExtraInfoAndOptions, + course: CoreCourseAnyCourseDataWithOptions, ): CoreCourseOptionsHandlerData | Promise; /** @@ -97,7 +103,7 @@ export interface CoreCourseOptionsMenuHandler extends CoreCourseOptionsHandler { * @return Data or promise resolved with data. */ getMenuDisplayData( - course: CoreEnrolledCourseDataWithExtraInfoAndOptions, + course: CoreCourseAnyCourseDataWithOptions, ): CoreCourseOptionsMenuHandlerData | Promise; } @@ -116,15 +122,14 @@ export interface CoreCourseOptionsHandlerData { class?: string; /** - * The component to render the handler. It must be the component class, not the name or an instance. - * When the component is created, it will receive the courseId as input. + * Path of the page to load for the handler. */ - component: Type; + page: string; /** - * Data to pass to the component. All the properties in this object will be passed to the component as inputs. + * Params to pass to the page (other than 'courseId' which is always sent). */ - componentData?: Record; + pageParams?: Params; } /** @@ -142,7 +147,7 @@ export interface CoreCourseOptionsMenuHandlerData { class?: string; /** - * Name of the page to load for the handler. + * Path of the page to load for the handler. */ page: string; @@ -160,29 +165,19 @@ export interface CoreCourseOptionsMenuHandlerData { /** * Data returned by the delegate for each handler. */ -export interface CoreCourseOptionsHandlerToDisplay { +export interface CoreCourseOptionsHandlerToDisplay extends CoreDelegateToDisplay { /** * Data to display. */ data: CoreCourseOptionsHandlerData; - /** - * Name of the handler, or name and sub context (AddonMessages, AddonMessages:blockContact, ...). - */ - name: string; - - /** - * The highest priority is displayed first. - */ - priority?: number; - /** * Called when a course is downloaded. It should prefetch all the data to be able to see the addon in offline. * * @param course The course. * @return Promise resolved when done. */ - prefetch?(course: CoreEnrolledCourseDataWithExtraInfoAndOptions): Promise; + prefetch?(course: CoreCourseAnyCourseData): Promise; } /** @@ -210,7 +205,7 @@ export interface CoreCourseOptionsMenuHandlerToDisplay { * @param course The course. * @return Promise resolved when done. */ - prefetch?(course: CoreEnrolledCourseDataWithExtraInfoAndOptions): Promise; + prefetch?(course: CoreCourseAnyCourseData): Promise; } /** @@ -226,7 +221,7 @@ export class CoreCourseOptionsDelegateService extends CoreDelegate; @@ -320,7 +315,7 @@ export class CoreCourseOptionsDelegateService extends CoreDelegate { @@ -367,7 +362,7 @@ export class CoreCourseOptionsDelegateService extends CoreDelegate { + const courseWithOptions: CoreCourseAnyCourseDataWithOptions = course; const accessData = { type: isGuest ? CoreCourseProvider.ACCESS_GUEST : CoreCourseProvider.ACCESS_DEFAULT, }; const handlersToDisplay: CoreCourseOptionsHandlerToDisplay[] | CoreCourseOptionsMenuHandlerToDisplay[] = []; if (navOptions) { - course.navOptions = navOptions; + courseWithOptions.navOptions = navOptions; } if (admOptions) { - course.admOptions = admOptions; + courseWithOptions.admOptions = admOptions; } - await this.loadCourseOptions(course, refresh); + await this.loadCourseOptions(courseWithOptions, refresh); + // Call getHandlersForAccess to make sure the handlers have been loaded. - await this.getHandlersForAccess(course.id, refresh, accessData, course.navOptions, course.admOptions); + await this.getHandlersForAccess(course.id, refresh, accessData, courseWithOptions.navOptions, courseWithOptions.admOptions); + const promises: Promise[] = []; let handlerList: CoreCourseOptionsMenuHandler[] | CoreCourseOptionsHandler[]; @@ -449,7 +447,7 @@ export class CoreCourseOptionsDelegateService extends CoreDelegate { + promises.push(Promise.resolve(getFunction!.call(handler, courseWithOptions)).then((data) => { handlersToDisplay.push({ data: data, priority: handler.priority, @@ -586,7 +584,7 @@ export class CoreCourseOptionsDelegateService extends CoreDelegate { + protected async loadCourseOptions(course: CoreCourseAnyCourseDataWithOptions, refresh = false): Promise { if (CoreCourses.instance.canGetAdminAndNavOptions() && (typeof course.navOptions == 'undefined' || typeof course.admOptions == 'undefined' || refresh)) { @@ -618,7 +616,7 @@ export class CoreCourseOptionsDelegateService extends CoreDelegate { @@ -673,5 +671,6 @@ export class CoreCourseOptionsDelegateService extends CoreDelegate { + // Run the handler the app is open to keep user in online status. + setTimeout(() => { + CoreCronDelegate.instance.forceCronHandlerExecution(CoreCourseLogCronHandler.instance.name); + }, 1000); + }); + + CoreEvents.on(CoreEvents.LOGIN, () => { + setTimeout(() => { + // Ignore errors here, since probably login is not complete: it happens on token invalid. + CoreUtils.instance.ignoreErrors( + CoreCronDelegate.instance.forceCronHandlerExecution(CoreCourseLogCronHandler.instance.name), + ); + }, 1000); + }); + } + /** * Check if the get course blocks WS is available in current site. * @@ -109,9 +132,8 @@ export class CoreCourseProvider { * * @param courseId Course ID. * @param completion Completion status of the module. - * @todo Add completion type. */ - checkModuleCompletion(courseId: number, completion: any): void { + checkModuleCompletion(courseId: number, completion: CoreCourseModuleCompletionData): void { if (completion && completion.tracking === 2 && completion.state === 0) { this.invalidateSections(courseId).finally(() => { CoreEvents.trigger(CoreEvents.COMPLETION_MODULE_VIEWED, { courseId: courseId }); @@ -134,9 +156,8 @@ export class CoreCourseProvider { } /** - * Check if the current view in a NavController is a certain course initial page. + * Check if the current view is a certain course initial page. * - * @param navCtrl NavController. * @param courseId Course ID. * @return Whether the current view is a certain course. */ @@ -346,7 +367,7 @@ export class CoreCourseProvider { ignoreCache: boolean = false, siteId?: string, modName?: string, - ): Promise { + ): Promise { siteId = siteId || CoreSites.instance.getCurrentSiteId(); // Helper function to do the WS request without processing the result. @@ -356,7 +377,7 @@ export class CoreCourseProvider { modName: string | undefined, includeStealth: boolean, preferCache: boolean, - ): Promise => { + ): Promise => { const params: CoreCourseGetContentsParams = { courseid: courseId!, options: [], @@ -394,7 +415,7 @@ export class CoreCourseProvider { } try { - const sections: CoreCourseSection[] = await site.read('core_course_get_contents', params, preSets); + const sections = await site.read('core_course_get_contents', params, preSets); return sections; } catch { @@ -419,7 +440,7 @@ export class CoreCourseProvider { courseId = module.course; } - let sections: CoreCourseSection[]; + let sections: CoreCourseWSSection[]; try { const site = await CoreSites.instance.getSite(siteId); // We have courseId, we can use core_course_get_contents for compatibility. @@ -440,7 +461,7 @@ export class CoreCourseProvider { sections = await this.getSections(courseId, false, false, preSets, siteId); } - let foundModule: CoreCourseModuleData | undefined; + let foundModule: CoreCourseWSModule | undefined; const foundSection = sections.some((section) => { if (sectionId != null && @@ -637,7 +658,7 @@ export class CoreCourseProvider { excludeModules?: boolean, excludeContents?: boolean, siteId?: string, - ): Promise { + ): Promise { if (sectionId < 0) { throw new CoreError('Invalid section ID'); @@ -671,7 +692,7 @@ export class CoreCourseProvider { preSets?: CoreSiteWSPreSets, siteId?: string, includeStealthModules: boolean = true, - ): Promise { + ): Promise { const site = await CoreSites.instance.getSite(siteId); preSets = preSets || {}; @@ -698,7 +719,7 @@ export class CoreCourseProvider { }); } - let sections: CoreCourseSection[]; + let sections: CoreCourseWSSection[]; try { sections = await site.read('core_course_get_contents', params, preSets); } catch { @@ -740,12 +761,12 @@ export class CoreCourseProvider { * @param sections Sections. * @return Modules. */ - getSectionsModules(sections: CoreCourseSection[]): CoreCourseModuleData[] { + getSectionsModules(sections: CoreCourseWSSection[]): CoreCourseWSModule[] { if (!sections || !sections.length) { return []; } - return sections.reduce((previous: CoreCourseModuleData[], section) => previous.concat(section.modules || []), []); + return sections.reduce((previous: CoreCourseWSModule[], section) => previous.concat(section.modules || []), []); } /** @@ -830,7 +851,7 @@ export class CoreCourseProvider { * @return Promise resolved when loaded. */ async loadModuleContents( - module: CoreCourseModuleData & CoreCourseModuleBasicInfo, + module: CoreCourseWSModule, courseId?: number, sectionId?: number, preferCache?: boolean, @@ -856,7 +877,6 @@ export class CoreCourseProvider { * @param siteId Site ID. If not defined, current site. * @param name Name of the course. * @return Promise resolved when the WS call is successful. - * @todo use logHelper. Remove eslint disable when done. */ async logView(courseId: number, sectionNumber?: number, siteId?: string, name?: string): Promise { const params: CoreCourseViewCourseWSParams = { @@ -875,7 +895,7 @@ export class CoreCourseProvider { if (!response.status) { throw Error('WS core_course_view_course failed.'); } else { - CoreEvents.trigger(CoreCoursesProvider.EVENT_MY_COURSES_UPDATED, { + CoreEvents.trigger(CoreCoursesProvider.EVENT_MY_COURSES_UPDATED, { courseId: courseId, action: CoreCoursesProvider.ACTION_VIEW, }, site.getId()); @@ -954,7 +974,20 @@ export class CoreCourseProvider { completed: completed, }; - return site.write('core_completion_update_activity_completion_status_manually', params); + const result = await site.write( + 'core_completion_update_activity_completion_status_manually', + params, + ); + + if (!result.status) { + if (result.warnings && result.warnings.length) { + throw new CoreWSError(result.warnings[0]); + } else { + throw new CoreError('Cannot change completion.'); + } + } + + return result; } /** @@ -963,7 +996,7 @@ export class CoreCourseProvider { * @param module The module object. * @return Whether the module has a view page. */ - moduleHasView(module: CoreCourseModuleSummary | CoreCourseModuleData): boolean { + moduleHasView(module: CoreCourseModuleSummary | CoreCourseWSModule): boolean { return !!module.url; } @@ -981,63 +1014,42 @@ export class CoreCourseProvider { * @param params Other params to pass to the course page. * @return Promise resolved when done. */ - async openCourse( - course: { id: number ; format?: string }, - params?: Params, // eslint-disable-line @typescript-eslint/no-unused-vars - ): Promise { - // @todo const loading = await CoreDomUtils.instance.showModalLoading(); + async openCourse(course: CoreCourseAnyCourseData | { id: number }, params?: Params): Promise { + const loading = await CoreDomUtils.instance.showModalLoading(); // Wait for site plugins to be fetched. // @todo await this.sitePluginsProvider.waitFetchPlugins(); - if (typeof course.format == 'undefined') { - // This block can be replaced by a call to CourseHelper.getCourse(), but it is circular dependant. - const coursesProvider = CoreCourses.instance; - try { - course = await coursesProvider.getUserCourse(course.id, true); - } catch (error) { - // Not enrolled or an error happened. Try to use another WebService. - const available = coursesProvider.isGetCoursesByFieldAvailableInSite(); - try { - if (available) { - course = await coursesProvider.getCourseByField('id', course.id); - } else { - course = await coursesProvider.getCourse(course.id); - } - } catch (error) { - // Ignore errors. - } - } + if (!('format' in course) || typeof course.format == 'undefined') { + const result = await CoreCourseHelper.instance.getCourse(course.id); + + course = result.course; } - /* @todo - if (!this.sitePluginsProvider.sitePluginPromiseExists('format_' + course.format)) { + if (course) { // @todo Replace with: if (!this.sitePluginsProvider.sitePluginPromiseExists('format_' + course.format)) { // No custom format plugin. We don't need to wait for anything. - await this.courseFormatDelegate.openCourse(course, params); + await CoreCourseFormatDelegate.instance.openCourse( course, params); loading.dismiss(); return; - } */ + } // This course uses a custom format plugin, wait for the format plugin to finish loading. try { /* @todo await this.sitePluginsProvider.sitePluginLoaded('format_' + course.format); // The format loaded successfully, but the handlers wont be registered until all site plugins have loaded. if (this.sitePluginsProvider.sitePluginsFinishedLoading) { - return this.courseFormatDelegate.openCourse(course, params); + return CoreCourseFormatDelegate.instance.openCourse(course, params); }*/ // Wait for plugins to be loaded. const deferred = CoreUtils.instance.promiseDefer(); const observer = CoreEvents.on(CoreEvents.SITE_PLUGINS_LOADED, () => { - observer && observer.off(); + observer?.off(); - /* @todo this.courseFormatDelegate.openCourse(course, params).then((response) => { - deferred.resolve(response); - }).catch((error) => { - deferred.reject(error); - });*/ + CoreCourseFormatDelegate.instance.openCourse( course, params) + .then(deferred.resolve).catch(deferred.reject); }); return deferred.promise; @@ -1334,7 +1346,7 @@ export type CoreCourseGetContentsParams = { /** * Data returned by core_course_get_contents WS. */ -export type CoreCourseSection = { +export type CoreCourseWSSection = { id: number; // Section ID. name: string; // Section name. visible?: number; // Is the section visible. @@ -1344,7 +1356,7 @@ export type CoreCourseSection = { hiddenbynumsections?: number; // Whether is a section hidden in the course format. uservisible?: boolean; // Is the section visible for the user?. availabilityinfo?: string; // Availability information. - modules: CoreCourseModuleData[]; + modules: CoreCourseWSModule[]; }; /** @@ -1371,9 +1383,9 @@ export type CoreCourseGetCourseModuleWSResponse = { }; /** - * Course module type. + * Course module data returned by the WS. */ -export type CoreCourseModuleData = { // List of module. +export type CoreCourseWSModule = { id: number; // Activity id. course?: number; // The course id. url?: string; // Activity url. @@ -1395,12 +1407,7 @@ export type CoreCourseModuleData = { // List of module. customdata?: string; // Custom data (JSON encoded). noviewlink?: boolean; // Whether the module has no view page. completion?: number; // Type of completion tracking: 0 means none, 1 manual, 2 automatic. - completiondata?: { // Module completion data. - state: number; // Completion state value: 0 means incomplete, 1 complete, 2 complete pass, 3 complete fail. - timecompleted: number; // Timestamp for completion status. - overrideby: number; // The user id who has overriden the status. - valueused?: boolean; // Whether the completion status affects the availability of another activity. - }; + completiondata?: CoreCourseModuleWSCompletionData; // Module completion data. contents: CoreCourseModuleContentFile[]; contentsinfo?: { // Contents summary information. filescount: number; // Total number of files. @@ -1411,19 +1418,28 @@ export type CoreCourseModuleData = { // List of module. }; }; +/** + * Module completion data. + */ +export type CoreCourseModuleWSCompletionData = { + state: number; // Completion state value: 0 means incomplete, 1 complete, 2 complete pass, 3 complete fail. + timecompleted: number; // Timestamp for completion status. + overrideby: number; // The user id who has overriden the status. + valueused?: boolean; // Whether the completion status affects the availability of another activity. +}; + export type CoreCourseModuleContentFile = { type: string; // A file or a folder or external link. filename: string; // Filename. filepath: string; // Filepath. filesize: number; // Filesize. - fileurl?: string; // Downloadable file url. - url?: string; // @deprecated. Use fileurl instead. + fileurl: string; // Downloadable file url. content?: string; // Raw content, will be used when type is content. timecreated: number; // Time created. timemodified: number; // Time modified. sortorder: number; // Content sort order. mimetype?: string; // File mime type. - isexternalfile?: boolean; // Whether is an external file. + isexternalfile?: number; // Whether is an external file. repositorytype?: string; // The repository type for external files. userid: number; // User who added this content to moodle. author: string; // Content owner. diff --git a/src/core/features/course/services/database/log.ts b/src/core/features/course/services/database/log.ts new file mode 100644 index 000000000..20eac30c2 --- /dev/null +++ b/src/core/features/course/services/database/log.ts @@ -0,0 +1,60 @@ +// (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 { CoreSiteSchema } from '@services/sites'; + +/** + * Database variables for CoreCourse service. + */ +export const ACTIVITY_LOG_TABLE = 'course_activity_log'; +export const SITE_SCHEMA: CoreSiteSchema = { + name: 'CoreCourseLogHelperProvider', + version: 1, + tables: [ + { + name: ACTIVITY_LOG_TABLE, + columns: [ + { + name: 'component', + type: 'TEXT', + }, + { + name: 'componentid', + type: 'INTEGER', + }, + { + name: 'ws', + type: 'TEXT', + }, + { + name: 'data', + type: 'TEXT', + }, + { + name: 'time', + type: 'INTEGER', + }, + ], + primaryKeys: ['component', 'componentid', 'ws', 'time'], + }, + ], +}; + +export type CoreCourseActivityLogDBRecord = { + component: string; + componentid: number; + ws: string; + time: number; + data?: string; +}; diff --git a/src/core/features/course/services/database/module-prefetch.ts b/src/core/features/course/services/database/module-prefetch.ts new file mode 100644 index 000000000..8b269cd4a --- /dev/null +++ b/src/core/features/course/services/database/module-prefetch.ts @@ -0,0 +1,46 @@ +// (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 { CoreSiteSchema } from '@services/sites'; + +/** + * Database variables for CoreCourseModulePrefetchDelegate service. + */ +export const CHECK_UPDATES_TIMES_TABLE = 'check_updates_times'; +export const SITE_SCHEMA: CoreSiteSchema = { + name: 'CoreCourseModulePrefetchDelegate', + version: 1, + tables: [ + { + name: CHECK_UPDATES_TIMES_TABLE, + columns: [ + { + name: 'courseId', + type: 'INTEGER', + primaryKey: true, + }, + { + name: 'time', + type: 'INTEGER', + notNull: true, + }, + ], + }, + ], +}; + +export type CoreCourseCheckUpdatesDBRecord = { + courseId: number; + time: number; +}; diff --git a/src/core/features/course/services/format-delegate.ts b/src/core/features/course/services/format-delegate.ts new file mode 100644 index 000000000..effa5d9a2 --- /dev/null +++ b/src/core/features/course/services/format-delegate.ts @@ -0,0 +1,378 @@ +// (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, Type } from '@angular/core'; +import { Params } from '@angular/router'; + +import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { CoreCourseAnyCourseData } from '@features/courses/services/courses'; +import { makeSingleton } from '@singletons'; +import { CoreCourseWSSection } from './course'; +import { CoreCourseSection } from './course-helper'; +import { CoreCourseFormatDefaultHandler } from './handlers/default-format'; + +/** + * Interface that all course format handlers must implement. + */ +export interface CoreCourseFormatHandler extends CoreDelegateHandler { + /** + * Name of the format the handler supports. E.g. 'singleactivity'. + */ + format: string; + + /** + * Get the title to use in course page. If not defined, course fullname. + * This function will be called without sections first, and then call it again when the sections are retrieved. + * + * @param course The course. + * @param sections List of sections. + * @return Title. + */ + getCourseTitle?(course: CoreCourseAnyCourseData, sections?: CoreCourseWSSection[]): string; + + /** + * Whether it allows seeing all sections at the same time. Defaults to true. + * + * @param course The course to check. + * @return Whether it can view all sections. + */ + canViewAllSections?(course: CoreCourseAnyCourseData): boolean; + + /** + * Whether the option blocks should be displayed. Defaults to true. + * + * @param course The course to check. + * @return Whether it can display blocks. + */ + displayBlocks?(course: CoreCourseAnyCourseData): boolean; + + /** + * Whether the option to enable section/module download should be displayed. Defaults to true. + * + * @param course The course to check. + * @return Whether the option to enable section/module download should be displayed. + */ + displayEnableDownload?(course: CoreCourseAnyCourseData): boolean; + + /** + * Whether the default section selector should be displayed. Defaults to true. + * + * @param course The course to check. + * @return Whether the default section selector should be displayed. + */ + displaySectionSelector?(course: CoreCourseAnyCourseData): boolean; + + /** + * Whether the course refresher should be displayed. If it returns false, a refresher must be included in the course format, + * and the doRefresh method of CoreCourseSectionPage must be called on refresh. Defaults to true. + * + * @param course The course to check. + * @param sections List of course sections. + * @return Whether the refresher should be displayed. + */ + displayRefresher?(course: CoreCourseAnyCourseData, sections: CoreCourseWSSection[]): boolean; + + /** + * Given a list of sections, get the "current" section that should be displayed first. Defaults to first section. + * + * @param course The course to get the title. + * @param sections List of sections. + * @return Promise resolved with current section. + */ + getCurrentSection?(course: CoreCourseAnyCourseData, sections: CoreCourseSection[]): Promise; + + /** + * Open the page to display a course. If not defined, the page CoreCourseSectionPage will be opened. + * Implement it only if you want to create your own page to display the course. In general it's better to use the method + * getCourseFormatComponent because it will display the course handlers at the top. + * Your page should include the course handlers using CoreCoursesDelegate. + * + * @param course The course to open. It should contain a "format" attribute. + * @param params Params to pass to the course page. + * @return Promise resolved when done. + */ + openCourse?(course: CoreCourseAnyCourseData, params?: Params): Promise; + + /** + * Return the Component to use to display the course format instead of using the default one. + * Use it if you want to display a format completely different from the default one. + * If you want to customize the default format there are several methods to customize parts of it. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param course The course to render. + * @return Promise resolved with component to use, undefined if not found. + */ + getCourseFormatComponent?(course: CoreCourseAnyCourseData): Promise | undefined>; + + /** + * Return the Component to use to display the course summary inside the default course format. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param course The course to render. + * @return Promise resolved with component to use, undefined if not found. + */ + getCourseSummaryComponent?(course: CoreCourseAnyCourseData): Promise | undefined>; + + /** + * Return the Component to use to display the section selector inside the default course format. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param course The course to render. + * @return Promise resolved with component to use, undefined if not found. + */ + getSectionSelectorComponent?(course: CoreCourseAnyCourseData): Promise | undefined>; + + /** + * Return the Component to use to display a single section. This component will only be used if the user is viewing a + * single section. If all the sections are displayed at once then it won't be used. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param course The course to render. + * @return Promise resolved with component to use, undefined if not found. + */ + getSingleSectionComponent?(course: CoreCourseAnyCourseData): Promise | undefined>; + + /** + * Return the Component to use to display all sections in a course. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param course The course to render. + * @return Promise resolved with component to use, undefined if not found. + */ + getAllSectionsComponent?(course: CoreCourseAnyCourseData): Promise | undefined>; + + /** + * Invalidate the data required to load the course format. + * + * @param course The course to get the title. + * @param sections List of sections. + * @return Promise resolved when the data is invalidated. + */ + invalidateData?(course: CoreCourseAnyCourseData, sections: CoreCourseWSSection[]): Promise; + + /** + * Whether the view should be refreshed when completion changes. If your course format doesn't display + * activity completion then you should return false. + * + * @param course The course. + * @return Whether course view should be refreshed when an activity completion changes. + */ + shouldRefreshWhenCompletionChanges?(course: CoreCourseAnyCourseData): Promise; +} + +/** + * Service to interact with course formats. + */ +@Injectable({ providedIn: 'root' }) +export class CoreCourseFormatDelegateService extends CoreDelegate { + + protected featurePrefix = 'CoreCourseFormatDelegate_'; + protected handlerNameProperty = 'format'; + + constructor(protected defaultHandler: CoreCourseFormatDefaultHandler) { + super('CoreCoursesCourseFormatDelegate', true); + } + + /** + * Whether it allows seeing all sections at the same time. Defaults to true. + * + * @param course The course to check. + * @return Whether it allows seeing all sections at the same time. + */ + canViewAllSections(course: CoreCourseAnyCourseData): boolean { + return !!this.executeFunctionOnEnabled(course.format || '', 'canViewAllSections', [course]); + } + + /** + * Whether the option blocks should be displayed. Defaults to true. + * + * @param course The course to check. + * @return Whether it can display blocks. + */ + displayBlocks(course: CoreCourseAnyCourseData): boolean { + return !!this.executeFunctionOnEnabled(course.format || '', 'displayBlocks', [course]); + } + + /** + * Whether the option to enable section/module download should be displayed. Defaults to true. + * + * @param course The course to check. + * @return Whether the option to enable section/module download should be displayed + */ + displayEnableDownload(course: CoreCourseAnyCourseData): boolean { + return !!this.executeFunctionOnEnabled(course.format || '', 'displayEnableDownload', [course]); + } + + /** + * Whether the course refresher should be displayed. If it returns false, a refresher must be included in the course format, + * and the doRefresh method of CoreCourseSectionPage must be called on refresh. Defaults to true. + * + * @param course The course to check. + * @param sections List of course sections. + * @return Whether the refresher should be displayed. + */ + displayRefresher(course: CoreCourseAnyCourseData, sections: CoreCourseWSSection[]): boolean { + return !!this.executeFunctionOnEnabled(course.format || '', 'displayRefresher', [course, sections]); + } + + /** + * Whether the default section selector should be displayed. Defaults to true. + * + * @param course The course to check. + * @return Whether the section selector should be displayed. + */ + displaySectionSelector(course: CoreCourseAnyCourseData): boolean { + return !!this.executeFunctionOnEnabled(course.format || '', 'displaySectionSelector', [course]); + } + + /** + * Get the component to use to display all sections in a course. + * + * @param course The course to render. + * @return Promise resolved with component to use, undefined if not found. + */ + async getAllSectionsComponent(course: CoreCourseAnyCourseData): Promise | undefined> { + try { + return await this.executeFunctionOnEnabled>(course.format || '', 'getAllSectionsComponent', [course]); + } catch (error) { + this.logger.error('Error getting all sections component', error); + } + } + + /** + * Get the component to use to display a course format. + * + * @param course The course to render. + * @return Promise resolved with component to use, undefined if not found. + */ + async getCourseFormatComponent(course: CoreCourseAnyCourseData): Promise | undefined> { + try { + return await this.executeFunctionOnEnabled>(course.format || '', 'getCourseFormatComponent', [course]); + } catch (error) { + this.logger.error('Error getting course format component', error); + } + } + + /** + * Get the component to use to display the course summary in the default course format. + * + * @param course The course to render. + * @return Promise resolved with component to use, undefined if not found. + */ + async getCourseSummaryComponent(course: CoreCourseAnyCourseData): Promise | undefined> { + try { + return await this.executeFunctionOnEnabled>(course.format || '', 'getCourseSummaryComponent', [course]); + } catch (error) { + this.logger.error('Error getting course summary component', error); + } + } + + /** + * Given a course, return the title to use in the course page. + * + * @param course The course to get the title. + * @param sections List of sections. + * @return Course title. + */ + getCourseTitle(course: CoreCourseAnyCourseData, sections?: CoreCourseWSSection[]): string | undefined { + return this.executeFunctionOnEnabled(course.format || '', 'getCourseTitle', [course, sections]); + } + + /** + * Given a course and a list of sections, return the current section that should be displayed first. + * + * @param course The course to get the title. + * @param sections List of sections. + * @return Promise resolved with current section. + */ + async getCurrentSection(course: CoreCourseAnyCourseData, sections: CoreCourseSection[]): Promise { + try { + const section = await this.executeFunctionOnEnabled( + course.format || '', + 'getCurrentSection', + [course, sections], + ); + + return section || sections[0]; + } catch { + // This function should never fail. Just return the first section (usually, "All sections"). + return sections[0]; + } + } + + /** + * Get the component to use to display the section selector inside the default course format. + * + * @param course The course to render. + * @return Promise resolved with component to use, undefined if not found. + */ + async getSectionSelectorComponent(course: CoreCourseAnyCourseData): Promise | undefined> { + try { + return await this.executeFunctionOnEnabled>(course.format || '', 'getSectionSelectorComponent', [course]); + } catch (error) { + this.logger.error('Error getting section selector component', error); + } + } + + /** + * Get the component to use to display a single section. This component will only be used if the user is viewing + * a single section. If all the sections are displayed at once then it won't be used. + * + * @param course The course to render. + * @return Promise resolved with component to use, undefined if not found. + */ + async getSingleSectionComponent(course: CoreCourseAnyCourseData): Promise | undefined> { + try { + return await this.executeFunctionOnEnabled>(course.format || '', 'getSingleSectionComponent', [course]); + } catch (error) { + this.logger.error('Error getting single section component', error); + } + } + + /** + * Invalidate the data required to load the course format. + * + * @param course The course to get the title. + * @param sections List of sections. + * @return Promise resolved when the data is invalidated. + */ + async invalidateData(course: CoreCourseAnyCourseData, sections: CoreCourseWSSection[]): Promise { + await this.executeFunctionOnEnabled(course.format || '', 'invalidateData', [course, sections]); + } + + /** + * Open a course. Should not be called directly. Call CoreCourseHelper.openCourse instead. + * + * @param course The course to open. It should contain a "format" attribute. + * @param params Params to pass to the course page. + * @return Promise resolved when done. + */ + async openCourse(course: CoreCourseAnyCourseData, params?: Params): Promise { + await this.executeFunctionOnEnabled(course.format || '', 'openCourse', [course, params]); + } + + /** + * Whether the view should be refreshed when completion changes. If your course format doesn't display + * activity completion then you should return false. + * + * @param course The course. + * @return Whether course view should be refreshed when an activity completion changes. + */ + async shouldRefreshWhenCompletionChanges(course: CoreCourseAnyCourseData): Promise { + return await this.executeFunctionOnEnabled(course.format || '', 'shouldRefreshWhenCompletionChanges', [course]); + } + +} + +export class CoreCourseFormatDelegate extends makeSingleton(CoreCourseFormatDelegateService) {} diff --git a/src/core/features/course/services/handlers/course-tag-area.ts b/src/core/features/course/services/handlers/course-tag-area.ts new file mode 100644 index 000000000..216bf0479 --- /dev/null +++ b/src/core/features/course/services/handlers/course-tag-area.ts @@ -0,0 +1,85 @@ +// (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, Type } from '@angular/core'; + +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreTagAreaHandler } from '@features/tag/services/tag-area-delegate'; +import { CoreCourseTagAreaComponent } from '../../components/tag-area/tag-area'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to support tags. + */ +@Injectable({ providedIn: 'root' }) +export class CoreCourseTagAreaHandlerService implements CoreTagAreaHandler { + + name = 'CoreCourseTagAreaHandler'; + type = 'core/course'; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return Whether or not the handler is enabled on a site level. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Parses the rendered content of a tag index and returns the items. + * + * @param content Rendered content. + * @return Area items (or promise resolved with the items). + */ + parseContent(content: string): CoreCouseTagItems[] { + const items: CoreCouseTagItems[] = []; + const element = CoreDomUtils.instance.convertToElement(content); + + Array.from(element.querySelectorAll('div.coursebox')).forEach((coursebox) => { + const courseId = parseInt(coursebox.getAttribute('data-courseid') || '', 10); + const courseLink = coursebox.querySelector('.coursename > a'); + const categoryLink = coursebox.querySelector('.coursecat > a'); + + if (courseId > 0 && courseLink) { + items.push({ + courseId, + courseName: courseLink.innerHTML, + categoryName: categoryLink ? categoryLink.innerHTML : null, + }); + } + }); + + return items; + } + + /** + * Get the component to use to display items. + * + * @param injector Injector. + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(): Type | Promise> { + return CoreCourseTagAreaComponent; + } + +} + +export class CoreCourseTagAreaHandler extends makeSingleton(CoreCourseTagAreaHandlerService) {} + +export type CoreCouseTagItems = { + courseId: number; + courseName: string; + categoryName: string | null; +}; diff --git a/src/core/features/course/services/handlers/default-format.ts b/src/core/features/course/services/handlers/default-format.ts new file mode 100644 index 000000000..f77d8f7df --- /dev/null +++ b/src/core/features/course/services/handlers/default-format.ts @@ -0,0 +1,195 @@ +// (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 { Params } from '@angular/router'; + +import { CoreCourseAnyCourseData, CoreCourses } from '@features/courses/services/courses'; +import { CoreNavigator } from '@services/navigator'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreCourseWSSection } from '../course'; +import { CoreCourseSection } from '../course-helper'; +import { CoreCourseFormatHandler } from '../format-delegate'; + +/** + * Default handler used when the course format doesn't have a specific implementation. + */ +@Injectable({ providedIn: 'root' }) +export class CoreCourseFormatDefaultHandler implements CoreCourseFormatHandler { + + name = 'CoreCourseFormatDefault'; + format = 'default'; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return Promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Get the title to use in course page. + * + * @param course The course. + * @return Title. + */ + getCourseTitle(course: CoreCourseAnyCourseData): string { + if (course.displayname) { + return course.displayname; + } else if (course.fullname) { + return course.fullname; + } + + return ''; + } + + /** + * Whether it allows seeing all sections at the same time. Defaults to true. + * + * @param course The course to check. + * @return Whether it can view all sections. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + canViewAllSections(course: CoreCourseAnyCourseData): boolean { + return true; + } + + /** + * Whether the option blocks should be displayed. Defaults to true. + * + * @param course The course to check. + * @return Whether it can display blocks. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + displayBlocks(course: CoreCourseAnyCourseData): boolean { + return true; + } + + /** + * Whether the option to enable section/module download should be displayed. Defaults to true. + * + * @param course The course to check. + * @return Whether the option to enable section/module download should be displayed + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + displayEnableDownload(course: CoreCourseAnyCourseData): boolean { + return true; + } + + /** + * Whether the default section selector should be displayed. Defaults to true. + * + * @param course The course to check. + * @return Whether the default section selector should be displayed. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + displaySectionSelector(course: CoreCourseAnyCourseData): boolean { + return true; + } + + /** + * Whether the course refresher should be displayed. If it returns false, a refresher must be included in the course format, + * and the doRefresh method of CoreCourseSectionPage must be called on refresh. Defaults to true. + * + * @param course The course to check. + * @param sections List of course sections. + * @return Whether the refresher should be displayed. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + displayRefresher?(course: CoreCourseAnyCourseData, sections: CoreCourseWSSection[]): boolean { + return true; + } + + /** + * Given a list of sections, get the "current" section that should be displayed first. + * + * @param course The course to get the title. + * @param sections List of sections. + * @return Current section (or promise resolved with current section). + */ + async getCurrentSection(course: CoreCourseAnyCourseData, sections: CoreCourseSection[]): Promise { + let marker: number | undefined; + + // We need the "marker" to determine the current section. + if ('marker' in course) { + // We already have it. + marker = course.marker; + } else if (!CoreCourses.instance.isGetCoursesByFieldAvailable()) { + // Cannot get the current section, return all of them. + return sections[0]; + } else { + // Try to retrieve the marker. + const courseData = await CoreUtils.instance.ignoreErrors(CoreCourses.instance.getCourseByField('id', course.id)); + + marker = courseData?.marker; + } + + if (marker && marker > 0) { + // Find the marked section. + const section = sections.find((sect) => sect.section == marker); + + if (section) { + return section; + } + } + + // Marked section not found or we couldn't retrieve the marker. Return all sections. + return sections[0]; + } + + /** + * Invalidate the data required to load the course format. + * + * @param course The course to get the title. + * @param sections List of sections. + * @return Promise resolved when the data is invalidated. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async invalidateData(course: CoreCourseAnyCourseData, sections: CoreCourseWSSection[]): Promise { + await CoreCourses.instance.invalidateCoursesByField('id', course.id); + } + + /** + * Open the page to display a course. If not defined, the page CoreCourseSectionPage will be opened. + * Implement it only if you want to create your own page to display the course. In general it's better to use the method + * getCourseFormatComponent because it will display the course handlers at the top. + * Your page should include the course handlers using CoreCoursesDelegate. + * + * @param course The course to open. It should contain a "format" attribute. + * @param params Params to pass to the course page. + * @return Promise resolved when done. + */ + async openCourse(course: CoreCourseAnyCourseData, params?: Params): Promise { + params = params || {}; + Object.assign(params, { course: course }); + + // Don't return the .push promise, we don't want to display a loading modal during the page transition. + CoreNavigator.instance.navigateToSitePath('course', { params }); + } + + /** + * Whether the view should be refreshed when completion changes. If your course format doesn't display + * activity completion then you should return false. + * + * @param course The course. + * @return Whether course view should be refreshed when an activity completion changes. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async shouldRefreshWhenCompletionChanges(course: CoreCourseAnyCourseData): Promise { + return true; + } + +} diff --git a/src/core/features/course/services/handlers/default-module.ts b/src/core/features/course/services/handlers/default-module.ts new file mode 100644 index 000000000..95f9d7b5e --- /dev/null +++ b/src/core/features/course/services/handlers/default-module.ts @@ -0,0 +1,113 @@ +// (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, Type } from '@angular/core'; + +import { CoreSites } from '@services/sites'; +import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '../module-delegate'; +import { CoreCourse, CoreCourseModuleBasicInfo, CoreCourseWSModule } from '../course'; +import { CoreCourseAnyCourseData } from '@features/courses/services/courses'; +import { CoreCourseModule } from '../course-helper'; +import { CoreCourseUnsupportedModuleComponent } from '@features/course/components/unsupported-module/unsupported-module'; +import { CoreNavigationOptions, CoreNavigator } from '@services/navigator'; + +/** + * Default handler used when the module doesn't have a specific implementation. + */ +@Injectable({ providedIn: 'root' }) +export class CoreCourseModuleDefaultHandler implements CoreCourseModuleHandler { + + name = 'CoreCourseModuleDefault'; + modName = 'default'; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return True or promise resolved with true if enabled. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Get the data required to display the module in the course contents view. + * + * @param module The module object. + * @param courseId The course ID. + * @param sectionId The section ID. + * @return Data to render the module. + */ + getData( + module: CoreCourseWSModule | CoreCourseModuleBasicInfo, + courseId: number, // eslint-disable-line @typescript-eslint/no-unused-vars + sectionId?: number, // eslint-disable-line @typescript-eslint/no-unused-vars + forCoursePage?: boolean, // eslint-disable-line @typescript-eslint/no-unused-vars + ): CoreCourseModuleHandlerData { + // Return the default data. + const defaultData: CoreCourseModuleHandlerData = { + icon: CoreCourse.instance.getModuleIconSrc(module.modname, 'modicon' in module ? module.modicon : undefined), + title: module.name, + class: 'core-course-default-handler core-course-module-' + module.modname + '-handler', + action: (event: Event, module: CoreCourseModule, courseId: number, options?: CoreNavigationOptions): void => { + event.preventDefault(); + event.stopPropagation(); + + options = options || {}; + options.params = { module, courseId }; + + CoreNavigator.instance.navigateToSitePath('course/unsupported-module', options); + }, + }; + + if ('url' in module && module.url) { + defaultData.buttons = [{ + icon: 'fas-external-link-alt', + label: 'core.openinbrowser', + action: (e: Event): void => { + e.preventDefault(); + e.stopPropagation(); + + CoreSites.instance.getCurrentSite()!.openInBrowserWithAutoLoginIfSameSite(module.url!); + }, + }]; + } + + return defaultData; + } + + /** + * Get the component to render the module. This is needed to support singleactivity course format. + * The component returned must implement CoreCourseModuleMainComponent. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param course The course object. + * @param module The module object. + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getMainComponent(course: CoreCourseAnyCourseData, module: CoreCourseWSModule): Promise | undefined> { + return CoreCourseUnsupportedModuleComponent; + } + + /** + * Whether to display the course refresher in single activity course format. If it returns false, a refresher must be + * included in the template that calls the doRefresh method of the component. Defaults to true. + * + * @return Whether the refresher should be displayed. + */ + displayRefresherInSingleActivity(): boolean { + return true; + } + +} diff --git a/src/core/features/course/services/handlers/log-cron.ts b/src/core/features/course/services/handlers/log-cron.ts new file mode 100644 index 000000000..9940cf672 --- /dev/null +++ b/src/core/features/course/services/handlers/log-cron.ts @@ -0,0 +1,65 @@ +// (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 { CoreCronHandler } from '@services/cron'; +import { CoreSites } from '@services/sites'; +import { CoreCourse } from '@features/course/services/course'; +import { makeSingleton } from '@singletons'; + +/** + * Log cron handler. It will update last access of the user while app is open. + */ +@Injectable({ providedIn: 'root' }) +export class CoreCourseLogCronHandlerService implements CoreCronHandler { + + name = 'CoreCourseLogCronHandler'; + + /** + * Execute the process. + * Receives the ID of the site affected, undefined for all sites. + * + * @param siteId ID of the site affected, undefined for all sites. + * @param force Wether the execution is forced (manual sync). + * @return Promise resolved when done, rejected if failure. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async execute(siteId?: string, force?: boolean): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return CoreCourse.instance.logView(site.getSiteHomeId(), undefined, site.getId(), site.getInfo()?.sitename); + } + + /** + * Check whether it's a synchronization process or not. + * + * @return Whether it's a synchronization process or not. + */ + isSync(): boolean { + return false; + } + + /** + * Get the time between consecutive executions. + * + * @return Time between consecutive executions (in ms). + */ + getInterval(): number { + return 240000; // 4 minutes. By default platform will see the user as online if lastaccess is less than 5 minutes. + } + +} + +export class CoreCourseLogCronHandler extends makeSingleton(CoreCourseLogCronHandlerService) {} diff --git a/src/core/features/course/services/handlers/modules-tag-area.ts b/src/core/features/course/services/handlers/modules-tag-area.ts new file mode 100644 index 000000000..21a786cc9 --- /dev/null +++ b/src/core/features/course/services/handlers/modules-tag-area.ts @@ -0,0 +1,62 @@ +// (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, Type } from '@angular/core'; + +import { CoreTagAreaHandler } from '@features/tag/services/tag-area-delegate'; +import { CoreTagFeedElement, CoreTagHelper } from '@features/tag/services/tag-helper'; +import { CoreTagFeedComponent } from '@features/tag/components/feed/feed'; +import { makeSingleton } from '@singletons'; + +/** + * Handler to support tags. + */ +@Injectable({ providedIn: 'root' }) +export class CoreCourseModulesTagAreaHandlerService implements CoreTagAreaHandler { + + name = 'CoreCourseModulesTagAreaHandler'; + type = 'core/course_modules'; + + /** + * Whether or not the handler is enabled on a site level. + * + * @return Whether or not the handler is enabled on a site level. + */ + async isEnabled(): Promise { + return true; + } + + /** + * Parses the rendered content of a tag index and returns the items. + * + * @param content Rendered content. + * @return Area items (or promise resolved with the items). + */ + parseContent(content: string): CoreTagFeedElement[] | Promise { + return CoreTagHelper.instance.parseFeedContent(content); + } + + /** + * Get the component to use to display items. + * + * @param injector Injector. + * @return The component (or promise resolved with component) to use, undefined if not found. + */ + getComponent(): Type | Promise> { + return CoreTagFeedComponent; + } + +} + +export class CoreCourseModulesTagAreaHandler extends makeSingleton(CoreCourseModulesTagAreaHandlerService) {} diff --git a/src/core/features/course/services/handlers/sync-cron.ts b/src/core/features/course/services/handlers/sync-cron.ts new file mode 100644 index 000000000..39a96a82d --- /dev/null +++ b/src/core/features/course/services/handlers/sync-cron.ts @@ -0,0 +1,52 @@ +// (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 { CoreCronHandler } from '@services/cron'; +import { makeSingleton } from '@singletons'; +import { CoreCourseSync } from '../sync'; + +/** + * Synchronization cron handler. + */ +@Injectable({ providedIn: 'root' }) +export class CoreCourseSyncCronHandlerService implements CoreCronHandler { + + name = 'CoreCourseSyncCronHandler'; + + /** + * Execute the process. + * Receives the ID of the site affected, undefined for all sites. + * + * @param siteId ID of the site affected, undefined for all sites. + * @param force Wether the execution is forced (manual sync). + * @return Promise resolved when done, rejected if failure. + */ + execute(siteId?: string, force?: boolean): Promise { + return CoreCourseSync.instance.syncAllCourses(siteId, force); + } + + /** + * Get the time between consecutive executions. + * + * @return Time between consecutive executions (in ms). + */ + getInterval(): number { + return CoreCourseSync.instance.syncInterval; + } + +} + +export class CoreCourseSyncCronHandler extends makeSingleton(CoreCourseSyncCronHandlerService) {} diff --git a/src/core/features/course/services/log-helper.ts b/src/core/features/course/services/log-helper.ts new file mode 100644 index 000000000..346bcc12d --- /dev/null +++ b/src/core/features/course/services/log-helper.ts @@ -0,0 +1,365 @@ +// (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 { CoreApp } from '@services/app'; +import { CoreSites } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { CorePushNotifications } from '@features/pushnotifications/services/pushnotifications'; +import { makeSingleton } from '@singletons'; +import { ACTIVITY_LOG_TABLE, CoreCourseActivityLogDBRecord } from './database/log'; +import { CoreStatusWithWarningsWSResponse } from '@services/ws'; +import { CoreWSError } from '@classes/errors/wserror'; + +/** + * Helper to manage logging to Moodle. + */ +@Injectable({ providedIn: 'root' }) +export class CoreCourseLogHelperProvider { + + /** + * Delete the offline saved activity logs. + * + * @param component Component name. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when deleted, rejected if failure. + */ + protected async deleteLogs(component: string, componentId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const conditions: Partial = { + component, + componentid: componentId, + }; + + await site.getDb().deleteRecords(ACTIVITY_LOG_TABLE, conditions); + } + + /** + * Delete a WS based log. + * + * @param component Component name. + * @param componentId Component ID. + * @param ws WS name. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when deleted, rejected if failure. + */ + protected async deleteWSLogsByComponent(component: string, componentId: number, ws: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const conditions: Partial = { + component, + componentid: componentId, + ws, + }; + + await site.getDb().deleteRecords(ACTIVITY_LOG_TABLE, conditions); + } + + /** + * Delete the offline saved activity logs using call data. + * + * @param ws WS name. + * @param data Data to send to the WS. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when deleted, rejected if failure. + */ + protected async deleteWSLogs(ws: string, data: Record, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const conditions: Partial = { + ws, + data: CoreUtils.instance.sortAndStringify(data), + }; + + await site.getDb().deleteRecords(ACTIVITY_LOG_TABLE, conditions); + } + + /** + * Get all the offline saved activity logs. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the list of offline logs. + */ + protected async getAllLogs(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return site.getDb().getAllRecords(ACTIVITY_LOG_TABLE); + } + + /** + * Get the offline saved activity logs. + * + * @param component Component name. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the list of offline logs. + */ + protected async getLogs(component: string, componentId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const conditions: Partial = { + component, + componentid: componentId, + }; + + return site.getDb().getRecords(ACTIVITY_LOG_TABLE, conditions); + } + + /** + * Perform log online. Data will be saved offline for syncing. + * + * @param ws WS name. + * @param data Data to send to the WS. + * @param component Component name. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async log(ws: string, data: Record, component: string, componentId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + if (!CoreApp.instance.isOnline()) { + // App is offline, store the action. + return this.storeOffline(ws, data, component, componentId, site.getId()); + } + + try { + await this.logOnline(ws, data, site.getId()); + } catch (error) { + if (CoreUtils.instance.isWebServiceError(error)) { + // The WebService has thrown an error, this means that responses cannot be submitted. + throw error; + } + + // Couldn't connect to server, store in offline. + return this.storeOffline(ws, data, component, componentId, site.getId()); + } + } + + /** + * Perform the log online. + * + * @param ws WS name. + * @param data Data to send to the WS. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when log is successfully submitted. Rejected with object containing + * the error message (if any) and a boolean indicating if the error was returned by WS. + */ + protected async logOnline( + ws: string, + data: Record, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + // Clone to have an unmodified data object. + const wsData = Object.assign({}, data); + + const response = await site.write(ws, wsData); + + if (!response.status) { + // Return the warning. If no warnings (shouldn't happen), create a fake one. + const warning = response.warnings?.[0] || { + warningcode: 'errorlog', + message: 'Error logging data.', + }; + + throw new CoreWSError(warning); + } + + // Remove all the logs performed. + // TODO: Remove this lines when time is accepted in logs. + await this.deleteWSLogs(ws, data, siteId); + } + + /** + * Perform log online. Data will be saved offline for syncing. + * It also triggers a Firebase view_item event. + * + * @param ws WS name. + * @param data Data to send to the WS. + * @param component Component name. + * @param componentId Component ID. + * @param name Name of the viewed item. + * @param category Category of the viewed item. + * @param eventData Data to pass to the Firebase event. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + logSingle( + ws: string, + data: Record, + component: string, + componentId: number, + name?: string, + category?: string, + eventData?: Record, + siteId?: string, + ): Promise { + CorePushNotifications.instance.logViewEvent(componentId, name, category, ws, eventData, siteId); + + return this.log(ws, data, component, componentId, siteId); + } + + /** + * Perform log online. Data will be saved offline for syncing. + * It also triggers a Firebase view_item_list event. + * + * @param ws WS name. + * @param data Data to send to the WS. + * @param component Component name. + * @param componentId Component ID. + * @param category Category of the viewed item. + * @param eventData Data to pass to the Firebase event. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + logList( + ws: string, + data: Record, + component: string, + componentId: number, + category: string, + eventData?: Record, + siteId?: string, + ): Promise { + CorePushNotifications.instance.logViewListEvent(category, ws, eventData, siteId); + + return this.log(ws, data, component, componentId, siteId); + } + + /** + * Save activity log for offline sync. + * + * @param ws WS name. + * @param data Data to send to the WS. + * @param component Component name. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return Resolved when done. + */ + protected async storeOffline( + ws: string, + data: Record, + component: string, + componentId: number, + siteId?: string, + ): Promise { + + const site = await CoreSites.instance.getSite(siteId); + + const log: CoreCourseActivityLogDBRecord = { + component, + componentid: componentId, + ws, + data: CoreUtils.instance.sortAndStringify(data), + time: CoreTimeUtils.instance.timestamp(), + }; + + await site.getDb().insertRecord(ACTIVITY_LOG_TABLE, log); + } + + /** + * Sync all the offline saved activity logs. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async syncSite(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + siteId = site.getId(); + + const logs = await this.getAllLogs(siteId); + + const unique: CoreCourseActivityLogDBRecord[] = []; + + // TODO: When time is accepted on log, do not discard same logs. + logs.forEach((log) => { + // Just perform unique syncs. + const found = unique.find((doneLog) => log.component == doneLog.component && log.componentid == doneLog.componentid && + log.ws == doneLog.ws && log.data == doneLog.data); + + if (!found) { + unique.push(log); + } + }); + + return this.syncLogs(unique, siteId); + } + + /** + * Sync the offline saved activity logs. + * + * @param component Component name. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async syncActivity(component: string, componentId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + siteId = site.getId(); + + const logs = await this.getLogs(component, componentId, siteId); + + const unique: CoreCourseActivityLogDBRecord[] = []; + + // TODO: When time is accepted on log, do not discard same logs. + logs.forEach((log) => { + // Just perform unique syncs. + const found = unique.find((doneLog) => log.ws == doneLog.ws && log.data == doneLog.data); + + if (!found) { + unique.push(log); + } + }); + + return this.syncLogs(unique, siteId); + } + + /** + * Sync and delete given logs. + * + * @param logs Array of log objects. + * @param siteId Site Id. + * @return Promise resolved when done. + */ + protected async syncLogs(logs: CoreCourseActivityLogDBRecord[], siteId: string): Promise { + await Promise.all(logs.map(async (log) => { + const data = CoreTextUtils.instance.parseJSON>(log.data || '{}', {}); + + try { + await this.logOnline(log.ws, data, siteId); + } catch (error) { + if (CoreUtils.instance.isWebServiceError(error)) { + // The WebService has thrown an error, this means that responses cannot be submitted. + await CoreUtils.instance.ignoreErrors(this.deleteWSLogs(log.ws, data, siteId)); + } + + throw error; + } + + await this.deleteWSLogsByComponent(log.component, log.componentid, log.ws, siteId); + })); + } + +} + +export class CoreCourseLogHelper extends makeSingleton(CoreCourseLogHelperProvider) {} diff --git a/src/core/features/course/services/module-delegate.ts b/src/core/features/course/services/module-delegate.ts new file mode 100644 index 000000000..e8559eb9f --- /dev/null +++ b/src/core/features/course/services/module-delegate.ts @@ -0,0 +1,373 @@ +// (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, Type } from '@angular/core'; +import { SafeUrl } from '@angular/platform-browser'; +import { Params } from '@angular/router'; +import { IonRefresher } from '@ionic/angular'; + +import { CoreSite } from '@classes/site'; +import { CoreCourseModuleDefaultHandler } from './handlers/default-module'; +import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { CoreCourseAnyCourseData } from '@features/courses/services/courses'; +import { CoreCourse, CoreCourseModuleBasicInfo, CoreCourseWSModule } from './course'; +import { CoreSites } from '@services/sites'; +import { NavigationOptions } from '@ionic/angular/providers/nav-controller'; +import { makeSingleton } from '@singletons'; +import { CoreCourseModule } from './course-helper'; + +/** + * Interface that all course module handlers must implement. + */ +export interface CoreCourseModuleHandler extends CoreDelegateHandler { + /** + * Name of the module. It should match the "modname" of the module returned in core_course_get_contents. + */ + modName: string; + + /** + * List of supported features. The keys should be the name of the feature. + * This is to replicate the "plugin_supports" function of Moodle. + * If you need some dynamic checks please implement the supportsFeature function. + */ + supportedFeatures?: Record; + + /** + * Get the data required to display the module in the course contents view. + * + * @param module The module object. + * @param courseId The course ID. + * @param sectionId The section ID. + * @param forCoursePage Whether the data will be used to render the course page. + * @return Data to render the module. + */ + getData( + module: CoreCourseWSModule | CoreCourseModuleBasicInfo, + courseId: number, + sectionId?: number, + forCoursePage?: boolean, + ): CoreCourseModuleHandlerData; + + /** + * Get the component to render the module. This is needed to support singleactivity course format. + * The component returned must implement CoreCourseModuleMainComponent. + * It's recommended to return the class of the component, but you can also return an instance of the component. + * + * @param course The course object. + * @param module The module object. + * @return Promise resolved with component to use, undefined if not found. + */ + getMainComponent(course: CoreCourseAnyCourseData, module: CoreCourseWSModule): Promise | undefined>; + + /** + * Whether to display the course refresher in single activity course format. If it returns false, a refresher must be + * included in the template that calls the doRefresh method of the component. Defaults to true. + * + * @return Whether the refresher should be displayed. + */ + displayRefresherInSingleActivity?(): boolean; + + /** + * Get the icon src for the module. + * + * @return The icon src. + */ + getIconSrc?(): string; + + /** + * Check if this type of module supports a certain feature. + * If this function is implemented, the supportedFeatures object will be ignored. + * + * @param feature The feature to check. + * @return The result of the supports check. + */ + supportsFeature?(feature: string): unknown; +} + +/** + * Data needed to render the module in course contents. + */ +export interface CoreCourseModuleHandlerData { + /** + * The title to display in the module. + */ + title: string; + + /** + * The accessibility title to use in the module. If not provided, title will be used. + */ + a11yTitle?: string; + + /** + * The image to use as icon (path to the image). + */ + icon?: string | SafeUrl; + + /** + * The class to assign to the item. + */ + class?: string; + + /** + * The text to show in an extra badge. + */ + extraBadge?: string; + + /** + * The color of the extra badge. Default: primary. + */ + extraBadgeColor?: string; + + /** + * Whether to display a button to download/refresh the module if it's downloadable. + * If it's set to true, the app will show a download/refresh button when needed and will handle the download of the + * module using CoreCourseModulePrefetchDelegate. + */ + showDownloadButton?: boolean; + + /** + * The buttons to display in the module item. + */ + buttons?: CoreCourseModuleHandlerButton[]; + + /** + * Whether to display a spinner where the download button is displayed. The module icon, title, etc. will be displayed. + */ + spinner?: boolean; + + /** + * Whether the data is being loaded. If true, it will display a spinner in the whole module, nothing else will be shown. + */ + loading?: boolean; + + /** + * Action to perform when the module is clicked. + * + * @param event The click event. + * @param module The module object. + * @param courseId The course ID. + * @param options Options for the navigation. + * @param params Params for the new page. + */ + action?(event: Event, module: CoreCourseModule, courseId: number, options?: NavigationOptions, params?: Params): void; + + /** + * Updates the status of the module. + * + * @param status Module status. + */ + updateStatus?(status: string): void; + + /** + * On Destroy function in case it's needed. + */ + onDestroy?(): void; +} + +/** + * Interface that all the components to render the module in singleactivity must implement. + */ +export interface CoreCourseModuleMainComponent { + /** + * Refresh the data. + * + * @param refresher Refresher. + * @param done Function to call when done. + * @return Promise resolved when done. + */ + doRefresh(refresher?: CustomEvent, done?: () => void): Promise; +} + +/** + * A button to display in a module item. + */ +export interface CoreCourseModuleHandlerButton { + /** + * The label to add to the button. + */ + label: string; + + /** + * The name of the button icon. + */ + icon: string; + + /** + * Whether the button should be hidden. + */ + hidden?: boolean; + + /** + * The name of the button icon to use in iOS instead of "icon". + * + * @deprecated since 3.9.5. Now the icon must be the same for all platforms. + */ + iosIcon?: string; + + /** + * The name of the button icon to use in MaterialDesign instead of "icon". + * + * @deprecated since 3.9.5. Now the icon must be the same for all platforms. + */ + mdIcon?: string; + + /** + * Action to perform when the button is clicked. + * + * @param event The click event. + * @param module The module object. + * @param courseId The course ID. + */ + action(event: Event, module: CoreCourseModule, courseId: number): void; +} + +/** + * Delegate to register module handlers. + */ +@Injectable({ providedIn: 'root' }) +export class CoreCourseModuleDelegateService extends CoreDelegate { + + protected featurePrefix = 'CoreCourseModuleDelegate_'; + protected handlerNameProperty = 'modName'; + + constructor(protected defaultHandler: CoreCourseModuleDefaultHandler) { + super('CoreCourseModuleDelegate', true); + } + + /** + * Get the component to render the module. + * + * @param course The course object. + * @param module The module object. + * @return Promise resolved with component to use, undefined if not found. + */ + async getMainComponent(course: CoreCourseAnyCourseData, module: CoreCourseWSModule): Promise | undefined> { + try { + return await this.executeFunctionOnEnabled>(module.modname, 'getMainComponent', [course, module]); + } catch (error) { + this.logger.error('Error getting main component', error); + } + } + + /** + * Get the data required to display the module in the course contents view. + * + * @param modname The name of the module type. + * @param module The module object. + * @param courseId The course ID. + * @param sectionId The section ID. + * @param forCoursePage Whether the data will be used to render the course page. + * @return Data to render the module. + */ + getModuleDataFor( + modname: string, + module: CoreCourseWSModule | CoreCourseModuleBasicInfo, + courseId: number, + sectionId?: number, + forCoursePage?: boolean, + ): CoreCourseModuleHandlerData | undefined { + return this.executeFunctionOnEnabled( + modname, + 'getData', + [module, courseId, sectionId, forCoursePage], + ); + } + + /** + * Check if a certain module type is disabled in a site. + * + * @param modname The name of the module type. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether module is disabled. + */ + async isModuleDisabled(modname: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.isModuleDisabledInSite(modname, site); + } + + /** + * Check if a certain module type is disabled in a site. + * + * @param modname The name of the module type. + * @param site Site. If not defined, use current site. + * @return Whether module is disabled. + */ + isModuleDisabledInSite(modname: string, site?: CoreSite): boolean { + const handler = this.getHandler(modname, false); + + if (handler) { + site = site || CoreSites.instance.getCurrentSite(); + + if (!site) { + return true; + } + + return this.isFeatureDisabled(handler, site); + } + + return false; + } + + /** + * Whether to display the course refresher in single activity course format. If it returns false, a refresher must be + * included in the template that calls the doRefresh method of the component. Defaults to true. + * + * @param modname The name of the module type. + * @return Whether the refresher should be displayed. + */ + displayRefresherInSingleActivity(modname: string): boolean { + return !!this.executeFunctionOnEnabled(modname, 'displayRefresherInSingleActivity'); + } + + /** + * Get the icon src for a certain type of module. + * + * @param modname The name of the module type. + * @param modicon The mod icon string. + * @return The icon src. + */ + getModuleIconSrc(modname: string, modicon?: string): string { + return this.executeFunctionOnEnabled(modname, 'getIconSrc') || + CoreCourse.instance.getModuleIconSrc(modname, modicon); + } + + /** + * Check if a certain type of module supports a certain feature. + * + * @param modname The modname. + * @param feature The feature to check. + * @param defaultValue Value to return if the module is not supported or doesn't know if it's supported. + * @return The result of the supports check. + */ + supportsFeature(modname: string, feature: string, defaultValue: T): T { + const handler = this.enabledHandlers[modname]; + let result: T | undefined; + + if (handler) { + if (handler.supportsFeature) { + // The handler specified a function to determine the feature, use it. + result = handler.supportsFeature(feature); + } else if (handler.supportedFeatures) { + // Handler has an object to determine the feature, use it. + result = handler.supportedFeatures[feature]; + } + } + + return result ?? defaultValue; + } + +} + +export class CoreCourseModuleDelegate extends makeSingleton(CoreCourseModuleDelegateService) {} diff --git a/src/core/features/course/services/module-prefetch-delegate.ts b/src/core/features/course/services/module-prefetch-delegate.ts new file mode 100644 index 000000000..57e6599b7 --- /dev/null +++ b/src/core/features/course/services/module-prefetch-delegate.ts @@ -0,0 +1,1594 @@ +// (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 { Subject, BehaviorSubject, Subscription } from 'rxjs'; +import { Md5 } from 'ts-md5/dist/md5'; + +import { CoreFile } from '@services/file'; +import { CoreFileHelper } from '@services/file-helper'; +import { CoreFilepool, CoreFilepoolComponentFileEventData } from '@services/filepool'; +import { CoreSites } from '@services/sites'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreCourse, CoreCourseModuleContentFile, CoreCourseWSModule } from './course'; +import { CoreCache } from '@classes/cache'; +import { CoreSiteWSPreSets } from '@classes/site'; +import { CoreConstants } from '@/core/constants'; +import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { makeSingleton } from '@singletons'; +import { CoreEventPackageStatusChanged, CoreEvents, CoreEventSectionStatusChangedData } from '@singletons/events'; +import { CoreError } from '@classes/errors/error'; +import { CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws'; +import { CHECK_UPDATES_TIMES_TABLE, CoreCourseCheckUpdatesDBRecord } from './database/module-prefetch'; +import { CoreFileSizeSum } from '@services/plugin-file-delegate'; + +const ROOT_CACHE_KEY = 'mmCourse:'; + +/** + * Delegate to register module prefetch handlers. + */ +@Injectable({ providedIn: 'root' }) +export class CoreCourseModulePrefetchDelegateService extends CoreDelegate { + + protected statusCache = new CoreCache(); + protected featurePrefix = 'CoreCourseModuleDelegate_'; + protected handlerNameProperty = 'modName'; + + // Promises for check updates, to prevent performing the same request twice at the same time. + protected courseUpdatesPromises: Record>> = {}; + + // Promises and observables for prefetching, to prevent downloading same section twice at the same time and notify progress. + protected prefetchData: Record> = {}; + + constructor() { + super('CoreCourseModulePrefetchDelegate', true); + } + + /** + * Initialize. + */ + initialize(): void { + CoreEvents.on(CoreEvents.LOGOUT, this.clearStatusCache.bind(this)); + + CoreEvents.on(CoreEvents.PACKAGE_STATUS_CHANGED, (data) => { + this.updateStatusCache(data.status, data.component, data.componentId); + }, CoreSites.instance.getCurrentSiteId()); + + // If a file inside a module is downloaded/deleted, clear the corresponding cache. + CoreEvents.on(CoreEvents.COMPONENT_FILE_ACTION, (data) => { + if (!CoreFilepool.instance.isFileEventDownloadedOrDeleted(data)) { + return; + } + + this.statusCache.invalidate(CoreFilepool.instance.getPackageId(data.component, data.componentId)); + }, CoreSites.instance.getCurrentSiteId()); + } + + /** + * Check if current site can check updates using core_course_check_updates. + * + * @return True if can check updates, false otherwise. + */ + canCheckUpdates(): boolean { + return CoreSites.instance.wsAvailableInCurrentSite('core_course_check_updates'); + } + + /** + * Check if a certain module can use core_course_check_updates. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Promise resolved with boolean: whether the module can use check updates WS. + */ + async canModuleUseCheckUpdates(module: CoreCourseWSModule, courseId: number): Promise { + const handler = this.getPrefetchHandlerFor(module); + + if (!handler) { + // Module not supported, cannot use check updates. + return false; + } + + if (handler.canUseCheckUpdates) { + return await handler.canUseCheckUpdates(module, courseId); + } + + // By default, modules can use check updates. + return true; + } + + /** + * Clear the status cache. + */ + clearStatusCache(): void { + this.statusCache.clear(); + } + + /** + * Creates the list of modules to check for get course updates. + * + * @param modules List of modules. + * @param courseId Course ID the modules belong to. + * @return Promise resolved with the lists. + */ + protected async createToCheckList(modules: CoreCourseWSModule[], courseId: number): Promise { + const result: ToCheckList = { + toCheck: [], + cannotUse: [], + }; + + const promises = modules.map(async (module) => { + try { + const data = await this.getModuleStatusAndDownloadTime(module, courseId); + if (data.status != CoreConstants.DOWNLOADED) { + return; + } + + // Module is downloaded and not outdated. Check if it can check updates. + const canUse = await this.canModuleUseCheckUpdates(module, courseId); + if (canUse) { + // Can use check updates, add it to the tocheck list. + result.toCheck.push({ + contextlevel: 'module', + id: module.id, + since: data.downloadTime || 0, + }); + } else { + // Cannot use check updates, add it to the cannotUse array. + result.cannotUse.push(module); + } + } catch { + // Ignore errors. + } + }); + + await Promise.all(promises); + + // Sort toCheck list. + result.toCheck.sort((a, b) => a.id >= b.id ? 1 : -1); + + return result; + } + + /** + * Determines a module status based on current status, restoring downloads if needed. + * + * @param module Module. + * @param status Current status. + * @param canCheck True if updates can be checked using core_course_check_updates. + * @return Module status. + */ + determineModuleStatus(module: CoreCourseWSModule, status: string, canCheck?: boolean): string { + const handler = this.getPrefetchHandlerFor(module); + const siteId = CoreSites.instance.getCurrentSiteId(); + + if (!handler) { + return status; + } + + if (status == CoreConstants.DOWNLOADING) { + // Check if the download is being handled. + if (!CoreFilepool.instance.getPackageDownloadPromise(siteId, handler.component, module.id)) { + // Not handled, the app was probably restarted or something weird happened. + // Re-start download (files already on queue or already downloaded will be skipped). + handler.prefetch(module); + } + } else if (handler.determineStatus) { + // The handler implements a determineStatus function. Apply it. + canCheck = canCheck ?? this.canCheckUpdates(); + + return handler.determineStatus(module, status, canCheck); + } + + return status; + } + + /** + * Download a module. + * + * @param module Module to download. + * @param courseId Course ID the module belongs to. + * @param dirPath Path of the directory where to store all the content files. + * @return Promise resolved when finished. + */ + async downloadModule(module: CoreCourseWSModule, courseId: number, dirPath?: string): Promise { + // Check if the module has a prefetch handler. + const handler = this.getPrefetchHandlerFor(module); + + if (!handler) { + return; + } + + await this.syncModule(module, courseId); + + await handler.download(module, courseId, dirPath); + } + + /** + * Check for updates in a course. + * + * @param modules List of modules. + * @param courseId Course ID the modules belong to. + * @return Promise resolved with the updates. If a module is set to false, it means updates cannot be + * checked for that module in the current site. + */ + async getCourseUpdates(modules: CoreCourseWSModule[], courseId: number): Promise { + if (!this.canCheckUpdates()) { + throw new CoreError('Cannot check course updates.'); + } + + // Check if there's already a getCourseUpdates in progress. + const id = Md5.hashAsciiStr(courseId + '#' + JSON.stringify(modules)); + const siteId = CoreSites.instance.getCurrentSiteId(); + + if (this.courseUpdatesPromises[siteId] && this.courseUpdatesPromises[siteId][id]) { + // There's already a get updates ongoing, return the promise. + return this.courseUpdatesPromises[siteId][id]; + } else if (!this.courseUpdatesPromises[siteId]) { + this.courseUpdatesPromises[siteId] = {}; + } + + this.courseUpdatesPromises[siteId][id] = this.fetchCourseUpdates(modules, courseId, siteId); + + try { + return await this.courseUpdatesPromises[siteId][id]; + } finally { + // Get updates finished, delete the promise. + delete this.courseUpdatesPromises[siteId][id]; + } + } + + /** + * Fetch updates in a course. + * + * @param modules List of modules. + * @param courseId Course ID the modules belong to. + * @param siteId Site ID. + * @return Promise resolved with the updates. If a module is set to false, it means updates cannot be + * checked for that module in the site. + */ + protected async fetchCourseUpdates( + modules: CoreCourseWSModule[], + courseId: number, + siteId: string, + ): Promise { + const data = await this.createToCheckList(modules, courseId); + const result: CourseUpdates = {}; + + // Mark as false the modules that cannot use check updates WS. + data.cannotUse.forEach((module) => { + result[module.id] = false; + }); + + if (!data.toCheck.length) { + // Nothing to check, no need to call the WS. + return result; + } + + // Get the site, maybe the user changed site. + const site = await CoreSites.instance.getSite(siteId); + + const params: CoreCourseCheckUpdatesWSParams = { + courseid: courseId, + tocheck: data.toCheck, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCourseUpdatesCacheKey(courseId), + emergencyCache: false, // If downloaded data has changed and offline, just fail. See MOBILE-2085. + uniqueCacheKey: true, + splitRequest: { + param: 'tocheck', + maxLength: 10, + }, + }; + + try { + const response = await site.read('core_course_check_updates', params, preSets); + + // Store the last execution of the check updates call. + const entry: CoreCourseCheckUpdatesDBRecord = { + courseId: courseId, + time: CoreTimeUtils.instance.timestamp(), + }; + CoreUtils.instance.ignoreErrors(site.getDb().insertRecord(CHECK_UPDATES_TIMES_TABLE, entry)); + + return this.treatCheckUpdatesResult(data.toCheck, response, result); + } catch (error) { + // Cannot get updates. + // Get cached entries but discard modules with a download time higher than the last execution of check updates. + let entry: CoreCourseCheckUpdatesDBRecord | undefined; + try { + entry = await site.getDb().getRecord( + CHECK_UPDATES_TIMES_TABLE, + { courseId: courseId }, + ); + } catch { + // No previous executions, return result as it is. + return result; + } + + preSets.getCacheUsingCacheKey = true; + preSets.omitExpires = true; + + const response = await site.read('core_course_check_updates', params, preSets); + + return this.treatCheckUpdatesResult(data.toCheck, response, result, entry.time); + } + } + + /** + * Check for updates in a course. + * + * @param courseId Course ID the modules belong to. + * @return Promise resolved with the updates. + */ + async getCourseUpdatesByCourseId(courseId: number): Promise { + if (!this.canCheckUpdates()) { + throw new CoreError('Cannot check course updates.'); + } + + // Get course sections and all their modules. + const sections = await CoreCourse.instance.getSections(courseId, false, true, { omitExpires: true }); + + return this.getCourseUpdates(CoreCourse.instance.getSectionsModules(sections), courseId); + } + + /** + * Get cache key for course updates WS calls. + * + * @param courseId Course ID. + * @return Cache key. + */ + protected getCourseUpdatesCacheKey(courseId: number): string { + return ROOT_CACHE_KEY + 'courseUpdates:' + courseId; + } + + /** + * Get modules download size. Only treat the modules with status not downloaded or outdated. + * + * @param modules List of modules. + * @param courseId Course ID the modules belong to. + * @return Promise resolved with the size. + */ + async getDownloadSize(modules: CoreCourseWSModule[], courseId: number): Promise { + // Get the status of each module. + const data = await this.getModulesStatus(modules, courseId); + + const downloadableModules = data[CoreConstants.NOT_DOWNLOADED].concat(data[CoreConstants.OUTDATED]); + const result: CoreFileSizeSum = { + size: 0, + total: true, + }; + + await Promise.all(downloadableModules.map(async (module) => { + const size = await this.getModuleDownloadSize(module, courseId); + + result.total = result.total && size.total; + result.size += size.size; + })); + + return result; + } + + /** + * Get the download size of a module. + * + * @param module Module to get size. + * @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 getModuleDownloadSize(module: CoreCourseWSModule, courseId: number, single?: boolean): Promise { + const handler = this.getPrefetchHandlerFor(module); + + if (!handler) { + return { size: 0, total: false }; + } + const downloadable = await this.isModuleDownloadable(module, courseId); + if (!downloadable) { + return { size: 0, total: true }; + } + + const packageId = CoreFilepool.instance.getPackageId(handler.component, module.id); + const downloadSize = this.statusCache.getValue(packageId, 'downloadSize'); + if (typeof downloadSize != 'undefined') { + return downloadSize; + } + + try { + const size = await handler.getDownloadSize(module, courseId, single); + + return this.statusCache.setValue(packageId, 'downloadSize', size); + } catch (error) { + const cachedSize = this.statusCache.getValue(packageId, 'downloadSize', true); + if (cachedSize) { + return cachedSize; + } + + throw error; + } + } + + /** + * Get the download size of a module. + * + * @param module Module to get size. + * @param courseId Course ID the module belongs to. + * @return Promise resolved with the size. + */ + async getModuleDownloadedSize(module: CoreCourseWSModule, courseId: number): Promise { + const handler = this.getPrefetchHandlerFor(module); + if (!handler) { + return 0; + } + + const downloadable = await this.isModuleDownloadable(module, courseId); + if (!downloadable) { + return 0; + } + + const packageId = CoreFilepool.instance.getPackageId(handler.component, module.id); + const downloadedSize = this.statusCache.getValue(packageId, 'downloadedSize'); + if (typeof downloadedSize != 'undefined') { + return downloadedSize; + } + + try { + let size = 0; + + if (handler.getDownloadedSize) { + // Handler implements a method to calculate the downloaded size, use it. + size = await handler.getDownloadedSize(module, courseId); + } else { + // Handler doesn't implement it, get the module files and check if they're downloaded. + const files = await this.getModuleFiles(module, courseId); + + const siteId = CoreSites.instance.getCurrentSiteId(); + + // Retrieve file size if it's downloaded. + await Promise.all(files.map(async (file) => { + const path = await CoreFilepool.instance.getFilePathByUrl(siteId, file.fileurl || ''); + + try { + const fileSize = await CoreFile.instance.getFileSize(path); + + size += fileSize; + } catch { + // Error getting size. Check if the file is being downloaded. + try { + await CoreFilepool.instance.isFileDownloadingByUrl(siteId, file.fileurl || ''); + + // If downloading, count as downloaded. + size += file.filesize || 0; + } catch { + // Not downloading and not found in disk, don't add any size + } + } + })); + } + + return this.statusCache.setValue(packageId, 'downloadedSize', size); + } catch { + return this.statusCache.getValue(packageId, 'downloadedSize', true) || 0; + } + } + + /** + * Gets the estimated total size of data stored for a module. This includes + * the files downloaded for it (getModuleDownloadedSize) and also the total + * size of web service requests stored for it. + * + * @param module Module to get the size. + * @param courseId Course ID the module belongs to. + * @return Promise resolved with the total size (0 if unknown) + */ + async getModuleStoredSize(module: CoreCourseWSModule, courseId: number): Promise { + let downloadedSize = await this.getModuleDownloadedSize(module, courseId); + + if (isNaN(downloadedSize)) { + downloadedSize = 0; + } + + const site = CoreSites.instance.getCurrentSite(); + const handler = this.getPrefetchHandlerFor(module); + if (!handler || !site) { + // If there is no handler then we can't find out the component name. + // We can't work out the cached size, so just return downloaded size. + return downloadedSize; + } + + + const cachedSize = await site.getComponentCacheSize(handler.component, module.id); + + return cachedSize + downloadedSize; + } + + /** + * Get module files. + * + * @param module Module to get the files. + * @param courseId Course ID the module belongs to. + * @return Promise resolved with the list of files. + */ + async getModuleFiles( + module: CoreCourseWSModule, + courseId: number, + ): Promise<(CoreWSExternalFile | CoreCourseModuleContentFile)[]> { + const handler = this.getPrefetchHandlerFor(module); + + if (handler?.getFiles) { + // The handler defines a function to get files, use it. + return await handler.getFiles(module, courseId); + } else if (handler?.loadContents) { + // The handler defines a function to load contents, use it before returning module contents. + await handler.loadContents(module, courseId); + + return module.contents; + } else { + return module.contents || []; + } + } + + /** + * Get the module status. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @param updates Result of getCourseUpdates for all modules in the course. If not provided, it will be + * calculated (slower). If it's false it means the site doesn't support check updates. + * @param refresh True if it should ignore the cache. + * @param sectionId ID of the section the module belongs to. + * @return Promise resolved with the status. + */ + async getModuleStatus( + module: CoreCourseWSModule, + courseId: number, + updates?: CourseUpdates | false, + refresh?: boolean, + sectionId?: number, + ): Promise { + const handler = this.getPrefetchHandlerFor(module); + const canCheck = this.canCheckUpdates(); + + if (!handler) { + // No handler found, module not downloadable. + return CoreConstants.NOT_DOWNLOADABLE; + } + + // Check if the status is cached. + const packageId = CoreFilepool.instance.getPackageId(handler.component, module.id); + const status = this.statusCache.getValue(packageId, 'status'); + + if (!refresh && typeof status != 'undefined') { + this.storeCourseAndSection(packageId, courseId, sectionId); + + return this.determineModuleStatus(module, status, canCheck); + } + + const result = await this.calculateModuleStatus(handler, module, courseId, updates, sectionId); + if (result.updateStatus) { + this.updateStatusCache(result.status, handler.component, module.id, courseId, sectionId); + } + + return this.determineModuleStatus(module, result.status, canCheck); + } + + /** + * Calculate a module status. + * + * @param handler Prefetch handler. + * @param module Module. + * @param courseId Course ID the module belongs to. + * @param updates Result of getCourseUpdates for all modules in the course. If not provided, it will be + * calculated (slower). If it's false it means the site doesn't support check updates. + * @param sectionId ID of the section the module belongs to. + * @return Promise resolved with the status. + */ + protected async calculateModuleStatus( + handler: CoreCourseModulePrefetchHandler, + module: CoreCourseWSModule, + courseId: number, + updates?: CourseUpdates | false, + sectionId?: number, + ): Promise<{status: string; updateStatus: boolean}> { + // Check if the module is downloadable. + const downloadable = await this.isModuleDownloadable(module, courseId); + if (!downloadable) { + return { + status: CoreConstants.NOT_DOWNLOADABLE, + updateStatus: true, + }; + } + + // Get the saved package status. + const siteId = CoreSites.instance.getCurrentSiteId(); + const canCheck = this.canCheckUpdates(); + const currentStatus = await CoreFilepool.instance.getPackageStatus(siteId, handler.component, module.id); + + let status = handler.determineStatus ? handler.determineStatus(module, currentStatus, canCheck) : currentStatus; + if (status != CoreConstants.DOWNLOADED || updates === false) { + return { + status, + updateStatus: true, + }; + } + + // Module is downloaded. Determine if there are updated in the module to show them outdated. + if (typeof updates == 'undefined') { + try { + // We don't have course updates, calculate them. + updates = await this.getCourseUpdatesByCourseId(courseId); + } catch { + // Error getting updates, show the stored status. + const packageId = CoreFilepool.instance.getPackageId(handler.component, module.id); + this.storeCourseAndSection(packageId, courseId, sectionId); + + return { + status: currentStatus, + updateStatus: false, + }; + } + } + + if (!updates || updates[module.id] === false) { + // Cannot check updates, always show outdated. + return { + status: CoreConstants.OUTDATED, + updateStatus: true, + }; + } + + try { + // Check if the module has any update. + const hasUpdates = await this.moduleHasUpdates(module, courseId, updates); + + if (!hasUpdates) { + // No updates, keep current status. + return { + status, + updateStatus: true, + }; + } + + // Has updates, mark the module as outdated. + status = CoreConstants.OUTDATED; + + await CoreUtils.instance.ignoreErrors( + CoreFilepool.instance.storePackageStatus(siteId, status, handler.component, module.id), + ); + + return { + status, + updateStatus: true, + }; + } catch { + // Error checking if module has updates. + const packageId = CoreFilepool.instance.getPackageId(handler.component, module.id); + const status = this.statusCache.getValue(packageId, 'status', true); + + return { + status: this.determineModuleStatus(module, status || CoreConstants.NOT_DOWNLOADED, canCheck), + updateStatus: true, + }; + } + } + + /** + * Get the status of a list of modules, along with the lists of modules for each status. + * + * @param modules List of modules to prefetch. + * @param courseId Course ID the modules belong to. + * @param sectionId ID of the section the modules belong to. + * @param refresh True if it should always check the DB (slower). + * @param onlyToDisplay True if the status will only be used to determine which button should be displayed. + * @param checkUpdates Whether to use the WS to check updates. Defaults to true. + * @return Promise resolved with the data. + */ + async getModulesStatus( + modules: CoreCourseWSModule[], + courseId: number, + sectionId?: number, + refresh?: boolean, + onlyToDisplay?: boolean, + checkUpdates: boolean = true, + ): Promise { + + let updates: CourseUpdates | false = false; + const result: CoreCourseModulesStatus = { + total: 0, + status: CoreConstants.NOT_DOWNLOADABLE, + [CoreConstants.NOT_DOWNLOADED]: [], + [CoreConstants.DOWNLOADED]: [], + [CoreConstants.DOWNLOADING]: [], + [CoreConstants.OUTDATED]: [], + }; + + if (checkUpdates) { + // Check updates in course. Don't use getCourseUpdates because the list of modules might not be the whole course list. + try { + updates = await this.getCourseUpdatesByCourseId(courseId); + } catch { + // Cannot get updates. + } + } + + await Promise.all(modules.map(async (module) => { + const handler = this.getPrefetchHandlerFor(module); + if (!handler || onlyToDisplay || handler.skipListStatus) { + return; + } + + try { + const modStatus = await this.getModuleStatus(module, courseId, updates, refresh); + + if (!result[modStatus]) { + return; + } + + result.status = CoreFilepool.instance.determinePackagesStatus(status, modStatus); + result[modStatus].push(module); + result.total++; + } catch (error) { + const packageId = CoreFilepool.instance.getPackageId(handler.component, module.id); + const cacheStatus = this.statusCache.getValue(packageId, 'status', true); + if (typeof cacheStatus == 'undefined') { + throw error; + } + + if (!result[cacheStatus]) { + return; + } + + result.status = CoreFilepool.instance.determinePackagesStatus(status, cacheStatus); + result[cacheStatus].push(module); + result.total++; + } + })); + + return result; + } + + /** + * Get a module status and download time. It will only return the download time if the module is downloaded or outdated. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Promise resolved with the data. + */ + protected async getModuleStatusAndDownloadTime( + module: CoreCourseWSModule, + courseId: number, + ): Promise<{ status: string; downloadTime?: number }> { + const handler = this.getPrefetchHandlerFor(module); + const siteId = CoreSites.instance.getCurrentSiteId(); + + if (!handler) { + // No handler found, module not downloadable. + return { status: CoreConstants.NOT_DOWNLOADABLE }; + } + + // Get the status from the cache. + const packageId = CoreFilepool.instance.getPackageId(handler.component, module.id); + const status = this.statusCache.getValue(packageId, 'status'); + + if (typeof status != 'undefined' && !CoreFileHelper.instance.isStateDownloaded(status)) { + // Module isn't downloaded, just return the status. + return { status }; + } + + // Check if the module is downloadable. + const downloadable = await this.isModuleDownloadable(module, courseId); + if (!downloadable) { + return { status: CoreConstants.NOT_DOWNLOADABLE }; + } + + // Get the stored data to get the status and downloadTime. + const data = await CoreFilepool.instance.getPackageData(siteId, handler.component, module.id); + + return { + status: data.status || CoreConstants.NOT_DOWNLOADED, + downloadTime: data.downloadTime || 0, + }; + } + + /** + * Get updates for a certain module. + * It will only return the updates if the module can use check updates and it's downloaded or outdated. + * + * @param module Module to check. + * @param courseId Course the module belongs to. + * @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down). + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the updates. + */ + async getModuleUpdates( + module: CoreCourseWSModule, + courseId: number, + ignoreCache?: boolean, + siteId?: string, + ): Promise { + + const site = await CoreSites.instance.getSite(siteId); + + const data = await this.getModuleStatusAndDownloadTime(module, courseId); + if (!CoreFileHelper.instance.isStateDownloaded(data.status)) { + // Not downloaded, no updates. + return null; + } + + // Module is downloaded. Check if it can check updates. + const canUse = await this.canModuleUseCheckUpdates(module, courseId); + if (!canUse) { + // Can't use check updates, no updates. + return null; + } + + const params: CoreCourseCheckUpdatesWSParams = { + courseid: courseId, + tocheck: [ + { + contextlevel: 'module', + id: module.id, + since: data.downloadTime || 0, + }, + ], + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getModuleUpdatesCacheKey(courseId, module.id), + }; + + if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + const response = await site.read('core_course_check_updates', params, preSets); + if (!response.instances[0]) { + throw new CoreError('Could not get module updates.'); + } + + return response.instances[0]; + } + + /** + * Get cache key for module updates WS calls. + * + * @param courseId Course ID. + * @param moduleId Module ID. + * @return Cache key. + */ + protected getModuleUpdatesCacheKey(courseId: number, moduleId: number): string { + return this.getCourseUpdatesCacheKey(courseId) + ':' + moduleId; + } + + /** + * Get a prefetch handler. + * + * @param module The module to work on. + * @return Prefetch handler. + */ + getPrefetchHandlerFor(module: CoreCourseWSModule): CoreCourseModulePrefetchHandler | undefined { + return this.getHandler(module.modname, true); + } + + /** + * Invalidate check updates WS call. + * + * @param courseId Course ID. + * @return Promise resolved when data is invalidated. + */ + async invalidateCourseUpdates(courseId: number): Promise { + const site = CoreSites.instance.getCurrentSite(); + if (!site) { + return; + } + + await site.invalidateWsCacheForKey(this.getCourseUpdatesCacheKey(courseId)); + } + + /** + * Invalidate a list of modules in a course. This should only invalidate WS calls, not downloaded files. + * + * @param modules List of modules. + * @param courseId Course ID. + * @return Promise resolved when modules are invalidated. + */ + async invalidateModules(modules: CoreCourseWSModule[], courseId: number): Promise { + + const promises = modules.map(async (module) => { + const handler = this.getPrefetchHandlerFor(module); + if (!handler) { + return; + } + + if (handler.invalidateModule) { + await CoreUtils.instance.ignoreErrors(handler.invalidateModule(module, courseId)); + } + + // Invalidate cache. + this.invalidateModuleStatusCache(module); + }); + + promises.push(this.invalidateCourseUpdates(courseId)); + + await Promise.all(promises); + } + + /** + * Invalidates the cache for a given module. + * + * @param module Module to be invalidated. + */ + invalidateModuleStatusCache(module: CoreCourseWSModule): void { + const handler = this.getPrefetchHandlerFor(module); + if (handler) { + this.statusCache.invalidate(CoreFilepool.instance.getPackageId(handler.component, module.id)); + } + } + + /** + * Invalidate check updates WS call for a certain module. + * + * @param courseId Course ID. + * @param moduleId Module ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when data is invalidated. + */ + async invalidateModuleUpdates(courseId: number, moduleId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getModuleUpdatesCacheKey(courseId, moduleId)); + } + + /** + * Check if a list of modules is being downloaded. + * + * @param id An ID to identify the download. + * @return True if it's being downloaded, false otherwise. + */ + isBeingDownloaded(id: string): boolean { + const siteId = CoreSites.instance.getCurrentSiteId(); + + return !!(this.prefetchData[siteId]?.[id]); + } + + /** + * Check if a module is downloadable. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Promise resolved with true if downloadable, false otherwise. + */ + async isModuleDownloadable(module: CoreCourseWSModule, courseId: number): Promise { + if (module.uservisible === false) { + // Module isn't visible by the user, cannot be downloaded. + return false; + } + + const handler = this.getPrefetchHandlerFor(module); + if (!handler) { + return false; + } + + if (!handler.isDownloadable) { + // Function not defined, assume it's downloadable. + return true; + } + + const packageId = CoreFilepool.instance.getPackageId(handler.component, module.id); + let downloadable = this.statusCache.getValue(packageId, 'downloadable'); + + if (typeof downloadable != 'undefined') { + return downloadable; + } + + try { + downloadable = await handler.isDownloadable(module, courseId); + + return this.statusCache.setValue(packageId, 'downloadable', downloadable); + } catch { + // Something went wrong, assume it's not downloadable. + return false; + } + } + + /** + * Check if a module has updates based on the result of getCourseUpdates. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @param updates Result of getCourseUpdates. + * @return Promise resolved with boolean: whether the module has updates. + */ + async moduleHasUpdates(module: CoreCourseWSModule, courseId: number, updates: CourseUpdates): Promise { + const handler = this.getPrefetchHandlerFor(module); + const moduleUpdates = updates[module.id]; + + if (handler?.hasUpdates) { + // Handler implements its own function to check the updates, use it. + return await handler.hasUpdates(module, courseId, moduleUpdates); + } else if (!moduleUpdates || !moduleUpdates.updates || !moduleUpdates.updates.length) { + // Module doesn't have any update. + return false; + } else if (handler?.updatesNames?.test) { + // Check the update names defined by the handler. + for (let i = 0, len = moduleUpdates.updates.length; i < len; i++) { + if (handler.updatesNames.test(moduleUpdates.updates[i].name)) { + return true; + } + } + + return false; + } + + // Handler doesn't define hasUpdates or updatesNames and there is at least 1 update. Assume it has updates. + return true; + } + + /** + * Prefetch a module. + * + * @param module Module to prefetch. + * @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 when finished. + */ + async prefetchModule(module: CoreCourseWSModule, courseId: number, single?: boolean): Promise { + const handler = this.getPrefetchHandlerFor(module); + if (!handler) { + return; + } + + await this.syncModule(module, courseId); + + await handler.prefetch(module, courseId, single); + } + + /** + * Sync a group of modules. + * + * @param modules Array of modules to sync. + * @param courseId Course ID the module belongs to. + * @return Promise resolved when finished. + */ + syncModules(modules: CoreCourseWSModule[], courseId: number): Promise { + return Promise.all(modules.map(async (module) => { + await this.syncModule(module, courseId); + + // Invalidate course updates. + await CoreUtils.instance.ignoreErrors(this.invalidateCourseUpdates(courseId)); + })); + } + + /** + * Sync a module. + * + * @param module Module to sync. + * @param courseId Course ID the module belongs to. + * @return Promise resolved when finished. + */ + async syncModule(module: CoreCourseWSModule, courseId: number): Promise { + const handler = this.getPrefetchHandlerFor(module); + if (!handler?.sync) { + return; + } + + const result = await CoreUtils.instance.ignoreErrors(handler.sync(module, courseId)); + + // Always invalidate status cache for this module. We cannot know if data was sent to server or not. + this.invalidateModuleStatusCache(module); + + return result; + } + + /** + * Prefetches a list of modules using their prefetch handlers. + * If a prefetch already exists for this site and id, returns the current promise. + * + * @param id An ID to identify the download. It can be used to retrieve the download promise. + * @param modules List of modules to prefetch. + * @param courseId Course ID the modules belong to. + * @param onProgress Function to call everytime a module is downloaded. + * @return Promise resolved when all modules have been prefetched. + */ + async prefetchModules( + id: string, + modules: CoreCourseWSModule[], + courseId: number, + onProgress?: CoreCourseModulesProgressFunction, + ): Promise { + + const siteId = CoreSites.instance.getCurrentSiteId(); + const currentPrefetchData = this.prefetchData[siteId]?.[id]; + + if (currentPrefetchData) { + // There's a prefetch ongoing, return the current promise. + if (onProgress) { + currentPrefetchData.subscriptions.push(currentPrefetchData.observable.subscribe(onProgress)); + } + + return currentPrefetchData.promise; + } + + let count = 0; + const total = modules.length; + const moduleIds = modules.map((module) => module.id); + const prefetchData: OngoingPrefetch = { + observable: new BehaviorSubject({ count: count, total: total }), + promise: Promise.resolve(), + subscriptions: [], + }; + + if (onProgress) { + prefetchData.observable.subscribe(onProgress); + } + + const promises = modules.map(async (module) => { + // Check if the module has a prefetch handler. + const handler = this.getPrefetchHandlerFor(module); + if (!handler) { + return; + } + + const downloadable = await this.isModuleDownloadable(module, courseId); + if (!downloadable) { + return; + } + + await handler.prefetch(module, courseId); + + const index = moduleIds.indexOf(module.id); + if (index > -1) { + moduleIds.splice(index, 1); + count++; + prefetchData.observable.next({ count: count, total: total }); + } + }); + + // Set the promise. + prefetchData.promise = CoreUtils.instance.allPromises(promises); + + // Store the prefetch data in the list. + this.prefetchData[siteId] = this.prefetchData[siteId] || {}; + this.prefetchData[siteId][id] = prefetchData; + + try { + await prefetchData.promise; + } finally { + // Unsubscribe all observers. + prefetchData.subscriptions.forEach((subscription) => { + subscription.unsubscribe(); + }); + delete this.prefetchData[siteId][id]; + } + } + + /** + * Remove module Files from handler. + * + * @param module Module to remove the files. + * @param courseId Course ID the module belongs to. + * @return Promise resolved when done. + */ + async removeModuleFiles(module: CoreCourseWSModule, courseId: number): Promise { + const handler = this.getPrefetchHandlerFor(module); + const siteId = CoreSites.instance.getCurrentSiteId(); + + if (handler?.removeFiles) { + // Handler implements a method to remove the files, use it. + await handler.removeFiles(module, courseId); + } else { + // No method to remove files, use get files to try to remove the files. + const files = await this.getModuleFiles(module, courseId); + + await Promise.all(files.map(async (file) => { + await CoreUtils.instance.ignoreErrors(CoreFilepool.instance.removeFileByUrl(siteId, file.fileurl || '')); + })); + } + + if (!handler) { + return; + } + + // Update downloaded size. + const packageId = CoreFilepool.instance.getPackageId(handler.component, module.id); + this.statusCache.setValue(packageId, 'downloadedSize', 0); + + // If module is downloadable, set not dowloaded status. + const downloadable = await this.isModuleDownloadable(module, courseId); + if (!downloadable) { + return; + } + + await CoreFilepool.instance.storePackageStatus(siteId, CoreConstants.NOT_DOWNLOADED, handler.component, module.id); + } + + /** + * Set an on progress function for the download of a list of modules. + * + * @param id An ID to identify the download. + * @param onProgress Function to call everytime a module is downloaded. + */ + setOnProgress(id: string, onProgress: CoreCourseModulesProgressFunction): void { + const currentData = this.prefetchData[CoreSites.instance.getCurrentSiteId()]?.[id]; + + if (currentData) { + // There's a prefetch ongoing, return the current promise. + currentData.subscriptions.push(currentData.observable.subscribe(onProgress)); + } + } + + /** + * If courseId or sectionId is set, save them in the cache. + * + * @param packageId The package ID. + * @param courseId Course ID. + * @param sectionId Section ID. + */ + storeCourseAndSection(packageId: string, courseId?: number, sectionId?: number): void { + if (courseId) { + this.statusCache.setValue(packageId, 'courseId', courseId); + } + if (sectionId && sectionId > 0) { + this.statusCache.setValue(packageId, 'sectionId', sectionId); + } + } + + /** + * Treat the result of the check updates WS call. + * + * @param toCheckList List of modules to check (from createToCheckList). + * @param response WS call response. + * @param result Object where to store the result. + * @param previousTime Time of the previous check updates execution. If set, modules downloaded + * after this time will be ignored. + * @return Result. + */ + protected treatCheckUpdatesResult( + toCheckList: CheckUpdatesToCheckWSParam[], + response: CoreCourseCheckUpdatesWSResponse, + result: CourseUpdates, + previousTime?: number, + ): CourseUpdates { + // Format the response to index it by module ID. + CoreUtils.instance.arrayToObject(response.instances, 'id', result); + + // Treat warnings, adding the not supported modules. + response.warnings?.forEach((warning) => { + if (warning.warningcode == 'missingcallback') { + result[warning.itemid!] = false; + } + }); + + if (previousTime) { + // Remove from the list the modules downloaded after previousTime. + toCheckList.forEach((entry) => { + if (result[entry.id] && entry.since > previousTime) { + delete result[entry.id]; + } + }); + } + + return result; + } + + /** + * Update the status of a module in the "cache". + * + * @param status New status. + * @param component Package's component. + * @param componentId An ID to use in conjunction with the component. + * @param courseId Course ID of the module. + * @param sectionId Section ID of the module. + */ + updateStatusCache( + status: string, + component: string, + componentId?: string | number, + courseId?: number, + sectionId?: number, + ): void { + const packageId = CoreFilepool.instance.getPackageId(component, componentId); + const cachedStatus = this.statusCache.getValue(packageId, 'status', true); + + // If courseId/sectionId is set, store it. + this.storeCourseAndSection(packageId, courseId, sectionId); + + if (cachedStatus === undefined || cachedStatus === status) { + this.statusCache.setValue(packageId, 'status', status); + + return; + } + + // The status has changed, notify that the section has changed. + courseId = courseId || this.statusCache.getValue(packageId, 'courseId', true); + sectionId = sectionId || this.statusCache.getValue(packageId, 'sectionId', true); + + // Invalidate and set again. + this.statusCache.invalidate(packageId); + this.statusCache.setValue(packageId, 'status', status); + + if (sectionId) { + const data: CoreEventSectionStatusChangedData = { + sectionId, + courseId: courseId!, + }; + CoreEvents.trigger(CoreEvents.SECTION_STATUS_CHANGED, data, CoreSites.instance.getCurrentSiteId()); + } + } + +} + +export class CoreCourseModulePrefetchDelegate extends makeSingleton(CoreCourseModulePrefetchDelegateService) {} + +/** + * Progress of downloading a list of modules. + */ +export type CoreCourseModulesProgress = { + /** + * Number of modules downloaded so far. + */ + count: number; + + /** + * Toal of modules to download. + */ + total: number; +}; + +/** + * Progress function for downloading a list of modules. + * + * @param data Progress data. + */ +export type CoreCourseModulesProgressFunction = (data: CoreCourseModulesProgress) => void; + +/** + * Interface that all course prefetch handlers must implement. + */ +export interface CoreCourseModulePrefetchHandler extends CoreDelegateHandler { + /** + * Name of the handler. + */ + name: string; + + /** + * Name of the module. It should match the "modname" of the module returned in core_course_get_contents. + */ + modName: string; + + /** + * The handler's component. + */ + component: string; + + /** + * The RegExp to check updates. If a module has an update whose name matches this RegExp, the module will be marked + * as outdated. This RegExp is ignored if hasUpdates function is defined. + */ + updatesNames?: RegExp; + + /** + * If true, this module will be treated as not downloadable when determining the status of a list of modules. The module will + * still be downloaded when downloading the section/course, it only affects whether the button should be displayed. + */ + skipListStatus: boolean; + + /** + * 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. + */ + getDownloadSize(module: CoreCourseWSModule, courseId: number, single?: boolean): Promise; + + /** + * Prefetch 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. + * @param dirPath Path of the directory where to store all the content files. + * @return Promise resolved when done. + */ + prefetch(module: CoreCourseWSModule, courseId?: number, single?: boolean, dirPath?: string): Promise; + + /** + * Download the module. + * + * @param module The module object returned by WS. + * @param courseId Course ID. + * @param dirPath Path of the directory where to store all the content files. + * @return Promise resolved when all content is downloaded. + */ + download(module: CoreCourseWSModule, courseId: number, dirPath?: string): Promise; + + /** + * Invalidate the prefetched content. + * + * @param moduleId The module ID. + * @param courseId Course ID the module belongs to. + * @return Promise resolved when the data is invalidated. + */ + invalidateContent(moduleId: number, courseId: number): Promise; + + /** + * Check if a certain module can use core_course_check_updates to check if it has updates. + * If not defined, it will assume all modules can be checked. + * The modules that return false will always be shown as outdated when they're downloaded. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Whether the module can use check_updates. The promise should never be rejected. + */ + canUseCheckUpdates?(module: CoreCourseWSModule, courseId: number): Promise; + + /** + * Return the status to show based on current status. E.g. a module might want to show outdated instead of downloaded. + * If not implemented, the original status will be returned. + * + * @param module Module. + * @param status The current status. + * @param canCheck Whether the site allows checking for updates. + * @return Status to display. + */ + determineStatus?(module: CoreCourseWSModule, status: string, canCheck: boolean): string; + + /** + * Get the downloaded size of a module. If not defined, we'll use getFiles to calculate it (it can be slow). + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Size, or promise resolved with the size. + */ + getDownloadedSize?(module: CoreCourseWSModule, courseId: number): Promise; + + /** + * Get the list of files of the module. If not defined, we'll assume they are in module.contents. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return List of files, or promise resolved with the files. + */ + getFiles?(module: CoreCourseWSModule, courseId: number): Promise<(CoreWSExternalFile | CoreCourseModuleContentFile)[]>; + + /** + * Check if a certain module has updates based on the result of check updates. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @param moduleUpdates List of updates for the module. + * @return Whether the module has updates. The promise should never be rejected. + */ + hasUpdates?(module: CoreCourseWSModule, courseId: number, moduleUpdates: false | CheckUpdatesWSInstance): Promise; + + /** + * Invalidate WS calls needed to determine module status (usually, to check if module is downloadable). + * It doesn't need to invalidate check updates. It should NOT invalidate files nor all the prefetched data. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Promise resolved when invalidated. + */ + invalidateModule?(module: CoreCourseWSModule, courseId: number): Promise; + + /** + * 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. + */ + isDownloadable?(module: CoreCourseWSModule, courseId: number): Promise; + + /** + * Load module contents in module.contents if they aren't loaded already. This is meant for resources. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Promise resolved when done. + */ + loadContents?(module: CoreCourseWSModule, courseId: number): Promise; + + /** + * Remove module downloaded files. If not defined, we'll use getFiles to remove them (slow). + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Promise resolved when done. + */ + removeFiles?(module: CoreCourseWSModule, courseId: number): Promise; + + /** + * 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: CoreCourseWSModule, courseId: number, siteId?: string): Promise; +} + +type ToCheckList = { + toCheck: CheckUpdatesToCheckWSParam[]; + cannotUse: CoreCourseWSModule[]; +}; + +/** + * Course updates. + */ +type CourseUpdates = Record; + +/** + * Status data about a list of modules. + */ +export type CoreCourseModulesStatus = { + total: number; // Number of modules. + status: string; // Status of the list of modules. + [CoreConstants.NOT_DOWNLOADED]: CoreCourseWSModule[]; // Modules with state NOT_DOWNLOADED. + [CoreConstants.DOWNLOADED]: CoreCourseWSModule[]; // Modules with state DOWNLOADED. + [CoreConstants.DOWNLOADING]: CoreCourseWSModule[]; // Modules with state DOWNLOADING. + [CoreConstants.OUTDATED]: CoreCourseWSModule[]; // Modules with state OUTDATED. +}; + +/** + * Data for an ongoing module prefetch. + */ +type OngoingPrefetch = { + promise: Promise; // Prefetch promise. + observable: Subject; // Observable to notify the download progress. + subscriptions: Subscription[]; // Subscriptions that are currently listening the progress. +}; + +/** + * Params of core_course_check_updates WS. + */ +export type CoreCourseCheckUpdatesWSParams = { + courseid: number; // Course id to check. + tocheck: CheckUpdatesToCheckWSParam[]; // Instances to check. + filter?: string[]; // Check only for updates in these areas. +}; + +/** + * Data to send in tocheck parameter. + */ +type CheckUpdatesToCheckWSParam = { + contextlevel: string; // The context level for the file location. Only module supported right now. + id: number; // Context instance id. + since: number; // Check updates since this time stamp. +}; + +/** + * Data returned by core_course_check_updates WS. + */ +export type CoreCourseCheckUpdatesWSResponse = { + instances: CheckUpdatesWSInstance[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Instance data returned by the WS. + */ +type CheckUpdatesWSInstance = { + contextlevel: string; // The context level. + id: number; // Instance id. + updates: { + name: string; // Name of the area updated. + timeupdated?: number; // Last time was updated. + itemids?: number[]; // The ids of the items updated. + }[]; +}; diff --git a/src/core/features/course/services/sync.ts b/src/core/features/course/services/sync.ts new file mode 100644 index 000000000..4cbcd2f0c --- /dev/null +++ b/src/core/features/course/services/sync.ts @@ -0,0 +1,260 @@ +// (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 { CoreSyncBaseProvider } from '@classes/base-sync'; + +import { CoreSites } from '@services/sites'; +import { CoreApp } from '@services/app'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreCourseOffline } from './course-offline'; +import { CoreCourse } from './course'; +import { CoreCourseLogHelper } from './log-helper'; +import { CoreWSExternalWarning } from '@services/ws'; +import { CoreCourseManualCompletionDBRecord } from './database/course'; +import { CoreNetworkError } from '@classes/errors/network-error'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreEvents, CoreEventSiteData } from '@singletons/events'; + +/** + * Service to sync course offline data. This only syncs the offline data of the course itself, not the offline data of + * the activities in the course. + */ +@Injectable({ providedIn: 'root' }) +export class CoreCourseSyncProvider extends CoreSyncBaseProvider { + + static readonly AUTO_SYNCED = 'core_course_autom_synced'; + + constructor() { + super('CoreCourseSyncProvider'); + } + + /** + * Try to synchronize all the courses in a certain site or in all sites. + * + * @param siteId Site ID to sync. If not defined, sync all sites. + * @param force Wether the execution is forced (manual sync). + * @return Promise resolved if sync is successful, rejected if sync fails. + */ + syncAllCourses(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('courses', this.syncAllCoursesFunc.bind(this, siteId, force), siteId); + } + + /** + * Sync all courses on a site. + * + * @param siteId Site ID to sync. + * @param force Wether the execution is forced (manual sync). + * @return Promise resolved if sync is successful, rejected if sync fails. + */ + protected async syncAllCoursesFunc(siteId: string, force: boolean): Promise { + await Promise.all([ + CoreCourseLogHelper.instance.syncSite(siteId), + this.syncCoursesCompletion(siteId, force), + ]); + } + + /** + * Sync courses offline completion. + * + * @param siteId Site ID to sync. + * @param force Wether the execution is forced (manual sync). + * @return Promise resolved if sync is successful, rejected if sync fails. + */ + protected async syncCoursesCompletion(siteId: string, force: boolean): Promise { + const completions = await CoreCourseOffline.instance.getAllManualCompletions(siteId); + + // Sync all courses. + await Promise.all(completions.map(async (completion) => { + const result = await (force ? this.syncCourse(completion.courseid, siteId) : + this.syncCourseIfNeeded(completion.courseid, siteId)); + + if (!result || !result.updated) { + return; + } + + // Sync successful, send event. + CoreEvents.trigger(CoreCourseSyncProvider.AUTO_SYNCED, { + courseId: completion.courseid, + warnings: result.warnings, + }, siteId); + })); + } + + /** + * Sync a course if it's needed. + * + * @param courseId Course ID to be synced. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the course is synced or it doesn't need to be synced. + */ + syncCourseIfNeeded(courseId: number, siteId?: string): Promise { + // Usually we call isSyncNeeded to check if a certain time has passed. + // However, since we barely send data for now just sync the course. + return this.syncCourse(courseId, siteId); + } + + /** + * Synchronize a course. + * + * @param courseId Course ID to be synced. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + async syncCourse(courseId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (this.isSyncing(courseId, siteId)) { + // There's already a sync ongoing for this discussion, return the promise. + return this.getOngoingSync(courseId, siteId)!; + } + + this.logger.debug(`Try to sync course '${courseId}'`); + + return this.addOngoingSync(courseId, this.syncCourseCompletion(courseId, siteId), siteId); + } + + /** + * Sync course offline completion. + * + * @param courseId Course ID to be synced. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved if sync is successful, rejected otherwise. + */ + protected async syncCourseCompletion(courseId: number, siteId?: string): Promise { + const result: CoreCourseSyncResult = { + warnings: [], + updated: false, + }; + + // Get offline responses to be sent. + const completions = await CoreUtils.instance.ignoreErrors( + CoreCourseOffline.instance.getCourseManualCompletions(courseId, siteId), + [], + ); + + + if (!completions || !completions.length) { + // Nothing to sync, set sync time. + await this.setSyncTime(String(courseId), siteId); + + // All done, return the data. + return result; + } + + if (!CoreApp.instance.isOnline()) { + // Cannot sync in offline. + throw new CoreNetworkError(); + } + + // Get the current completion status to check if any completion was modified in web. + // This can be retrieved on core_course_get_contents since 3.6 but this is an easy way to get them. + const onlineCompletions = await CoreCourse.instance.getActivitiesCompletionStatus( + courseId, + siteId, + undefined, + false, + true, + false, + ); + + // Send all the completions. + await Promise.all(completions.map(async (entry) => { + const onlineComp = onlineCompletions[entry.cmid]; + + // Check if the completion was modified in online. If so, discard it. + if (onlineComp && onlineComp.timecompleted * 1000 > entry.timecompleted) { + await CoreCourseOffline.instance.deleteManualCompletion(entry.cmid, siteId); + + // Completion deleted, add a warning if the completion status doesn't match. + if (onlineComp.state != entry.completed) { + result.warnings.push(Translate.instance.instant('core.course.warningofflinemanualcompletiondeleted', { + name: entry.coursename || courseId, + error: Translate.instance.instant('core.course.warningmanualcompletionmodified'), + })); + } + + return; + } + + try { + await CoreCourse.instance.markCompletedManuallyOnline(entry.cmid, !!entry.completed, siteId); + + result.updated = true; + + await CoreCourseOffline.instance.deleteManualCompletion(entry.cmid, siteId); + } catch (error) { + if (!CoreUtils.instance.isWebServiceError(error)) { + // Couldn't connect to server, reject. + throw error; + } + + // The WebService has thrown an error, this means that the completion cannot be submitted. Delete it. + result.updated = true; + + await CoreCourseOffline.instance.deleteManualCompletion(entry.cmid, siteId); + + // Completion deleted, add a warning. + result.warnings.push(Translate.instance.instant('core.course.warningofflinemanualcompletiondeleted', { + name: entry.coursename || courseId, + error: CoreTextUtils.instance.getErrorMessageFromError(error), + })); + } + })); + + if (result.updated) { + try { + // Update data. + await CoreCourse.instance.invalidateSections(courseId, siteId); + + const currentSite = CoreSites.instance.getCurrentSite(); + + if (currentSite?.isVersionGreaterEqualThan('3.6')) { + await CoreCourse.instance.getSections(courseId, false, true, undefined, siteId); + } else { + await CoreCourse.instance.getActivitiesCompletionStatus(courseId, siteId); + } + } catch { + // Ignore errors. + } + } + + // Sync finished, set sync time. + await this.setSyncTime(String(courseId), siteId); + + // All done, return the data. + return result; + } + +} + +export class CoreCourseSync extends makeSingleton(CoreCourseSyncProvider) {} + +/** + * Result of course sync. + */ +export type CoreCourseSyncResult = { + updated: boolean; + warnings: CoreWSExternalWarning[]; +}; + +/** + * Data passed to AUTO_SYNCED event. + */ +export type CoreCourseAutoSyncData = CoreEventSiteData & { + courseId: number; + warnings: CoreWSExternalWarning[]; +}; diff --git a/src/core/features/courses/components/course-list-item/course-list-item.ts b/src/core/features/courses/components/course-list-item/course-list-item.ts index 5d6361075..0ec9214c0 100644 --- a/src/core/features/courses/components/course-list-item/course-list-item.ts +++ b/src/core/features/courses/components/course-list-item/course-list-item.ts @@ -13,7 +13,8 @@ // limitations under the License. import { Component, Input, OnInit } from '@angular/core'; -import { NavController } from '@ionic/angular'; +import { CoreCourseHelper } from '@features/course/services/course-helper'; +import { CoreNavigator } from '@services/navigator'; import { CoreCourses, CoreCourseSearchedData } from '../../services/courses'; import { CoreCoursesHelper, CoreCourseWithImageAndColor } from '../../services/courses-helper'; @@ -39,11 +40,6 @@ export class CoreCoursesCourseListItemComponent implements OnInit { icons: CoreCoursesEnrolmentIcons[] = []; isEnrolled = false; - constructor( - protected navCtrl: NavController, - ) { - } - /** * Component being initialized. */ @@ -95,13 +91,11 @@ export class CoreCoursesCourseListItemComponent implements OnInit { * @param course The course to open. */ openCourse(): void { - /* if (this.isEnrolled) { + if (this.isEnrolled) { CoreCourseHelper.instance.openCourse(this.course); } else { - this.navCtrl.navigateForward('/main/home/courses/preview', { queryParams: { course: this.course } }); - } */ - // @todo while opencourse function is not completed, open preview page. - this.navCtrl.navigateForward('/main/home/courses/preview', { queryParams: { course: this.course } }); + CoreNavigator.instance.navigate('courses/preview', { params: { course: this.course } }); + } } } diff --git a/src/core/features/courses/pages/categories/categories.ts b/src/core/features/courses/pages/categories/categories.ts index 2926c94f6..0a4de8996 100644 --- a/src/core/features/courses/pages/categories/categories.ts +++ b/src/core/features/courses/pages/categories/categories.ts @@ -13,13 +13,13 @@ // limitations under the License. import { Component, OnInit } from '@angular/core'; -import { IonRefresher, NavController } from '@ionic/angular'; +import { IonRefresher } from '@ionic/angular'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreUtils } from '@services/utils/utils'; import { CoreCategoryData, CoreCourses, CoreCourseSearchedData } from '../../services/courses'; import { Translate } from '@singletons'; -import { ActivatedRoute } from '@angular/router'; +import { CoreNavigator } from '@services/navigator'; /** * Page that displays a list of categories and the courses in the current category if any. @@ -38,10 +38,7 @@ export class CoreCoursesCategoriesPage implements OnInit { protected categoryId = 0; - constructor( - protected navCtrl: NavController, - protected route: ActivatedRoute, - ) { + constructor() { this.title = Translate.instance.instant('core.courses.categories'); } @@ -49,7 +46,7 @@ export class CoreCoursesCategoriesPage implements OnInit { * View loaded. */ ngOnInit(): void { - this.categoryId = parseInt(this.route.snapshot.params['id'], 0) || 0; + this.categoryId = CoreNavigator.instance.getRouteNumberParam('id') || 0; this.fetchCategories().finally(() => { this.categoriesLoaded = true; diff --git a/src/core/features/courses/pages/course-preview/course-preview.ts b/src/core/features/courses/pages/course-preview/course-preview.ts index 2bde270cd..c5893a08d 100644 --- a/src/core/features/courses/pages/course-preview/course-preview.ts +++ b/src/core/features/courses/pages/course-preview/course-preview.ts @@ -13,7 +13,7 @@ // limitations under the License. import { Component, OnDestroy, NgZone, OnInit } from '@angular/core'; -import { ModalController, IonRefresher, NavController } from '@ionic/angular'; +import { ModalController, IonRefresher } from '@ionic/angular'; import { CoreApp } from '@services/app'; import { CoreEventCourseStatusChanged, CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreSites } from '@services/sites'; @@ -31,9 +31,9 @@ import { CoreCourseOptionsDelegate } from '@features/course/services/course-opti import { CoreCourse, CoreCourseProvider } from '@features/course/services/course'; import { CoreCourseHelper, CorePrefetchStatusInfo } from '@features/course/services/course-helper'; import { Translate } from '@singletons'; -import { ActivatedRoute } from '@angular/router'; import { CoreConstants } from '@/core/constants'; import { CoreCoursesSelfEnrolPasswordComponent } from '../../components/self-enrol-password/self-enrol-password'; +import { CoreNavigator } from '@services/navigator'; /** * Page that allows "previewing" a course and enrolling in it if enabled and not enrolled. @@ -78,8 +78,6 @@ export class CoreCoursesCoursePreviewPage implements OnInit, OnDestroy { constructor( protected modalCtrl: ModalController, protected zone: NgZone, - protected route: ActivatedRoute, - protected navCtrl: NavController, ) { this.isMobile = CoreApp.instance.isMobile(); this.downloadCourseEnabled = !CoreCourses.instance.isDownloadCourseDisabledInSite(); @@ -98,12 +96,11 @@ export class CoreCoursesCoursePreviewPage implements OnInit, OnDestroy { * View loaded. */ async ngOnInit(): Promise { - const navParams = this.route.snapshot.queryParams; - this.course = navParams['course']; - this.avoidOpenCourse = !!navParams['avoidOpenCourse']; + this.course = CoreNavigator.instance.getRouteParam('course'); + this.avoidOpenCourse = !!CoreNavigator.instance.getRouteBooleanParam('avoidOpenCourse'); if (!this.course) { - this.navCtrl.back(); + CoreNavigator.instance.back(); return; } diff --git a/src/core/features/courses/pages/my-courses/my-courses.ts b/src/core/features/courses/pages/my-courses/my-courses.ts index 015dc4212..0e95d0a58 100644 --- a/src/core/features/courses/pages/my-courses/my-courses.ts +++ b/src/core/features/courses/pages/my-courses/my-courses.ts @@ -13,7 +13,7 @@ // limitations under the License. import { Component, OnInit, OnDestroy, ViewChild } from '@angular/core'; -import { NavController, IonSearchbar, IonRefresher } from '@ionic/angular'; +import { IonSearchbar, IonRefresher } from '@ionic/angular'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; @@ -26,6 +26,7 @@ import { CoreCoursesHelper, CoreEnrolledCourseDataWithExtraInfoAndOptions } from import { CoreCourseHelper } from '@features/course/services/course-helper'; import { CoreConstants } from '@/core/constants'; import { CoreCourseOptionsDelegate } from '@features/course/services/course-options-delegate'; +import { CoreNavigator } from '@services/navigator'; /** * Page that displays the list of courses the user is enrolled in. @@ -54,9 +55,7 @@ export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy { protected isDestroyed = false; protected courseIds = ''; - constructor( - protected navCtrl: NavController, - ) { + constructor() { // Update list if user enrols in a course. this.myCoursesObserver = CoreEvents.on( CoreCoursesProvider.EVENT_MY_COURSES_UPDATED, @@ -200,7 +199,7 @@ export class CoreCoursesMyCoursesPage implements OnInit, OnDestroy { * Go to search courses. */ openSearch(): void { - this.navCtrl.navigateForward(['/main/home/courses/search']); + CoreNavigator.instance.navigate('courses/search'); } /** diff --git a/src/core/features/courses/services/courses.ts b/src/core/features/courses/services/courses.ts index dc1414c0d..54e64f496 100644 --- a/src/core/features/courses/services/courses.ts +++ b/src/core/features/courses/services/courses.ts @@ -1174,7 +1174,7 @@ export class CoreCourses extends makeSingleton(CoreCoursesProvider) {} export type CoreCoursesMyCoursesUpdatedEventData = { action: string; // Action performed. courseId?: number; // Course ID affected (if any). - course?: any; // Course affected (if any). + course?: CoreCourseAnyCourseData; // Course affected (if any). state?: string; // Only for ACTION_STATE_CHANGED. The state that changed (hidden, favourite). value?: boolean; // The new value for the state changed. }; @@ -1588,3 +1588,16 @@ type CoreCourseSetFavouriteCoursesWSParams = { favourite: boolean; // Favourite status. }[]; }; + +/** + * Any of the possible course data. + */ +export type CoreCourseAnyCourseData = CoreEnrolledCourseData | CoreCourseSearchedData | CoreCourseGetCoursesData; + +/** + * Course data with admin and navigation option availability. + */ +export type CoreCourseAnyCourseDataWithOptions = CoreCourseAnyCourseData & { + navOptions?: CoreCourseUserAdminOrNavOptionIndexed; + admOptions?: CoreCourseUserAdminOrNavOptionIndexed; +}; diff --git a/src/core/features/editor/components/rich-text-editor/core-editor-rich-text-editor.html b/src/core/features/editor/components/rich-text-editor/core-editor-rich-text-editor.html index a409f709c..d2187aefc 100644 --- a/src/core/features/editor/components/rich-text-editor/core-editor-rich-text-editor.html +++ b/src/core/features/editor/components/rich-text-editor/core-editor-rich-text-editor.html @@ -24,85 +24,85 @@ diff --git a/src/core/features/grades/services/handlers/course-option.ts b/src/core/features/grades/services/handlers/course-option.ts index 5f5f050c7..9dcb7cb61 100644 --- a/src/core/features/grades/services/handlers/course-option.ts +++ b/src/core/features/grades/services/handlers/course-option.ts @@ -15,7 +15,7 @@ import { Injectable } from '@angular/core'; import { CoreCourseProvider } from '@features/course/services/course'; import { - CoreCourseAccessData, + CoreCourseAccess, CoreCourseOptionsHandler, CoreCourseOptionsHandlerData, } from '@features/course/services/course-options-delegate'; @@ -68,7 +68,7 @@ export class CoreGradesCourseOptionHandlerService implements CoreCourseOptionsHa */ isEnabledForCourse( courseId: number, - accessData: CoreCourseAccessData, + accessData: CoreCourseAccess, navOptions?: CoreCourseUserAdminOrNavOptionIndexed, ): boolean | Promise { if (accessData && accessData.type == CoreCourseProvider.ACCESS_GUEST) { diff --git a/src/core/features/h5p/components/h5p-player/core-h5p-player.html b/src/core/features/h5p/components/h5p-player/core-h5p-player.html index cb510fd5d..e6ad42431 100644 --- a/src/core/features/h5p/components/h5p-player/core-h5p-player.html +++ b/src/core/features/h5p/components/h5p-player/core-h5p-player.html @@ -1,6 +1,6 @@
- +
diff --git a/src/core/features/login/pages/credentials/credentials.ts b/src/core/features/login/pages/credentials/credentials.ts index 34cb74c81..677ef5638 100644 --- a/src/core/features/login/pages/credentials/credentials.ts +++ b/src/core/features/login/pages/credentials/credentials.ts @@ -13,9 +13,7 @@ // limitations under the License. import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { NavController } from '@ionic/angular'; import { CoreApp } from '@services/app'; import { CoreSites } from '@services/sites'; @@ -62,8 +60,6 @@ export class CoreLoginCredentialsPage implements OnInit, OnDestroy { constructor( protected fb: FormBuilder, - protected route: ActivatedRoute, - protected navCtrl: NavController, ) { const canScanQR = CoreUtils.instance.canScanQR(); @@ -82,17 +78,23 @@ export class CoreLoginCredentialsPage implements OnInit, OnDestroy { * Initialize the component. */ ngOnInit(): void { - this.route.queryParams.subscribe(params => { - this.siteUrl = params['siteUrl']; - this.siteName = params['siteName'] || undefined; - this.logoUrl = !CoreConstants.CONFIG.forceLoginLogo && params['logoUrl'] || undefined; - this.siteConfig = params['siteConfig']; - this.urlToOpen = params['urlToOpen']; + const siteUrl = CoreNavigator.instance.getRouteParam('siteUrl'); + if (!siteUrl) { + CoreDomUtils.instance.showErrorModal('Site URL not supplied.'); + CoreNavigator.instance.back(); - this.credForm = this.fb.group({ - username: [params['username'] || '', Validators.required], - password: ['', Validators.required], - }); + return; + } + + this.siteUrl = siteUrl; + this.siteName = CoreNavigator.instance.getRouteParam('siteName'); + this.logoUrl = !CoreConstants.CONFIG.forceLoginLogo && CoreNavigator.instance.getRouteParam('logoUrl') || undefined; + this.siteConfig = CoreNavigator.instance.getRouteParam('siteConfig'); + this.urlToOpen = CoreNavigator.instance.getRouteParam('urlToOpen'); + + this.credForm = this.fb.group({ + username: [CoreNavigator.instance.getRouteParam('username') || '', Validators.required], + password: ['', Validators.required], }); this.treatSiteConfig(); @@ -251,7 +253,7 @@ export class CoreLoginCredentialsPage implements OnInit, OnDestroy { CoreLoginHelper.instance.treatUserTokenError(siteUrl, error, username, password); if (error.loggedout) { - this.navCtrl.navigateRoot('/login/sites'); + CoreNavigator.instance.navigate('/login/sites', { reset: true }); } else if (error.errorcode == 'forcepasswordchangenotice') { // Reset password field. this.credForm.controls.password.reset(); diff --git a/src/core/features/login/pages/email-signup/email-signup.ts b/src/core/features/login/pages/email-signup/email-signup.ts index ba3d36dae..67cdcf4eb 100644 --- a/src/core/features/login/pages/email-signup/email-signup.ts +++ b/src/core/features/login/pages/email-signup/email-signup.ts @@ -14,8 +14,7 @@ import { Component, ViewChild, ElementRef, OnInit } from '@angular/core'; import { FormBuilder, FormGroup, Validators, FormControl } from '@angular/forms'; -import { ActivatedRoute } from '@angular/router'; -import { NavController, IonContent, IonRefresher } from '@ionic/angular'; +import { IonContent, IonRefresher } from '@ionic/angular'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; @@ -32,6 +31,7 @@ import { AuthEmailSignupSettings, CoreLoginHelper, } from '@features/login/services/login-helper'; +import { CoreNavigator } from '@services/navigator'; /** * Page to signup using email. @@ -80,9 +80,7 @@ export class CoreLoginEmailSignupPage implements OnInit { namefieldsErrors?: Record>; constructor( - protected navCtrl: NavController, protected fb: FormBuilder, - protected route: ActivatedRoute, ) { // Create the ageVerificationForm. this.ageVerificationForm = this.fb.group({ @@ -115,7 +113,15 @@ export class CoreLoginEmailSignupPage implements OnInit { * Component initialized. */ ngOnInit(): void { - this.siteUrl = this.route.snapshot.queryParams['siteUrl']; + const siteUrl = CoreNavigator.instance.getRouteParam('siteUrl'); + if (!siteUrl) { + CoreDomUtils.instance.showErrorModal('Site URL not supplied.'); + CoreNavigator.instance.back(); + + return; + } + + this.siteUrl = siteUrl; // Fetch the data. this.fetchData().finally(() => { @@ -238,7 +244,7 @@ export class CoreLoginEmailSignupPage implements OnInit { { $a: Translate.instance.instant('core.login.auth_email') }, ), ); - this.navCtrl.pop(); + CoreNavigator.instance.back(); return false; } @@ -321,7 +327,7 @@ export class CoreLoginEmailSignupPage implements OnInit { // Show alert and ho back. const message = Translate.instance.instant('core.login.emailconfirmsent', { $a: params.email }); CoreDomUtils.instance.showAlert(Translate.instance.instant('core.success'), message); - this.navCtrl.pop(); + CoreNavigator.instance.back(); } else { if (result.warnings && result.warnings.length) { let error = result.warnings[0].message; diff --git a/src/core/features/login/pages/forgotten-password/forgotten-password.ts b/src/core/features/login/pages/forgotten-password/forgotten-password.ts index d42e63054..b3aa6ac2a 100644 --- a/src/core/features/login/pages/forgotten-password/forgotten-password.ts +++ b/src/core/features/login/pages/forgotten-password/forgotten-password.ts @@ -13,14 +13,13 @@ // limitations under the License. import { Component, ViewChild, ElementRef, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { NavController } from '@ionic/angular'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreLoginHelper } from '@features/login/services/login-helper'; import { Translate, Platform } from '@singletons'; import { CoreWSExternalWarning } from '@services/ws'; +import { CoreNavigator } from '@services/navigator'; /** * Page to recover a forgotten password. @@ -38,9 +37,7 @@ export class CoreLoginForgottenPasswordPage implements OnInit { autoFocus!: boolean; constructor( - protected navCtrl: NavController, protected formBuilder: FormBuilder, - protected route: ActivatedRoute, ) { } @@ -48,13 +45,19 @@ export class CoreLoginForgottenPasswordPage implements OnInit { * Initialize the component. */ ngOnInit(): void { - const params = this.route.snapshot.queryParams; + const siteUrl = CoreNavigator.instance.getRouteParam('siteUrl'); + if (!siteUrl) { + CoreDomUtils.instance.showErrorModal('Site URL not supplied.'); + CoreNavigator.instance.back(); - this.siteUrl = params['siteUrl']; + return; + } + + this.siteUrl = siteUrl; this.autoFocus = Platform.instance.is('tablet'); this.myForm = this.formBuilder.group({ field: ['username', Validators.required], - value: [params['username'] || '', Validators.required], + value: [CoreNavigator.instance.getRouteParam('username') || '', Validators.required], }); } @@ -97,7 +100,7 @@ export class CoreLoginForgottenPasswordPage implements OnInit { CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, true); CoreDomUtils.instance.showAlert(Translate.instance.instant('core.success'), response.notice); - this.navCtrl.pop(); + CoreNavigator.instance.back(); } } catch (error) { CoreDomUtils.instance.showErrorModal(error); diff --git a/src/core/features/login/pages/init/init.ts b/src/core/features/login/pages/init/init.ts index 096dda7a0..98bd7d68a 100644 --- a/src/core/features/login/pages/init/init.ts +++ b/src/core/features/login/pages/init/init.ts @@ -13,7 +13,6 @@ // limitations under the License. import { Component, OnInit } from '@angular/core'; -import { NavController } from '@ionic/angular'; import { CoreApp, CoreRedirectData } from '@services/app'; import { ApplicationInit, SplashScreen } from '@singletons'; @@ -34,7 +33,6 @@ export class CoreLoginInitPage implements OnInit { // @todo this page should be removed in favor of native splash // or a splash component rendered in the root app component - constructor(protected navCtrl: NavController) {} /** * Initialize the component. @@ -121,7 +119,7 @@ export class CoreLoginInitPage implements OnInit { return; } - await this.navCtrl.navigateRoot('/login/sites'); + await CoreNavigator.instance.navigate('/login/sites', { reset: true }); } } diff --git a/src/core/features/login/pages/reconnect/reconnect.ts b/src/core/features/login/pages/reconnect/reconnect.ts index 24204a371..047e1cd69 100644 --- a/src/core/features/login/pages/reconnect/reconnect.ts +++ b/src/core/features/login/pages/reconnect/reconnect.ts @@ -13,9 +13,8 @@ // limitations under the License. import { Component, OnInit, OnDestroy, ViewChild, ElementRef } from '@angular/core'; -import { ActivatedRoute, Params } from '@angular/router'; +import { Params } from '@angular/router'; import { FormBuilder, FormGroup, Validators } from '@angular/forms'; -import { NavController } from '@ionic/angular'; import { CoreApp } from '@services/app'; import { CoreSites } from '@services/sites'; @@ -60,9 +59,7 @@ export class CoreLoginReconnectPage implements OnInit, OnDestroy { protected eventThrown = false; constructor( - protected navCtrl: NavController, protected fb: FormBuilder, - protected route: ActivatedRoute, ) { const currentSite = CoreSites.instance.getCurrentSite(); @@ -77,11 +74,14 @@ export class CoreLoginReconnectPage implements OnInit, OnDestroy { * Initialize the component. */ async ngOnInit(): Promise { - const params = this.route.snapshot.queryParams; + const siteId = CoreNavigator.instance.getRouteParam('siteId'); + if (!siteId) { + return this.cancel(); + } - this.siteId = params['siteId']; - this.page = params['pageName']; - this.pageParams = params['pageParams']; + this.siteUrl = siteId; + this.page = CoreNavigator.instance.getRouteParam('pageName'); + this.pageParams = CoreNavigator.instance.getRouteParam('pageParams'); try { const site = await CoreSites.instance.getSite(this.siteId); diff --git a/src/core/features/login/pages/site-policy/site-policy.ts b/src/core/features/login/pages/site-policy/site-policy.ts index 15af5cce8..05faec926 100644 --- a/src/core/features/login/pages/site-policy/site-policy.ts +++ b/src/core/features/login/pages/site-policy/site-policy.ts @@ -13,8 +13,6 @@ // limitations under the License. import { Component, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { NavController } from '@ionic/angular'; import { CoreSites } from '@services/sites'; import { CoreDomUtils } from '@services/utils/dom'; @@ -39,19 +37,12 @@ export class CoreLoginSitePolicyPage implements OnInit { protected siteId?: string; protected currentSite?: CoreSite; - constructor( - protected navCtrl: NavController, - protected route: ActivatedRoute, - ) { - } - /** * Component initialized. */ ngOnInit(): void { - const params = this.route.snapshot.queryParams; - this.siteId = params['siteId']; + this.siteId = CoreNavigator.instance.getRouteParam('siteId'); this.currentSite = CoreSites.instance.getCurrentSite(); if (!this.currentSite) { @@ -111,7 +102,7 @@ export class CoreLoginSitePolicyPage implements OnInit { async cancel(): Promise { await CoreUtils.instance.ignoreErrors(CoreSites.instance.logout()); - await this.navCtrl.navigateRoot('/login/sites'); + await CoreNavigator.instance.navigate('/login/sites', { reset: true }); } /** diff --git a/src/core/features/login/pages/site/site.ts b/src/core/features/login/pages/site/site.ts index 326b6f648..f509eb178 100644 --- a/src/core/features/login/pages/site/site.ts +++ b/src/core/features/login/pages/site/site.ts @@ -13,9 +13,7 @@ // limitations under the License. import { Component, OnInit, ViewChild, ElementRef } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; import { FormBuilder, FormGroup, ValidatorFn, AbstractControl, ValidationErrors } from '@angular/forms'; -import { NavController } from '@ionic/angular'; import { CoreApp } from '@services/app'; import { CoreConfig } from '@services/config'; @@ -60,9 +58,7 @@ export class CoreLoginSitePage implements OnInit { siteFinderSettings: SiteFinderSettings; constructor( - protected route: ActivatedRoute, protected formBuilder: FormBuilder, - protected navCtrl: NavController, ) { let url = ''; @@ -117,9 +113,7 @@ export class CoreLoginSitePage implements OnInit { * Initialize the component. */ ngOnInit(): void { - this.route.queryParams.subscribe(params => { - this.showKeyboard = !!params['showKeyboard']; - }); + this.showKeyboard = !!CoreNavigator.instance.getRouteBooleanParam('showKeyboard'); } /** @@ -336,7 +330,7 @@ export class CoreLoginSitePage implements OnInit { CoreLoginHelper.instance.treatUserTokenError(siteData.url, error, siteData.username, siteData.password); if (error.loggedout) { - this.navCtrl.navigateRoot('/login/sites'); + CoreNavigator.instance.navigate('/login/sites', { reset: true }); } } finally { modal.dismiss(); @@ -375,8 +369,8 @@ export class CoreLoginSitePage implements OnInit { pageParams['logoUrl'] = foundSite.imageurl; } - this.navCtrl.navigateForward('/login/credentials', { - queryParams: pageParams, + CoreNavigator.instance.navigate('/login/credentials', { + params: pageParams, }); } } diff --git a/src/core/features/login/services/login-helper.ts b/src/core/features/login/services/login-helper.ts index dade42d2e..5bcf17d22 100644 --- a/src/core/features/login/services/login-helper.ts +++ b/src/core/features/login/services/login-helper.ts @@ -33,7 +33,6 @@ import { CoreWSError } from '@classes/errors/wserror'; import { makeSingleton, Translate } from '@singletons'; import { CoreLogger } from '@singletons/logger'; import { CoreUrl } from '@singletons/url'; -import { CoreObject } from '@singletons/object'; import { CoreNavigator } from '@services/navigator'; /** @@ -57,9 +56,7 @@ export class CoreLoginHelperProvider { protected isOpeningReconnect = false; waitingForBrowser = false; - constructor( - protected navCtrl: NavController, - ) { + constructor() { this.logger = CoreLogger.getInstance('CoreLoginHelper'); } @@ -178,8 +175,8 @@ export class CoreLoginHelperProvider { const canReset = await this.canRequestPasswordReset(siteUrl); if (canReset) { - await this.navCtrl.navigateForward(['/login/forgottenpassword'], { - queryParams: { + await CoreNavigator.instance.navigate('/login/forgottenpassword', { + params: { siteUrl, username, }, @@ -426,15 +423,7 @@ export class CoreLoginHelperProvider { params = { showKeyboard: showKeyboard }; } - if (setRoot) { - await this.navCtrl.navigateRoot(pageRoute, { - queryParams: params, - }); - } else { - await this.navCtrl.navigateForward(pageRoute, { - queryParams: params, - }); - } + await CoreNavigator.instance.navigate(pageRoute, { params, reset: setRoot }); } /** @@ -791,7 +780,7 @@ export class CoreLoginHelperProvider { return; } - await this.navCtrl.navigateRoot('/login/changepassword', { queryParams: { siteId } }); + await CoreNavigator.instance.navigate('/login/changepassword', { params: { siteId }, reset: true }); } /** @@ -982,12 +971,13 @@ export class CoreLoginHelperProvider { this.isOpeningReconnect = true; - await CoreUtils.instance.ignoreErrors(this.navCtrl.navigateRoot('/login/reconnect', { - queryParams: CoreObject.withoutEmpty({ + await CoreUtils.instance.ignoreErrors(CoreNavigator.instance.navigate('/login/reconnect', { + params: { siteId, pageName: data.pageName, pageParams: data.params, - }), + }, + reset: true, })); this.isOpeningReconnect = false; @@ -1148,7 +1138,7 @@ export class CoreLoginHelperProvider { return; } - this.navCtrl.navigateRoot('/login/sitepolicy', { queryParams: { siteId: siteId } }); + CoreNavigator.instance.navigate('/login/sitepolicy', { params: { siteId }, reset: true }); } /** diff --git a/src/core/features/login/tests/pages/init.test.ts b/src/core/features/login/tests/pages/init.test.ts index fd135d94c..8124299a8 100644 --- a/src/core/features/login/tests/pages/init.test.ts +++ b/src/core/features/login/tests/pages/init.test.ts @@ -12,19 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { NavController } from '@ionic/angular'; - import { CoreApp } from '@services/app'; import { CoreLoginInitPage } from '@features/login/pages/init/init'; import { CoreSites } from '@services/sites'; import { ApplicationInit, SplashScreen } from '@singletons'; -import { mock, mockSingleton, renderComponent, RenderConfig } from '@/testing/utils'; +import { mockSingleton, renderComponent } from '@/testing/utils'; +import { CoreNavigator, CoreNavigatorService } from '@services/navigator'; describe('CoreLoginInitPage', () => { - let navController: NavController; - let config: Partial; + let navigator: CoreNavigatorService; beforeEach(() => { mockSingleton(CoreApp, { getRedirect: () => ({}) }); @@ -32,28 +30,23 @@ describe('CoreLoginInitPage', () => { mockSingleton(CoreSites, { isLoggedIn: () => false }); mockSingleton(SplashScreen, ['hide']); - navController = mock(['navigateRoot']); - config = { - providers: [ - { provide: NavController, useValue: navController }, - ], - }; + navigator = mockSingleton(CoreNavigator, ['navigate']); }); it('should render', async () => { - const fixture = await renderComponent(CoreLoginInitPage, config); + const fixture = await renderComponent(CoreLoginInitPage, {}); expect(fixture.debugElement.componentInstance).toBeTruthy(); expect(fixture.nativeElement.querySelector('ion-spinner')).toBeTruthy(); }); it('navigates to sites page after loading', async () => { - const fixture = await renderComponent(CoreLoginInitPage, config); + const fixture = await renderComponent(CoreLoginInitPage, {}); fixture.componentInstance.ngOnInit(); await ApplicationInit.instance.donePromise; - expect(navController.navigateRoot).toHaveBeenCalledWith('/login/sites'); + expect(navigator.navigate).toHaveBeenCalledWith('/login/sites', { reset: true }); }); }); diff --git a/src/core/features/mainmenu/pages/home/home.ts b/src/core/features/mainmenu/pages/home/home.ts index ab24ce8aa..005951458 100644 --- a/src/core/features/mainmenu/pages/home/home.ts +++ b/src/core/features/mainmenu/pages/home/home.ts @@ -17,7 +17,7 @@ import { Subscription } from 'rxjs'; import { CoreSites } from '@services/sites'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; -import { CoreTabsComponent } from '@components/tabs/tabs'; +import { CoreTab, CoreTabsComponent } from '@components/tabs/tabs'; import { CoreMainMenuHomeDelegate, CoreMainMenuHomeHandlerToDisplay } from '../../services/home-delegate'; /** @@ -33,7 +33,7 @@ export class CoreMainMenuHomePage implements OnInit { @ViewChild(CoreTabsComponent) tabsComponent?: CoreTabsComponent; siteName!: string; - tabs: CoreMainMenuHomeHandlerToDisplay[] = []; + tabs: CoreTab[] = []; loaded = false; selectedTab?: number; @@ -68,9 +68,10 @@ export class CoreMainMenuHomePage implements OnInit { const tab = this.tabs.find((tab) => tab.title == handler.title); return tab || handler; - }) + }); + // Sort them by priority so new handlers are in the right position. - .sort((a, b) => (b.priority || 0) - (a.priority || 0)); + newTabs.sort((a, b) => (b.priority || 0) - (a.priority || 0)); if (typeof this.selectedTab == 'undefined' && newTabs.length > 0) { let maxPriority = 0; diff --git a/src/core/features/mainmenu/pages/menu/menu.ts b/src/core/features/mainmenu/pages/menu/menu.ts index d0298183f..66a48045f 100644 --- a/src/core/features/mainmenu/pages/menu/menu.ts +++ b/src/core/features/mainmenu/pages/menu/menu.ts @@ -14,7 +14,7 @@ import { Component, OnInit, OnDestroy, ViewChild, ChangeDetectorRef } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { NavController, IonTabs } from '@ionic/angular'; +import { IonTabs } from '@ionic/angular'; import { Subscription } from 'rxjs'; import { CoreApp } from '@services/app'; @@ -25,7 +25,7 @@ import { CoreMainMenu, CoreMainMenuProvider } from '../../services/mainmenu'; import { CoreMainMenuDelegate, CoreMainMenuHandlerToDisplay } from '../../services/mainmenu-delegate'; import { CoreDomUtils } from '@services/utils/dom'; import { Translate } from '@singletons'; -import { CoreRedirectPayload } from '@services/navigator'; +import { CoreNavigator, CoreRedirectPayload } from '@services/navigator'; /** * Page that displays the main menu of the app. @@ -55,7 +55,6 @@ export class CoreMainMenuPage implements OnInit, OnDestroy { constructor( protected route: ActivatedRoute, - protected navCtrl: NavController, protected changeDetector: ChangeDetectorRef, protected router: Router, ) {} @@ -66,7 +65,7 @@ export class CoreMainMenuPage implements OnInit, OnDestroy { ngOnInit(): void { // @TODO this should be handled by route guards and can be removed if (!CoreSites.instance.isLoggedIn()) { - this.navCtrl.navigateRoot('/login/init'); + CoreNavigator.instance.navigate('/login/init', { reset: true }); return; } @@ -191,8 +190,8 @@ export class CoreMainMenuPage implements OnInit, OnDestroy { if (i >= 0) { // Tab found. Open it with the params. - this.navCtrl.navigateForward(data.redirectPath, { - queryParams: data.redirectParams, + CoreNavigator.instance.navigate(data.redirectPath, { + params: data.redirectParams, animated: false, }); } else { diff --git a/src/core/features/settings/pages/about/about.ts b/src/core/features/settings/pages/about/about.ts index ca4546ebf..9bf7daa68 100644 --- a/src/core/features/settings/pages/about/about.ts +++ b/src/core/features/settings/pages/about/about.ts @@ -13,10 +13,10 @@ // limitations under the License. import { Component } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; import { CoreConstants } from '@/core/constants'; import { CoreSites } from '@services/sites'; +import { CoreNavigator } from '@services/navigator'; /** * App settings about menu page. @@ -31,10 +31,7 @@ export class CoreSettingsAboutPage { versionName: string; privacyPolicy: string; - constructor( - protected router: Router, - protected route: ActivatedRoute, - ) { + constructor() { const currentSite = CoreSites.instance.getCurrentSite(); this.appName = CoreConstants.CONFIG.appname; @@ -53,7 +50,7 @@ export class CoreSettingsAboutPage { openPage(page: string): void { // const navCtrl = this.svComponent ? this.svComponent.getMasterNav() : this.navCtrl; // navCtrl.push(page); - this.router.navigate([page], { relativeTo: this.route }); + CoreNavigator.instance.navigate(page); } } diff --git a/src/core/features/settings/pages/site/site.ts b/src/core/features/settings/pages/site/site.ts index 9d3402e4b..c6be86921 100644 --- a/src/core/features/settings/pages/site/site.ts +++ b/src/core/features/settings/pages/site/site.ts @@ -13,7 +13,7 @@ // limitations under the License. import { Component, OnDestroy, OnInit } from '@angular/core'; -import { ActivatedRoute, Params, Router } from '@angular/router'; +import { Params } from '@angular/router'; import { IonRefresher } from '@ionic/angular'; import { CoreSettingsDelegate, CoreSettingsHandlerData } from '../../services/settings-delegate'; @@ -57,16 +57,11 @@ export class CoreSitePreferencesPage implements OnInit, OnDestroy { protected sitesObserver: CoreEventObserver; protected isDestroyed = false; - constructor( - protected route: ActivatedRoute, - protected router: Router, // Will be removed when splitview is implemented - ) { + constructor() { this.isIOS = CoreApp.instance.isIOS(); this.siteId = CoreSites.instance.getCurrentSiteId(); - this.selectedPage = route.snapshot.paramMap.get('page') || undefined; - this.sitesObserver = CoreEvents.on(CoreEvents.SITE_UPDATED, (data: CoreEventSiteUpdatedData) => { if (data.siteId == this.siteId) { this.refreshData(); @@ -78,6 +73,8 @@ export class CoreSitePreferencesPage implements OnInit, OnDestroy { * View loaded. */ ngOnInit(): void { + // @todo this.selectedPage = route.snapshot.paramMap.get('page') || undefined; + this.fetchData().finally(() => { this.loaded = true; diff --git a/src/core/features/sitehome/pages/index/index.ts b/src/core/features/sitehome/pages/index/index.ts index 905c2a6f8..b70d4b024 100644 --- a/src/core/features/sitehome/pages/index/index.ts +++ b/src/core/features/sitehome/pages/index/index.ts @@ -13,18 +13,21 @@ // limitations under the License. import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { IonRefresher, NavController } from '@ionic/angular'; +import { IonRefresher } from '@ionic/angular'; +import { Params } from '@angular/router'; import { CoreSite, CoreSiteConfig } from '@classes/site'; -import { CoreCourse, CoreCourseModuleBasicInfo, CoreCourseSection } from '@features/course/services/course'; +import { CoreCourse, CoreCourseModuleBasicInfo, CoreCourseWSSection } from '@features/course/services/course'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreSites } from '@services/sites'; import { CoreSiteHome } from '@features/sitehome/services/sitehome'; import { CoreCourses, CoreCoursesProvider } from '@features//courses/services/courses'; import { CoreEventObserver, CoreEvents } from '@singletons/events'; -import { CoreCourseHelper } from '@features/course/services/course-helper'; +import { CoreCourseHelper, CoreCourseModule } from '@features/course/services/course-helper'; import { CoreBlockCourseBlocksComponent } from '@features/block/components/course-blocks/course-blocks'; +import { CoreCourseModuleDelegate, CoreCourseModuleHandlerData } from '@features/course/services/module-delegate'; +import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; +import { CoreNavigator } from '@services/navigator'; /** * Page that displays site home index. @@ -38,7 +41,7 @@ export class CoreSiteHomeIndexPage implements OnInit, OnDestroy { @ViewChild(CoreBlockCourseBlocksComponent) courseBlocksComponent?: CoreBlockCourseBlocksComponent; dataLoaded = false; - section?: CoreCourseSection & { + section?: CoreCourseWSSection & { hasContent?: boolean; }; @@ -51,22 +54,14 @@ export class CoreSiteHomeIndexPage implements OnInit, OnDestroy { downloadCourseEnabled = false; downloadCoursesEnabled = false; downloadEnabledIcon = 'far-square'; - newsForumModule?: CoreCourseModuleBasicInfo; + newsForumModule?: NewsForum; protected updateSiteObserver?: CoreEventObserver; - constructor( - protected route: ActivatedRoute, - protected navCtrl: NavController, - // @todo private prefetchDelegate: CoreCourseModulePrefetchDelegate, - ) {} - /** * Page being initialized. */ ngOnInit(): void { - const navParams = this.route.snapshot.queryParams; - this.searchEnabled = !CoreCourses.instance.isSearchCoursesDisabledInSite(); this.downloadCourseEnabled = !CoreCourses.instance.isDownloadCourseDisabledInSite(); this.downloadCoursesEnabled = !CoreCourses.instance.isDownloadCoursesDisabledInSite(); @@ -83,10 +78,10 @@ export class CoreSiteHomeIndexPage implements OnInit, OnDestroy { this.currentSite = CoreSites.instance.getCurrentSite()!; this.siteHomeId = CoreSites.instance.getCurrentSiteHomeId(); - const module = navParams['module']; + const module = CoreNavigator.instance.getRouteParam('module'); if (module) { - // @todo const modParams = navParams.get('modParams'); - // CoreCourseHelper.instance.openModule(module, this.siteHomeId, undefined, modParams); + const modParams = CoreNavigator.instance.getRouteParam('modParams'); + CoreCourseHelper.instance.openModule(module, this.siteHomeId, undefined, modParams); } this.loadContent().finally(() => { @@ -112,13 +107,13 @@ export class CoreSiteHomeIndexPage implements OnInit, OnDestroy { try { const forum = await CoreSiteHome.instance.getNewsForum(); this.newsForumModule = await CoreCourse.instance.getModuleBasicInfo(forum.cmid); - /* @todo this.newsForumModule.handlerData = this.moduleDelegate.getModuleDataFor( + this.newsForumModule.handlerData = CoreCourseModuleDelegate.instance.getModuleDataFor( this.newsForumModule.modname, this.newsForumModule, this.siteHomeId, this.newsForumModule.section, true, - );*/ + ); } catch { // Ignore errors. } @@ -130,15 +125,14 @@ export class CoreSiteHomeIndexPage implements OnInit, OnDestroy { // Check "Include a topic section" setting from numsections. this.section = config.numsections ? sections.find((section) => section.section == 1) : undefined; if (this.section) { - this.section.hasContent = false; - this.section.hasContent = CoreCourseHelper.instance.sectionHasContent(this.section); - this.hasContent = CoreCourseHelper.instance.addHandlerDataForModules( + const result = CoreCourseHelper.instance.addHandlerDataForModules( [this.section], this.siteHomeId, undefined, undefined, true, - ) || this.hasContent; + ); + this.hasContent = result.hasContent || this.hasContent; } // Add log in Moodle. @@ -174,7 +168,7 @@ export class CoreSiteHomeIndexPage implements OnInit, OnDestroy { if (this.section && this.section.modules) { // Invalidate modules prefetch data. - // @todo promises.push(this.prefetchDelegate.invalidateModules(this.section.modules, this.siteHomeId)); + promises.push(CoreCourseModulePrefetchDelegate.instance.invalidateModules(this.section.modules, this.siteHomeId)); } if (this.courseBlocksComponent) { @@ -224,7 +218,7 @@ export class CoreSiteHomeIndexPage implements OnInit, OnDestroy { * Go to search courses. */ openSearch(): void { - this.navCtrl.navigateForward(['/main/home/courses/search']); + CoreNavigator.instance.navigateToSitePath('courses/search'); } /** @@ -235,3 +229,7 @@ export class CoreSiteHomeIndexPage implements OnInit, OnDestroy { } } + +type NewsForum = CoreCourseModuleBasicInfo & { + handlerData?: CoreCourseModuleHandlerData; +}; diff --git a/src/core/features/sitehome/services/sitehome.ts b/src/core/features/sitehome/services/sitehome.ts index 668891af7..e6a852aae 100644 --- a/src/core/features/sitehome/services/sitehome.ts +++ b/src/core/features/sitehome/services/sitehome.ts @@ -17,7 +17,7 @@ import { Injectable } from '@angular/core'; import { CoreSites } from '@services/sites'; import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; import { makeSingleton } from '@singletons'; -import { CoreCourse, CoreCourseSection } from '../../course/services/course'; +import { CoreCourse } from '../../course/services/course'; import { CoreCourses } from '../../courses/services/courses'; import { AddonModForum, AddonModForumData } from '@/addons/mod/forum/services/forum'; @@ -90,8 +90,7 @@ export class CoreSiteHomeProvider { const preSets: CoreSiteWSPreSets = { emergencyCache: false }; try { - const sections: CoreCourseSection[] = - await CoreCourse.instance.getSections(siteHomeId, false, true, preSets, site.id); + const sections = await CoreCourse.instance.getSections(siteHomeId, false, true, preSets, site.id); if (!sections || !sections.length) { throw Error('No sections found'); diff --git a/src/core/features/tag/components/components.module.ts b/src/core/features/tag/components/components.module.ts new file mode 100644 index 000000000..595a5d3e3 --- /dev/null +++ b/src/core/features/tag/components/components.module.ts @@ -0,0 +1,39 @@ +// (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 { NgModule } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreTagFeedComponent } from './feed/feed'; +import { CoreSharedModule } from '@/core/shared.module'; + +@NgModule({ + declarations: [ + CoreTagFeedComponent, + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreSharedModule, + ], + providers: [ + ], + exports: [ + CoreTagFeedComponent, + ], +}) +export class CoreTagComponentsModule {} diff --git a/src/core/features/tag/components/feed/core-tag-feed.html b/src/core/features/tag/components/feed/core-tag-feed.html new file mode 100644 index 000000000..322459677 --- /dev/null +++ b/src/core/features/tag/components/feed/core-tag-feed.html @@ -0,0 +1,12 @@ + + + + + + +

{{ item.heading }}

+

{{ text }}

+
+
\ No newline at end of file diff --git a/src/core/features/tag/components/feed/feed.ts b/src/core/features/tag/components/feed/feed.ts new file mode 100644 index 000000000..8d69e3af3 --- /dev/null +++ b/src/core/features/tag/components/feed/feed.ts @@ -0,0 +1,30 @@ +// (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 { Component, Input } from '@angular/core'; + +import { CoreTagFeedElement } from '@features/tag/services/tag-helper'; + +/** + * Component to render a tag area that uses the "core_tag/tagfeed" web template. + */ +@Component({ + selector: 'core-tag-feed', + templateUrl: 'core-tag-feed.html', +}) +export class CoreTagFeedComponent { + + @Input() items?: CoreTagFeedElement[]; // Area items to render. + +} diff --git a/src/core/features/tag/pages/index-area/index-area.page.ts b/src/core/features/tag/pages/index-area/index-area.page.ts index 46ea0ed39..085220850 100644 --- a/src/core/features/tag/pages/index-area/index-area.page.ts +++ b/src/core/features/tag/pages/index-area/index-area.page.ts @@ -16,11 +16,9 @@ import { Component, OnInit, Type } from '@angular/core'; import { IonInfiniteScroll, IonRefresher } from '@ionic/angular'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreTag } from '@features/tag/services/tag'; -import { CoreTagFeedElement } from '../../services/tag-helper'; -import { ActivatedRoute } from '@angular/router'; import { CoreTagAreaDelegate } from '../../services/tag-area-delegate'; import { Translate } from '@singletons'; -import { CoreUtils } from '@services/utils/utils'; +import { CoreNavigator } from '@services/navigator'; /** * Page that displays the tag index area. @@ -45,38 +43,31 @@ export class CoreTagIndexAreaPage implements OnInit { loaded = false; componentName?: string; itemType?: string; - items: CoreTagFeedElement[] = []; + items: unknown[] = []; nextPage = 0; canLoadMore = false; areaComponent?: Type; loadMoreError = false; - constructor( - protected route: ActivatedRoute, - ) { } - /** * View loaded. */ async ngOnInit(): Promise { + this.tagId = CoreNavigator.instance.getRouteNumberParam('tagId') || this.tagId; + this.tagName = CoreNavigator.instance.getRouteParam('tagName') || this.tagName; + this.collectionId = CoreNavigator.instance.getRouteNumberParam('collectionId') || this.collectionId; + this.areaId = CoreNavigator.instance.getRouteNumberParam('areaId') || this.areaId; + this.fromContextId = CoreNavigator.instance.getRouteNumberParam('fromContextId') || this.fromContextId; + this.contextId = CoreNavigator.instance.getRouteNumberParam('contextId') || this.contextId; + this.recursive = CoreNavigator.instance.getRouteBooleanParam('recursive') ?? true; - const navParams = this.route.snapshot.queryParams; - - this.tagId = navParams['tagId'] ? parseInt(navParams['tagId'], 10) : this.tagId; - this.tagName = navParams['tagName'] || this.tagName; - this.collectionId = navParams['collectionId'] ? parseInt(navParams['collectionId'], 10) : this.collectionId; - this.areaId = navParams['areaId'] ? parseInt(navParams['areaId']!, 10) : this.areaId; - this.fromContextId = parseInt(navParams['fromContextId'], 10) || this.fromContextId; - this.contextId = navParams['contextId'] ? parseInt(navParams['contextId'], 10) : this.contextId; - this.recursive = typeof navParams['recursive'] == 'undefined'? true : navParams['recursive']; - - this.areaNameKey = navParams['areaNameKey']; + this.areaNameKey = CoreNavigator.instance.getRouteParam('areaNameKey') || ''; // Pass the the following parameters to avoid fetching the first page. - this.componentName = navParams['componentName']; - this.itemType = navParams['itemType']; - this.items = []; // @todo navParams['items'] || []; - this.nextPage = typeof navParams['nextPage'] != 'undefined' ? parseInt(navParams['nextPage'], 10) : 0; - this.canLoadMore = CoreUtils.instance.isTrueOrOne(navParams['canLoadMore']); + this.componentName = CoreNavigator.instance.getRouteParam('componentName'); + this.itemType = CoreNavigator.instance.getRouteParam('itemType'); + this.items = CoreNavigator.instance.getRouteParam('items') || []; + this.nextPage = CoreNavigator.instance.getRouteNumberParam('nextPage') || 0; + this.canLoadMore = CoreNavigator.instance.getRouteBooleanParam('canLoadMore') || false; try { if (!this.componentName || !this.itemType || !this.items.length || this.nextPage == 0) { diff --git a/src/core/features/tag/pages/index/index.page.ts b/src/core/features/tag/pages/index/index.page.ts index 3dc89b254..5a6f3d8fc 100644 --- a/src/core/features/tag/pages/index/index.page.ts +++ b/src/core/features/tag/pages/index/index.page.ts @@ -18,8 +18,7 @@ import { CoreDomUtils } from '@services/utils/dom'; // import { CoreSplitViewComponent } from '@components/split-view/split-view'; import { CoreTag } from '@features/tag/services/tag'; import { CoreTagAreaDelegate } from '@features/tag/services/tag-area-delegate'; -import { ActivatedRoute, Router } from '@angular/router'; -import { CoreTagFeedElement } from '../../services/tag-helper'; +import { CoreNavigator } from '@services/navigator'; /** * Page that displays the tag index. @@ -45,24 +44,17 @@ export class CoreTagIndexPage implements OnInit { areas: (CoreTagAreaDisplay | null)[] = []; - constructor( - protected route: ActivatedRoute, - protected router: Router, - ) { } - /** * View loaded. */ async ngOnInit(): Promise { - const navParams = this.route.snapshot.queryParams; - - this.tagId = navParams['tagId'] ? parseInt(navParams['tagId'], 10) : this.tagId; - this.tagName = navParams['tagName'] || this.tagName; - this.collectionId = navParams['collectionId'] ? parseInt(navParams['collectionId'], 10) : this.collectionId; - this.areaId = navParams['areaId'] ? parseInt(navParams['areaId']!, 10) : this.areaId; - this.fromContextId = parseInt(navParams['fromContextId'], 10) || this.fromContextId; - this.contextId = navParams['contextId'] ? parseInt(navParams['contextId'], 10) : this.contextId; - this.recursive = typeof navParams['recursive'] == 'undefined'? true : navParams['recursive']; + this.tagId = CoreNavigator.instance.getRouteNumberParam('tagId') || this.tagId; + this.tagName = CoreNavigator.instance.getRouteParam('tagName') || this.tagName; + this.collectionId = CoreNavigator.instance.getRouteNumberParam('collectionId') || this.collectionId; + this.areaId = CoreNavigator.instance.getRouteNumberParam('areaId') || this.areaId; + this.fromContextId = CoreNavigator.instance.getRouteNumberParam('fromContextId') || this.fromContextId; + this.contextId = CoreNavigator.instance.getRouteNumberParam('contextId') || this.contextId; + this.recursive = CoreNavigator.instance.getRouteBooleanParam('recursive') ?? true; try { await this.fetchData(); @@ -169,11 +161,7 @@ export class CoreTagIndexPage implements OnInit { nextPage: 1, }; // this.splitviewCtrl.push('index-area', params); - this.router.navigate(['../index-area'], { - queryParams: params, - relativeTo: this.route, - }); - + CoreNavigator.instance.navigate('../index-area', { params }); } } @@ -183,7 +171,7 @@ export type CoreTagAreaDisplay = { componentName: string; itemType: string; nameKey: string; - items: CoreTagFeedElement[]; + items: unknown[]; canLoadMore: boolean; badge: string; }; diff --git a/src/core/features/tag/pages/search/search.page.ts b/src/core/features/tag/pages/search/search.page.ts index ee1b80aac..fd6459e69 100644 --- a/src/core/features/tag/pages/search/search.page.ts +++ b/src/core/features/tag/pages/search/search.page.ts @@ -13,8 +13,7 @@ // limitations under the License. import { Component, OnInit } from '@angular/core'; -import { IonRefresher, NavController } from '@ionic/angular'; -import { ActivatedRoute } from '@angular/router'; +import { IonRefresher } from '@ionic/angular'; import { CoreApp } from '@services/app'; import { CoreDomUtils } from '@services/utils/dom'; @@ -23,6 +22,7 @@ import { CoreTextUtils } from '@services/utils/text'; import { CoreTagCloud, CoreTagCollection, CoreTagCloudTag, CoreTag } from '@features/tag/services/tag'; import { Translate } from '@singletons'; import { CoreContentLinksHelper } from '@features/contentlinks/services/contentlinks-helper'; +import { CoreNavigator } from '@services/navigator'; /** * Page that displays most used tags and allows searching. @@ -41,21 +41,12 @@ export class CoreTagSearchPage implements OnInit { loaded = false; searching = false; - constructor( - protected navCtrl: NavController, - protected route: ActivatedRoute, - ) { - - } - /** * View loaded. */ ngOnInit(): void { - // @todo: Check params work. - this.collectionId = this.route.snapshot.queryParamMap.has('collectionId') ? - parseInt(this.route.snapshot.queryParamMap.get('collectionId')!, 10) : 0; - this.query = this.route.snapshot.queryParamMap.get('query') || ''; + this.collectionId = CoreNavigator.instance.getRouteNumberParam('collectionId') || 0; + this.query = CoreNavigator.instance.getRouteParam('query') || ''; this.fetchData().finally(() => { this.loaded = true; diff --git a/src/core/features/tag/services/tag-area-delegate.ts b/src/core/features/tag/services/tag-area-delegate.ts index a0daaf087..fbe1f14c3 100644 --- a/src/core/features/tag/services/tag-area-delegate.ts +++ b/src/core/features/tag/services/tag-area-delegate.ts @@ -15,7 +15,6 @@ import { Injectable, Type } from '@angular/core'; import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; import { makeSingleton } from '@singletons'; -import { CoreTagFeedElement } from './tag-helper'; /** * Interface that all tag area handlers must implement. @@ -32,7 +31,7 @@ export interface CoreTagAreaHandler extends CoreDelegateHandler { * @param content Rendered content. * @return Area items (or promise resolved with the items). */ - parseContent(content: string): CoreTagFeedElement[] | Promise; + parseContent(content: string): unknown[] | Promise; /** * Get the component to use to display items. @@ -74,7 +73,7 @@ export class CoreTagAreaDelegateService extends CoreDelegate * @param content Rendered content. * @return Promise resolved with the area items, or undefined if not found. */ - async parseContent(component: string, itemType: string, content: string): Promise { + async parseContent(component: string, itemType: string, content: string): Promise { const type = component + '/' + itemType; return await this.executeFunctionOnEnabled(type, 'parseContent', [content]); diff --git a/src/core/features/tag/tag.module.ts b/src/core/features/tag/tag.module.ts index 45db517af..706f0a26c 100644 --- a/src/core/features/tag/tag.module.ts +++ b/src/core/features/tag/tag.module.ts @@ -20,6 +20,7 @@ import { CoreContentLinksDelegate } from '@features/contentlinks/services/conten import { CoreTagMainMenuHandler, CoreTagMainMenuHandlerService } from './services/handlers/mainmenu'; import { CoreTagIndexLinkHandler } from './services/handlers/index-link'; import { CoreTagSearchLinkHandler } from './services/handlers/search-link'; +import { CoreTagComponentsModule } from './components/components.module'; const routes: Routes = [ { @@ -31,6 +32,7 @@ const routes: Routes = [ @NgModule({ imports: [ CoreMainMenuRoutingModule.forChild({ children: routes }), + CoreTagComponentsModule, ], exports: [CoreMainMenuRoutingModule], providers: [ diff --git a/src/core/features/user/pages/about/about.page.ts b/src/core/features/user/pages/about/about.page.ts index 549deaf40..aab8c18cb 100644 --- a/src/core/features/user/pages/about/about.page.ts +++ b/src/core/features/user/pages/about/about.page.ts @@ -13,7 +13,6 @@ // limitations under the License. import { Component, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; import { SafeUrl } from '@angular/platform-browser'; import { IonRefresher } from '@ionic/angular'; @@ -24,6 +23,7 @@ import { CoreUtils } from '@services/utils/utils'; import { CoreEvents } from '@singletons/events'; import { CoreUser, CoreUserProfile, CoreUserProfileRefreshedData, CoreUserProvider } from '@features/user/services/user'; import { CoreUserHelper } from '@features/user/services/user-helper'; +import { CoreNavigator } from '@services/navigator'; /** * Page that displays info about a user. @@ -46,9 +46,7 @@ export class CoreUserAboutPage implements OnInit { formattedAddress?: string; encodedAddress?: SafeUrl; - constructor( - protected route: ActivatedRoute, - ) { + constructor() { this.siteId = CoreSites.instance.getCurrentSiteId(); } @@ -58,8 +56,8 @@ export class CoreUserAboutPage implements OnInit { * @return Promise resolved when done. */ async ngOnInit(): Promise { - this.userId = parseInt(this.route.snapshot.queryParams['userId'], 10) || 0; - this.courseId = parseInt(this.route.snapshot.queryParams['courseId'], 10) || 0; + this.userId = CoreNavigator.instance.getRouteNumberParam('userId') || 0; + this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId') || 0; this.fetchUser().finally(() => { this.userLoaded = true; diff --git a/src/core/features/user/pages/profile/profile.page.ts b/src/core/features/user/pages/profile/profile.page.ts index a932a4645..883acfe1c 100644 --- a/src/core/features/user/pages/profile/profile.page.ts +++ b/src/core/features/user/pages/profile/profile.page.ts @@ -13,8 +13,7 @@ // limitations under the License. import { Component, OnDestroy, OnInit } from '@angular/core'; -import { ActivatedRoute } from '@angular/router'; -import { IonRefresher, NavController } from '@ionic/angular'; +import { IonRefresher } from '@ionic/angular'; import { Subscription } from 'rxjs'; import { CoreSite } from '@classes/site'; @@ -35,6 +34,7 @@ import { CoreUserDelegate, CoreUserDelegateService, CoreUserProfileHandlerData } import { CoreFileUploaderHelper } from '@features/fileuploader/services/fileuploader-helper'; import { CoreIonLoadingElement } from '@classes/ion-loading'; import { CoreUtils } from '@services/utils/utils'; +import { CoreNavigator } from '@services/navigator'; @Component({ selector: 'page-core-user-profile', @@ -43,7 +43,7 @@ import { CoreUtils } from '@services/utils/utils'; }) export class CoreUserProfilePage implements OnInit, OnDestroy { - protected courseId!: number; + protected courseId?: number; protected userId!: number; protected site?: CoreSite; protected obsProfileRefreshed: CoreEventObserver; @@ -61,11 +61,7 @@ export class CoreUserProfilePage implements OnInit, OnDestroy { newPageHandlers: CoreUserProfileHandlerData[] = []; communicationHandlers: CoreUserProfileHandlerData[] = []; - constructor( - protected route: ActivatedRoute, - protected navCtrl: NavController, - ) { - + constructor() { this.obsProfileRefreshed = CoreEvents.on(CoreUserProvider.PROFILE_REFRESHED, (data) => { if (!this.user || !data.user) { return; @@ -81,12 +77,20 @@ export class CoreUserProfilePage implements OnInit, OnDestroy { */ async ngOnInit(): Promise { this.site = CoreSites.instance.getCurrentSite(); - this.userId = parseInt(this.route.snapshot.queryParams['userId'], 10); - this.courseId = parseInt(this.route.snapshot.queryParams['courseId'], 10); + this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId'); + const userId = CoreNavigator.instance.getRouteNumberParam('userId'); if (!this.site) { return; } + if (userId === undefined) { + CoreDomUtils.instance.showErrorModal('User ID not supplied'); + CoreNavigator.instance.back(); + + return; + } + + this.userId = userId; // Allow to change the profile image only in the app profile page. this.canChangeProfilePicture = @@ -257,9 +261,8 @@ export class CoreUserProfilePage implements OnInit, OnDestroy { */ openUserDetails(): void { // @todo: Navigate out of split view if this page is in the right pane. - this.navCtrl.navigateForward(['../about'], { - relativeTo: this.route, - queryParams: { + CoreNavigator.instance.navigate('../about', { + params: { courseId: this.courseId, userId: this.userId, }, diff --git a/src/core/features/user/services/user-delegate.ts b/src/core/features/user/services/user-delegate.ts index 4992fa802..68118bdaf 100644 --- a/src/core/features/user/services/user-delegate.ts +++ b/src/core/features/user/services/user-delegate.ts @@ -51,7 +51,7 @@ export interface CoreUserProfileHandler extends CoreDelegateHandler { */ isEnabledForUser( user: CoreUserProfile, - courseId: number, + courseId?: number, navOptions?: CoreCourseUserAdminOrNavOptionIndexed, admOptions?: CoreCourseUserAdminOrNavOptionIndexed, ): Promise; @@ -63,7 +63,7 @@ export interface CoreUserProfileHandler extends CoreDelegateHandler { * @param courseId Course ID where to show. * @return Data to be shown. */ - getDisplayData(user: CoreUserProfile, courseId: number): CoreUserProfileHandlerData; + getDisplayData(user: CoreUserProfile, courseId?: number): CoreUserProfileHandlerData; } /** @@ -218,7 +218,7 @@ export class CoreUserDelegateService extends CoreDelegate { + getProfileHandlersFor(user: CoreUserProfile, courseId?: number): Subject { // Initialize the user handlers if it isn't initialized already. if (!this.userHandlers[user.id]) { this.userHandlers[user.id] = { @@ -240,7 +240,7 @@ export class CoreUserDelegateService extends CoreDelegate { + protected async calculateUserHandlers(user: CoreUserProfile, courseId?: number): Promise { // @todo: Get Course admin/nav options. let navOptions; let admOptions; diff --git a/src/core/features/user/services/user.ts b/src/core/features/user/services/user.ts index 260956e93..4ed4e8ee3 100644 --- a/src/core/features/user/services/user.ts +++ b/src/core/features/user/services/user.ts @@ -257,8 +257,12 @@ export class CoreUserProvider { try { return await this.getUserFromWS(userId, courseId, siteId); - } catch { - return this.getUserFromLocalDb(userId, siteId); + } catch (error) { + try { + return await this.getUserFromLocalDb(userId, siteId); + } catch { + throw error; + } } } @@ -808,7 +812,7 @@ export class CoreUser extends makeSingleton(CoreUserProvider) {} * Data passed to PROFILE_REFRESHED event. */ export type CoreUserProfileRefreshedData = { - courseId: number; // Course the user profile belongs to. + courseId?: number; // Course the user profile belongs to. userId: number; // User ID. user?: CoreUserProfile; // User affected. }; diff --git a/src/core/services/file-helper.ts b/src/core/services/file-helper.ts index f494b2433..11a29b0e1 100644 --- a/src/core/services/file-helper.ts +++ b/src/core/services/file-helper.ts @@ -26,6 +26,7 @@ import { CoreUtils } from '@services/utils/utils'; import { CoreConstants } from '@/core/constants'; import { CoreError } from '@classes/errors/error'; import { makeSingleton, Translate } from '@singletons'; +import { CoreNetworkError } from '@classes/errors/network-error'; /** * Provider to provide some helper functions regarding files and packages. @@ -152,7 +153,7 @@ export class CoreFileHelperProvider { } else { if (!isOnline && !this.isStateDownloaded(state)) { // Not downloaded and user is offline, reject. - throw new CoreError(Translate.instance.instant('core.networkerrormsg')); + throw new CoreNetworkError(); } if (onProgress) { @@ -160,40 +161,39 @@ export class CoreFileHelperProvider { onProgress({ calculating: true }); } - try { - await CoreFilepool.instance.shouldDownloadBeforeOpen(fixedUrl, file.filesize || 0); - } catch (error) { - // Start the download if in wifi, but return the URL right away so the file is opened. - if (isWifi) { - this.downloadFile(fileUrl, component, componentId, timemodified, onProgress, file, siteId); - } - - if (!this.isStateDownloaded(state) || isOnline) { - // Not downloaded or online, return the online URL. + const shouldDownloadFirst = await CoreFilepool.instance.shouldDownloadFileBeforeOpen(fixedUrl, file.filesize || 0); + if (shouldDownloadFirst) { + // Download the file first. + if (state == CoreConstants.DOWNLOADING) { + // It's already downloading, stop. return fixedUrl; - } else { - // Outdated but offline, so we return the local URL. - return CoreFilepool.instance.getUrlByUrl( - siteId, - fileUrl, - component, - componentId, - timemodified, - false, - false, - file, - ); } + + // Download and then return the local URL. + return this.downloadFile(fileUrl, component, componentId, timemodified, onProgress, file, siteId); } - // Download the file first. - if (state == CoreConstants.DOWNLOADING) { - // It's already downloading, stop. + // Start the download if in wifi, but return the URL right away so the file is opened. + if (isWifi) { + this.downloadFile(fileUrl, component, componentId, timemodified, onProgress, file, siteId); + } + + if (!this.isStateDownloaded(state) || isOnline) { + // Not downloaded or online, return the online URL. return fixedUrl; + } else { + // Outdated but offline, so we return the local URL. + return CoreFilepool.instance.getUrlByUrl( + siteId, + fileUrl, + component, + componentId, + timemodified, + false, + false, + file, + ); } - - // Download and then return the local URL. - return this.downloadFile(fileUrl, component, componentId, timemodified, onProgress, file, siteId); } } diff --git a/src/core/services/filepool.ts b/src/core/services/filepool.ts index 974225eed..211bc7107 100644 --- a/src/core/services/filepool.ts +++ b/src/core/services/filepool.ts @@ -797,7 +797,7 @@ export class CoreFilepoolProvider { * @param onProgress Function to call on progress. * @return Promise resolved when the package is downloaded. */ - protected downloadOrPrefetchPackage( + downloadOrPrefetchPackage( siteId: string, fileList: CoreWSExternalFile[], prefetch: boolean, @@ -2763,13 +2763,7 @@ export class CoreFilepoolProvider { * @param url File online URL. * @param size File size. * @return Promise resolved if should download before open, rejected otherwise. - * @description - * Convenience function to check if a file should be downloaded before opening it. - * - * The default behaviour in the app is to download first and then open the local file in the following cases: - * - The file is small (less than DOWNLOAD_THRESHOLD). - * - The file cannot be streamed. - * If the file is big and can be streamed, the promise returned by this function will be rejected. + * @ddeprecated since 3.9.5. Please use shouldDownloadFileBeforeOpen instead. */ async shouldDownloadBeforeOpen(url: string, size: number): Promise { if (size >= 0 && size <= CoreFilepoolProvider.DOWNLOAD_THRESHOLD) { @@ -2784,6 +2778,32 @@ export class CoreFilepoolProvider { } } + /** + * Convenience function to check if a file should be downloaded before opening it. + * + * @param url File online URL. + * @param size File size. + * @return Promise resolved with boolean: whether file should be downloaded before opening it. + * @description + * Convenience function to check if a file should be downloaded before opening it. + * + * The default behaviour in the app is to download first and then open the local file in the following cases: + * - The file is small (less than DOWNLOAD_THRESHOLD). + * - The file cannot be streamed. + * If the file is big and can be streamed, the promise returned by this function will be rejected. + */ + async shouldDownloadFileBeforeOpen(url: string, size: number): Promise { + if (size >= 0 && size <= CoreFilepoolProvider.DOWNLOAD_THRESHOLD) { + // The file is small, download it. + return true; + } + + const mimetype = await CoreUtils.instance.getMimeTypeFromUrl(url); + + // If the file is streaming (audio or video), return false. + return mimetype.indexOf('video') == -1 && mimetype.indexOf('audio') == -1; + } + /** * Store package status. * diff --git a/src/core/services/navigator.ts b/src/core/services/navigator.ts index af34f481d..86af33ebe 100644 --- a/src/core/services/navigator.ts +++ b/src/core/services/navigator.ts @@ -53,6 +53,9 @@ export type CoreNavigationOptions = { @Injectable({ providedIn: 'root' }) export class CoreNavigatorService { + protected storedParams: Record = {}; + protected lastParamId = 0; + /** * Check whether the active route is using the given path. * @@ -89,6 +92,10 @@ export class CoreNavigatorService { queryParams: CoreObject.isEmpty(options.params ?? {}) ? null : options.params, relativeTo: path.startsWith('/') ? null : this.getCurrentRoute(), }); + + // Remove objects from queryParams and replace them with an ID. + this.replaceObjectParams(navigationOptions.queryParams); + const navigationResult = (options.reset ?? false) ? await NavController.instance.navigateRoot(url, navigationOptions) : await NavController.instance.navigateForward(url, navigationOptions); @@ -183,10 +190,64 @@ export class CoreNavigatorService { * * @return Current path. */ - protected getCurrentPath(): string { + getCurrentPath(): string { return CoreUrlUtils.instance.removeUrlParams(Router.instance.url); } + /** + * Get a parameter for the current route. + * Please notice that objects can only be retrieved once. You must call this function only once per page and parameter, + * unless there's a new navigation to the page. + * + * @param name Name of the parameter. + * @return Value of the parameter, undefined if not found. + */ + getRouteParam(name: string): T | undefined { + const route = this.getCurrentRoute(); + const value = route.snapshot.queryParams[name] ?? route.snapshot.params[name]; + + const storedParam = this.storedParams[value]; + // Remove the parameter from our map if it's in there. + delete this.storedParams[value]; + + return storedParam ?? value; + } + + /** + * Get a number route param. + * Angular router automatically converts numbers to string, this function automatically converts it back to number. + * + * @param name Name of the parameter. + * @return Value of the parameter, undefined if not found. + */ + getRouteNumberParam(name: string): number | undefined { + const value = this.getRouteParam(name); + + return value !== undefined ? Number(value) : value; + } + + /** + * Get a boolean route param. + * Angular router automatically converts booleans to string, this function automatically converts it back to boolean. + * + * @param name Name of the parameter. + * @return Value of the parameter, undefined if not found. + */ + getRouteBooleanParam(name: string): boolean | undefined { + const value = this.getRouteParam(name); + + return value !== undefined ? Boolean(value) : value; + } + + /** + * Navigate back. + * + * @return Promise resolved when done. + */ + back(): Promise { + return NavController.instance.pop(); + } + /** * Get current activated route. * @@ -243,6 +304,31 @@ export class CoreNavigatorService { }); } + /** + * Replace all objects in query params with an ID that can be used to retrieve the object later. + * + * @param queryParams Params. + */ + protected replaceObjectParams(queryParams?: Params | null): void { + for (const name in queryParams) { + const value = queryParams[name]; + if (typeof value != 'object' || value === null) { + continue; + } + + const id = this.getNewParamId(); + this.storedParams[id] = value; + queryParams[name] = id; + } + } + + /** + * Get an ID for a new parameter. + */ + protected getNewParamId(): string { + return 'param-' + (++this.lastParamId); + } + } export class CoreNavigator extends makeSingleton(CoreNavigatorService) {} diff --git a/src/core/services/plugin-file-delegate.ts b/src/core/services/plugin-file-delegate.ts index 93cd9340b..c5b791374 100644 --- a/src/core/services/plugin-file-delegate.ts +++ b/src/core/services/plugin-file-delegate.ts @@ -20,6 +20,7 @@ import { CoreWSExternalFile } from '@services/ws'; import { CoreConstants } from '@/core/constants'; import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; import { makeSingleton } from '@singletons'; +import { CoreSites } from './sites'; /** * Delegate to register pluginfile information handlers. @@ -133,11 +134,13 @@ export class CorePluginFileDelegateService extends CoreDelegate { + async getFilesDownloadSize(files: CoreWSExternalFile[], siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + const filteredFiles = []; await Promise.all(files.map(async (file) => { - const state = await CoreFilepool.instance.getFileStateByUrl(siteId, file.fileurl, file.timemodified); + const state = await CoreFilepool.instance.getFileStateByUrl(siteId!, file.fileurl, file.timemodified); if (state != CoreConstants.DOWNLOADED && state != CoreConstants.NOT_DOWNLOADABLE) { filteredFiles.push(file); diff --git a/src/core/services/sites.ts b/src/core/services/sites.ts index bac5b6d49..b28c44c5e 100644 --- a/src/core/services/sites.ts +++ b/src/core/services/sites.ts @@ -49,6 +49,7 @@ import { SchemaVersionsDBEntry, } from '@services/database/sites'; import { CoreArray } from '../singletons/array'; +import { CoreNetworkError } from '@classes/errors/network-error'; export const CORE_SITE_SCHEMAS = new InjectionToken('CORE_SITE_SCHEMAS'); @@ -136,7 +137,7 @@ export class CoreSitesProvider { if (!CoreUrlUtils.instance.isHttpURL(siteUrl)) { throw new CoreError(Translate.instance.instant('core.login.invalidsite')); } else if (!CoreApp.instance.isOnline()) { - throw new CoreError(Translate.instance.instant('core.networkerrormsg')); + throw new CoreNetworkError(); } try { @@ -359,7 +360,7 @@ export class CoreSitesProvider { retry?: boolean, ): Promise { if (!CoreApp.instance.isOnline()) { - throw new CoreError(Translate.instance.instant('core.networkerrormsg')); + throw new CoreNetworkError(); } if (!service) { diff --git a/src/core/services/utils/dom.ts b/src/core/services/utils/dom.ts index e3a286976..444376fa9 100644 --- a/src/core/services/utils/dom.ts +++ b/src/core/services/utils/dom.ts @@ -35,6 +35,7 @@ import { CoreSilentError } from '@classes/errors/silenterror'; import { makeSingleton, Translate, AlertController, LoadingController, ToastController } from '@singletons'; import { CoreLogger } from '@singletons/logger'; import { CoreFileSizeSum } from '@services/plugin-file-delegate'; +import { CoreNetworkError } from '@classes/errors/network-error'; /* * "Utils" service with helper functions for UI, DOM elements and HTML code. @@ -642,11 +643,13 @@ export class CoreDomUtilsProvider { * Given a message, it deduce if it's a network error. * * @param message Message text. + * @param error Error object. * @return True if the message error is a network error, false otherwise. */ - protected isNetworkError(message: string): boolean { + protected isNetworkError(message: string, error?: CoreError | CoreTextErrorObject | string): boolean { return message == Translate.instance.instant('core.networkerrormsg') || - message == Translate.instance.instant('core.fileuploader.errormustbeonlinetoupload'); + message == Translate.instance.instant('core.fileuploader.errormustbeonlinetoupload') || + error instanceof CoreNetworkError; } /** @@ -1178,7 +1181,7 @@ export class CoreDomUtilsProvider { * @return Promise resolved with the alert modal. */ async showAlert( - header: string, + header: string | undefined, message: string, buttonText?: string, autocloseTime?: number, @@ -1260,12 +1263,17 @@ export class CoreDomUtilsProvider { * @param autocloseTime Number of milliseconds to wait to close the modal. If not defined, modal won't be closed. * @return Promise resolved with the alert modal. */ - showAlertTranslated(title: string, message: string, buttonText?: string, autocloseTime?: number): Promise { - title = title ? Translate.instance.instant(title) : title; + showAlertTranslated( + header: string | undefined, + message: string, + buttonText?: string, + autocloseTime?: number, + ): Promise { + header = header ? Translate.instance.instant(header) : header; message = message ? Translate.instance.instant(message) : message; buttonText = buttonText ? Translate.instance.instant(buttonText) : buttonText; - return this.showAlert(title, message, buttonText, autocloseTime); + return this.showAlert(header, message, buttonText, autocloseTime); } /** @@ -1365,7 +1373,7 @@ export class CoreDomUtilsProvider { buttons: [Translate.instance.instant('core.ok')], }; - if (this.isNetworkError(message)) { + if (this.isNetworkError(message, error)) { alertOptions.cssClass = 'core-alert-network-error'; } else { alertOptions.header = Translate.instance.instant('core.error'); diff --git a/src/core/services/utils/iframe.ts b/src/core/services/utils/iframe.ts index 09a5f5de1..b2ee65307 100644 --- a/src/core/services/utils/iframe.ts +++ b/src/core/services/utils/iframe.ts @@ -13,7 +13,6 @@ // limitations under the License. import { Injectable } from '@angular/core'; -import { NavController } from '@ionic/angular'; import { WKUserScriptWindow } from 'cordova-plugin-wkuserscript'; import { WKWebViewCookiesWindow } from 'cordova-plugin-wkwebview-cookies'; @@ -197,18 +196,16 @@ export class CoreIframeUtilsProvider { * @param element Element to treat (iframe, embed, ...). * @param contentWindow The window of the element contents. * @param contentDocument The document of the element contents. - * @param navCtrl NavController to use if a link can be opened in the app. */ redefineWindowOpen( element: CoreFrameElement, contentWindow: Window, contentDocument: Document, - navCtrl?: NavController, ): void { if (contentWindow) { // Intercept window.open. contentWindow.open = (url: string, name: string) => { - this.windowOpen(url, name, element, navCtrl); + this.windowOpen(url, name, element); // eslint-disable-next-line @typescript-eslint/no-explicit-any return null as any; @@ -220,7 +217,7 @@ export class CoreIframeUtilsProvider { CoreIframeUtilsProvider.FRAME_TAGS.forEach((tag) => { const elements = Array.from(contentDocument.querySelectorAll(tag)); elements.forEach((subElement: CoreFrameElement) => { - this.treatFrame(subElement, true, navCtrl); + this.treatFrame(subElement, true); }); }); } @@ -232,9 +229,8 @@ export class CoreIframeUtilsProvider { * * @param element Element to treat (iframe, embed, ...). * @param isSubframe Whether it's a frame inside another frame. - * @param navCtrl NavController to use if a link can be opened in the app. */ - treatFrame(element: CoreFrameElement, isSubframe?: boolean, navCtrl?: NavController): void { + treatFrame(element: CoreFrameElement, isSubframe?: boolean): void { if (!element) { return; } @@ -246,7 +242,7 @@ export class CoreIframeUtilsProvider { // Redefine window.open in this element and sub frames, it might have been loaded already. if (window && document) { - this.redefineWindowOpen(element, window, document, navCtrl); + this.redefineWindowOpen(element, window, document); } // Treat links. @@ -309,10 +305,9 @@ export class CoreIframeUtilsProvider { * @param url URL passed to window.open. * @param name Name passed to window.open. * @param element HTML element of the frame. - * @param navCtrl NavController to use if a link can be opened in the app. * @return Promise resolved when done. */ - protected async windowOpen(url: string, name: string, element?: CoreFrameElement, navCtrl?: NavController): Promise { + protected async windowOpen(url: string, name: string, element?: CoreFrameElement): Promise { const scheme = CoreUrlUtils.instance.getUrlScheme(url); if (!scheme) { // It's a relative URL, use the frame src to create the full URL. @@ -367,9 +362,7 @@ export class CoreIframeUtilsProvider { } } else { // It's an external link, check if it can be opened in the app. - await CoreWindow.open(url, name, { - navCtrl, - }); + await CoreWindow.open(url, name); } } diff --git a/src/core/services/utils/utils.ts b/src/core/services/utils/utils.ts index 261b5b92c..4aa3d14f4 100644 --- a/src/core/services/utils/utils.ts +++ b/src/core/services/utils/utils.ts @@ -114,7 +114,7 @@ export class CoreUtilsProvider { * @param result Object where to put the properties. If not defined, a new object will be created. * @return The object. */ - arrayToObject | string>( + arrayToObject( array: T[], propertyName?: string, result: Record = {}, diff --git a/src/core/services/ws.ts b/src/core/services/ws.ts index cf82d0c22..33b285cc8 100644 --- a/src/core/services/ws.ts +++ b/src/core/services/ws.ts @@ -36,6 +36,7 @@ import { CoreLogger } from '@singletons/logger'; import { CoreWSError } from '@classes/errors/wserror'; import { CoreAjaxError } from '@classes/errors/ajaxerror'; import { CoreAjaxWSError } from '@classes/errors/ajaxwserror'; +import { CoreNetworkError } from '@classes/errors/network-error'; /** * This service allows performing WS calls and download/upload files. @@ -72,11 +73,16 @@ export class CoreWSProvider { * * @param method The WebService method to be called. * @param siteUrl Complete site url to perform the call. - * @param ajaxData Arguments to pass to the method. + * @param data Arguments to pass to the method. * @param preSets Extra settings and information. * @return Deferred promise resolved with the response data in success and rejected with the error if it fails. */ - protected addToRetryQueue(method: string, siteUrl: string, data: unknown, preSets: CoreWSPreSets): Promise { + protected addToRetryQueue( + method: string, + siteUrl: string, + data: Record, + preSets: CoreWSPreSets, + ): Promise { const call = { method, siteUrl, @@ -98,11 +104,11 @@ export class CoreWSProvider { * @param preSets Extra settings and information. * @return Promise resolved with the response data in success and rejected if it fails. */ - call(method: string, data: unknown, preSets: CoreWSPreSets): Promise { + call(method: string, data: Record, preSets: CoreWSPreSets): Promise { if (!preSets) { throw new CoreError(Translate.instance.instant('core.unexpectederror')); } else if (!CoreApp.instance.isOnline()) { - throw new CoreError(Translate.instance.instant('core.networkerrormsg')); + throw new CoreNetworkError(); } preSets.typeExpected = preSets.typeExpected || 'object'; @@ -244,7 +250,7 @@ export class CoreWSProvider { this.logger.debug('Downloading file', url, path, addExtension); if (!CoreApp.instance.isOnline()) { - throw new CoreError(Translate.instance.instant('core.networkerrormsg')); + throw new CoreNetworkError(); } // Use a tmp path to download the file and then move it to final location. @@ -493,7 +499,7 @@ export class CoreWSProvider { } /** - * Perform the post call and save the promise while waiting to be resolved. + * Perform the post call. It can be split into several requests. * * @param method The WebService method to be called. * @param siteUrl Complete site url to perform the call. @@ -501,7 +507,12 @@ export class CoreWSProvider { * @param preSets Extra settings and information. * @return Promise resolved with the response data in success and rejected with CoreWSError if it fails. */ - performPost(method: string, siteUrl: string, ajaxData: unknown, preSets: CoreWSPreSets): Promise { + async performPost( + method: string, + siteUrl: string, + ajaxData: Record, + preSets: CoreWSPreSets, + ): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any const options: any = {}; @@ -510,6 +521,71 @@ export class CoreWSProvider { options.responseType = 'text'; } + if (!preSets.splitRequest || !ajaxData[preSets.splitRequest.param]) { + return this.performSinglePost(method, siteUrl, ajaxData, preSets, options); + } + + // Split the request into several requests if needed. + const promises: Promise[] = []; + const splitParam = ajaxData[preSets.splitRequest.param]; + + for (let i = 0; i < splitParam.length; i += preSets.splitRequest.maxLength) { + // Limit the array sent. + const limitedData = Object.assign({}, ajaxData); + limitedData[preSets.splitRequest.param] = splitParam.slice(i, i + preSets.splitRequest.maxLength); + + promises.push(this.performSinglePost(method, siteUrl, limitedData, preSets, options)); + } + + const results = await Promise.all(promises); + + // Combine the results. + const firstResult = results.shift(); + + if (preSets.splitRequest.combineCallback) { + return results.reduce(preSets.splitRequest.combineCallback, firstResult); + } + + return results.reduce((previous: T, current: T) => this.combineObjectsArrays(previous, current), firstResult); + } + + /** + * Combine the arrays of two objects, adding them to the first object. + * + * @param object1 First object. + * @param object2 Second object. + * @return First object with items added. + */ + protected combineObjectsArrays(object1: T, object2: T): T { + for (const name in object2) { + const value = object2[name]; + + if (Array.isArray(value)) { + (object1 as Record)[name] = (object1[name] as typeof value).concat(value); + } + } + + return object1; + } + + /** + * Perform a single post request. + * + * @param method The WebService method to be called. + * @param siteUrl Complete site url to perform the call. + * @param ajaxData Arguments to pass to the method. + * @param preSets Extra settings and information. + * @param options Request options. + * @return Promise resolved with the response data in success and rejected with CoreWSError if it fails. + */ + protected performSinglePost( + method: string, + siteUrl: string, + ajaxData: Record, + preSets: CoreWSPreSets, + options: any, // eslint-disable-line @typescript-eslint/no-explicit-any + ): Promise { + // We add the method name to the URL purely to help with debugging. // This duplicates what is in the ajaxData, but that does no harm. // POST variables take precedence over GET. @@ -666,7 +742,7 @@ export class CoreWSProvider { if (!preSets) { throw new CoreError(Translate.instance.instant('core.unexpectederror')); } else if (!CoreApp.instance.isOnline()) { - throw new CoreError(Translate.instance.instant('core.networkerrormsg')); + throw new CoreNetworkError(); } preSets.typeExpected = preSets.typeExpected || 'object'; @@ -750,7 +826,7 @@ export class CoreWSProvider { } if (!CoreApp.instance.isOnline()) { - throw new CoreError(Translate.instance.instant('core.networkerrormsg')); + throw new CoreNetworkError(); } const uploadUrl = preSets.siteUrl + '/webservice/upload.php'; @@ -1089,6 +1165,32 @@ export type CoreWSPreSets = { * Defaults to false. Clean multibyte Unicode chars from data. */ cleanUnicode?: boolean; + + /** + * Whether to split a request if it has too many parameters. Sending too many parameters to the site + * can cause the request to fail (see PHP's max_input_vars). + */ + splitRequest?: CoreWSPreSetsSplitRequest; +}; + +/** + * Options to split a request. + */ +export type CoreWSPreSetsSplitRequest = { + /** + * Name of the parameter used to split the request if too big. Must be an array parameter. + */ + param: string; + + /** + * Max number of entries sent per request. + */ + maxLength: number; + + /** + * Callback to combine the results. If not supplied, arrays in the result will be concatenated. + */ + combineCallback?: (previousValue: unknown, currentValue: unknown, currentIndex: number, array: unknown[]) => unknown; }; /** @@ -1188,7 +1290,7 @@ type AngularHttpRequestOptions = Omit & { type RetryCall = { method: string; siteUrl: string; - data: unknown; + data: Record; preSets: CoreWSPreSets; deferred: PromiseDefer; }; diff --git a/src/core/singletons/events.ts b/src/core/singletons/events.ts index 6c6029688..db1bdc8d4 100644 --- a/src/core/singletons/events.ts +++ b/src/core/singletons/events.ts @@ -264,10 +264,33 @@ export type CoreEventFormActionData = CoreEventSiteData & { online?: boolean; // Whether the data was sent to server or not. Only when submitting. }; - /** * Data passed to NOTIFICATION_SOUND_CHANGED event. */ export type CoreEventNotificationSoundChangedData = CoreEventSiteData & { enabled: boolean; }; + +/** + * Data passed to SELECT_COURSE_TAB event. + */ +export type CoreEventSelectCourseTabData = CoreEventSiteData & { + name?: string; // Name of the tab's handler. If not set, load course contents. + sectionId?: number; + sectionNumber?: number; +}; + +/** + * Data passed to COMPLETION_MODULE_VIEWED event. + */ +export type CoreEventCompletionModuleViewedData = CoreEventSiteData & { + courseId?: number; +}; + +/** + * Data passed to SECTION_STATUS_CHANGED event. + */ +export type CoreEventSectionStatusChangedData = CoreEventSiteData & { + courseId: number; + sectionId?: number; +}; diff --git a/src/core/singletons/window.ts b/src/core/singletons/window.ts index c3a5762e2..593cbcea6 100644 --- a/src/core/singletons/window.ts +++ b/src/core/singletons/window.ts @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { NavController } from '@ionic/angular'; + import { CoreFileHelper } from '@services/file-helper'; import { CoreSites } from '@services/sites'; import { CoreUrlUtils } from '@services/utils/url'; @@ -23,9 +25,10 @@ import { CoreUtils } from '@services/utils/utils'; export type CoreWindowOpenOptions = { /** * NavController to use when opening the link in the app. + * + * @deprecated since 3.9.5 */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - navCtrl?: any; // @todo NavController; + navCtrl?: NavController; }; /** diff --git a/src/theme/app.scss b/src/theme/app.scss index 705825efe..63c199dad 100644 --- a/src/theme/app.scss +++ b/src/theme/app.scss @@ -1,3 +1,31 @@ + // Common styles. + .text-left { text-align: left; } + .text-right { text-align: right; } + .text-center { text-align: center; } + .text-justify { text-align: justify; } + .clearfix { + &:after { + content: ""; + display: table; + clear: both; + } + } + .img-responsive { + display: block; + max-width: 100%; + &[height] { + height: auto; + } + } + + .opacity-hide { opacity: 0; } + .core-big { font-size: 115%; } + .invisible { visibility: hidden; } + + .button-no-uppercase { + text-transform: none; + } + // Correctly inherit ion-text-wrap onto labels. ion-item.ion-text-wrap ion-label { white-space: normal !important; @@ -169,6 +197,10 @@ ion-toolbar { .item.core-danger-item { --border-color: var(--ion-color-danger); } +.item-dimmed { + opacity: 0.7; + --background: var(--gray-lighter); +} // Extra text colors. .text-gray { @@ -298,15 +330,53 @@ ion-item img.core-module-icon[slot="start"] { // Select. ion-select.core-button-select, .core-button-select { - background: var(--ion-color-primary-contrast); - color: var(--ion-color-primary); - white-space: normal; + --background: var(--core-button-select-background); + background: var(--background); + --color: var(--ion-color-primary); + color: var(--color); + text-overflow: ellipsis; + white-space: nowrap; min-height: 45px; + overflow: hidden; margin: 8px; box-shadow: 0 3px 1px -2px rgba(0, 0, 0, .2), 0 2px 2px 0 rgba(0, 0, 0, .14), 0 1px 5px 0 rgba(0, 0, 0, .12); &::part(icon) { margin: 0 8px; } + .core-button-select-text { + margin-inline-end: auto; + } + +} +ion-button.core-button-select { + &::part(native) { + text-transform: none; + font-weight: 400; + font-size: 16px; + } + ion-icon { + margin: 0 8px; + } + .select-icon { + width: 19px; + height: 19px; + position: relative; + opacity: 0.33; + + .select-icon-inner { + left: 5px; + top: 50%; + margin-top: -2px; + position: absolute; + width: 0px; + height: 0px; + color: currentcolor; + pointer-events: none; + border-top: 5px solid; + border-right: 5px solid transparent; + border-left: 5px solid transparent; + } + } } // File uploader. @@ -335,3 +405,13 @@ ion-select.core-button-select, overflow-x: scroll; flex-direction: row; } + +// Text for accessibility, hidden from the view. +.accesshide { + position: absolute; + left: -10000px; + font-weight: normal; + font-size: 1em; +} + +@import "./format-text.scss"; diff --git a/src/theme/format-text.scss b/src/theme/format-text.scss new file mode 100644 index 000000000..149e2e807 --- /dev/null +++ b/src/theme/format-text.scss @@ -0,0 +1,83 @@ +@import "./mixins.scss"; + +/** Format Text - Show more styles. */ +/** Styles of elements inside the directive should be placed in format-text.scss */ +core-format-text { + user-select: text; + word-break: break-word; + word-wrap: break-word; + + &[maxHeight], + &[ng-reflect-max-height] { + display: block; + position: relative; + width: 100%; + overflow: hidden; + + /* Force display inline */ + &.inline { + display: inline-block; + width: auto; + } + + // This is to allow clicks in radio/checkbox content. + &.core-text-formatted { + cursor: pointer; + pointer-events: auto; + + .core-show-more { + display: none; + } + + &:not(.core-shortened) { + max-height: none !important; + } + + &.core-shortened { + overflow: hidden; + min-height: 50px; + + .core-show-more { + text-align: end; + font-size: 14px; + display: block; + position: absolute; + @include position(null, 0, 0, null); + z-index: 7; + background-color: var(--background); + color: var(--color); + padding-left: 10px; // RTL + /*@include darkmode() { + color: var(--white); + background-color: $core-dark-item-bg-color; + }*/ + } + + &:before { + content: ''; + height: 100%; + position: absolute; + right: 0; + bottom: 0; + left: 0; + background: -moz-linear-gradient(top, rgba(0, 0, 0, 0) calc(100% - 50px), var(--background) calc(100% - 15px)); + background: -webkit-gradient(left top, left bottom, color-stop(calc(100% - 50px), rgba(0, 0, 0, 0)), color-stop(calc(100% - 15px), var(--background))); + background: -webkit-linear-gradient(top, rgba(0, 0, 0, 0) calc(100% - 50px), var(--background) calc(100% - 15px)); + background: linear-gradient(to bottom, rgba(0, 0, 0, 0) calc(100% - 50px), var(--background) calc(100% - 15px)); + z-index: 6; + } + } + } + + &.core-expand-in-fullview { + .core-show-more { + @include push-arrow-color(dedede, true); + @include padding-horizontal(null, 18px); + @include background-position(end, 0, center); + + background-repeat: no-repeat; + background-size: 14px 14px; + } + } + } +} diff --git a/src/theme/global.scss b/src/theme/global.scss index 44997bd1f..54f8ccf5d 100644 --- a/src/theme/global.scss +++ b/src/theme/global.scss @@ -11,6 +11,7 @@ /* Application styles */ @import "./variables.scss"; +@import "./mixins.scss"; /* Core CSS required for Ionic components to work properly */ @import "~@ionic/angular/css/core.css"; diff --git a/src/theme/mixins.scss b/src/theme/mixins.scss new file mode 100644 index 000000000..93c952668 --- /dev/null +++ b/src/theme/mixins.scss @@ -0,0 +1,697 @@ +// Place here our custom mixins. +@mixin core-transition($where: all, $time: 500ms) { + -webkit-transition: $where $time ease-in-out; + -moz-transition: $where $time ease-in-out; + -ms-transition: $where $time ease-in-out; + -o-transition: $where $time ease-in-out; + transition: $where $time ease-in-out; +} + +@mixin push-arrow-color($color: dedede, $flip-rtl: false) { + $svg: ""; + @if $flip-rtl != true { + @include multi-dir() { + background-image: url("data:image/svg+xml;charset=utf-8,#{$svg}"); + } + } @else { + $flipped-svg: ""; + + @include ltr () { + background-image: url("data:image/svg+xml;charset=utf-8,#{$svg}"); + } + @include rtl() { + background-image: url("data:image/svg+xml;charset=utf-8,#{$flipped-svg}"); + } + } +} + +@mixin border-start($px, $type, $color) { + @include ltr() { + border-left: $px $type $color; + } + + @include rtl() { + border-right: $px $type $color; + } +} + + +@mixin border-end($px, $type, $color) { + @include ltr() { + border-right: $px $type $color; + } + + @include rtl() { + border-left: $px $type $color; + } +} + +@mixin safe-area-border-start($px, $type, $color) { + $safe-area-position: calc(constant(safe-area-inset-left) + #{$px}); + $safe-area-position-env: calc(env(safe-area-inset-left) + #{$px}); + + @include border-start($px, $type, $color); + @media screen and (orientation: landscape) { + @include border-start($safe-area-position, $type, $color); + @include border-start($safe-area-position-env, $type, $color); + } +} + +@mixin safe-area-border-end($px, $type, $color) { + $safe-area-position: calc(constant(safe-area-inset-right) + #{$px}); + $safe-area-position-env: calc(env(safe-area-inset-right) + #{$px}); + + @include border-end($px, $type, $color); + @media screen and (orientation: landscape) { + @include border-end($safe-area-position, $type, $color); + @include border-end($safe-area-position-env, $type, $color); + } +} + +@mixin safe-area-margin-horizontal($start, $end: $start) { + $safe-area-end: null; + $safe-area-start: null; + $safe-area-start-env: null; + $safe-area-end-env: null; + + @if ($end) { + $safe-area-end: calc(constant(safe-area-inset-right) + #{$end}); + $safe-area-end-env: calc(env(safe-area-inset-right) + #{$end}); + } + @if ($start) { + $safe-area-start: calc(constant(safe-area-inset-left) + #{$start}); + $safe-area-start-env: calc(env(safe-area-inset-left) + #{$start}); + } + + @include margin-horizontal($start, $end); + + @media screen and (orientation: landscape) { + @include margin-horizontal($safe-area-start, $safe-area-end); + @include margin-horizontal($safe-area-start-env, $safe-area-end-env); + } +} + +@mixin safe-area-padding-start($start, $end) { + $safe-area-start: calc(constant(safe-area-inset-left) + #{$start}); + $safe-area-start-env: calc(env(safe-area-inset-left) + #{$start}); + + @include padding-horizontal($start, $end); + + @media screen and (orientation: landscape) { + @include padding-horizontal($safe-area-start, $end); + @include padding-horizontal($safe-area-start-env, $end); + } +} + +@mixin safe-area-padding-end($start, $end) { + $safe-area-end: calc(constant(safe-area-inset-right) + #{$end}); + $safe-area-end-env: calc(env(safe-area-inset-right) + #{$end}); + + @include padding-horizontal($start, $end); + + @media screen and (orientation: landscape) { + @include padding-horizontal($start, $safe-area-end); + @include padding-horizontal($start, $safe-area-end-env); + } +} + +@mixin safe-area-position($top: null, $end: null, $bottom: null, $start: null) { + @include position-horizontal($start, $end); + @include safe-position-horizontal($start, $end); + top: $top; + bottom: $bottom; +} + +@mixin core-headings() { + h1 { + font-size: 3rem; + } + h2 { + font-size: 2.8rem; + } + h3 { + font-size: 2.6rem; + } + h4 { + font-size: 2.2rem; + } + h5 { + font-size: 1.8rem; + } + h6 { + font-size: 1.4rem; + } +} + +@mixin core-as-items() { + .item-md.item-block > .item-inner { + border-bottom: 1px solid $list-md-border-color; + } + + .item-ios.item-block > .item-inner { + border-bottom: $hairlines-width solid $list-ios-border-color; + } + + &:last-child .item > .item-inner { + border-bottom: 0; + } +} + +@mixin core-items() { + &.item-md.item-block > .item-inner { + border-bottom: 1px solid $list-md-border-color; + } + + &.item-ios.item-block > .item-inner { + border-bottom: $hairlines-width solid $list-ios-border-color; + } + + &.item-block:last-child > .item-inner { + border-bottom: 0; + } +} + +@mixin darkmode() { + $root: #{&}; + + @at-root body.dark { + #{$root} { + @content; + } + } +} + +// Extracted from ionic.mixins.scss +// https://github.com/ionic-team/ionic-framework/blob/master/core/src/themes/ionic.mixins.scss +@mixin input-cover() { + @include position(0, null, null, 0); + @include margin(0); + + position: absolute; + + width: 100%; + height: 100%; + + border: 0; + background: transparent; + cursor: pointer; + + appearance: none; + outline: none; + + &::-moz-focus-inner { + border: 0; + } +} + +@mixin text-inherit() { + font-family: inherit; + font-size: inherit; + font-style: inherit; + font-weight: inherit; + letter-spacing: inherit; + text-decoration: inherit; + text-indent: inherit; + text-overflow: inherit; + text-transform: inherit; + text-align: inherit; + white-space: inherit; + color: inherit; +} + +@mixin button-state() { + @include position(0, 0, 0, 0); + + position: absolute; + + content: ""; + + opacity: 0; +} + +// Font smoothing +// -------------------------------------------------- + +@mixin font-smoothing() { + -moz-osx-font-smoothing: grayscale; + -webkit-font-smoothing: antialiased; +} + +// Get the key from a map based on the index +@function index-to-key($map, $index) { + $keys: map-keys($map); + + @return nth($keys, $index); +} + + +// Breakpoint Mixins +// --------------------------------------------------------------------------------- + +// Breakpoint viewport sizes and media queries. +// +// Breakpoints are defined as a map of (name: minimum width), order from small to large: +// +// (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px) +// +// The map defined in the `$screen-breakpoints` global variable is used as the `$breakpoints` argument by default. + +// --------------------------------------------------------------------------------- + +// Minimum breakpoint width. Null for the smallest (first) breakpoint. +// +// >> breakpoint-min(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px)) +// 576px +@function breakpoint-min($name, $breakpoints: $screen-breakpoints) { + $min: map-get($breakpoints, $name); + + @return if($name != index-to-key($breakpoints, 1), $min, null); +} + +// Returns a blank string if smallest breakpoint, otherwise returns the name with a dash infront. +// Useful for making responsive utilities. +// +// >> breakpoint-infix(xs, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px)) +// "" (Returns a blank string) +// >> breakpoint-infix(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px)) +// "-sm" +@function breakpoint-infix($name, $breakpoints: $screen-breakpoints) { + @return if(breakpoint-min($name, $breakpoints) == null, "", "-#{$name}"); +} + +// Media of at least the minimum breakpoint width. No query for the smallest breakpoint. +// Makes the @content apply to the given breakpoint and wider. +@mixin media-breakpoint-up($name, $breakpoints: $screen-breakpoints) { + $min: breakpoint-min($name, $breakpoints); + @if $min { + @media (min-width: $min) { + @content; + } + } @else { + @content; + } +} + +// Name of the next breakpoint, or null for the last breakpoint. +// +// >> breakpoint-next(sm) +// md +// >> breakpoint-next(sm, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px)) +// md +// >> breakpoint-next(sm, $breakpoint-names: (xs sm md lg xl)) +// md +@function breakpoint-next($name, $breakpoints: $screen-breakpoints, $breakpoint-names: map-keys($breakpoints)) { + $n: index($breakpoint-names, $name); + @return if($n < length($breakpoint-names), nth($breakpoint-names, $n + 1), null); +} + +// Maximum breakpoint width. Null for the smallest (first) breakpoint. +// The maximum value is reduced by 0.02px to work around the limitations of +// `min-` and `max-` prefixes and viewports with fractional widths. +// +// See https://www.w3.org/TR/mediaqueries-4/#mq-min-max +// Uses 0.02px rather than 0.01px to work around a current rounding bug in Safari. // Uses 0.02px rather than 0.01px to work around a current rounding bug in Safari. +// See https://bugs.webkit.org/show_bug.cgi?id=178261 // See https://bugs.webkit.org/show_bug.cgi?id=178261 +// +// >> breakpoint-max(md, (xs: 0, sm: 576px, md: 768px, lg: 992px, xl: 1200px)) +// 767.98px +@function breakpoint-max($name, $breakpoints: $screen-breakpoints) { + $max: map-get($breakpoints, $name); + @return if($max and $max > 0, $max - .02, null); +} + +// Media of at most the maximum breakpoint width. No query for the largest breakpoint. +// Makes the @content apply to the given breakpoint and narrower. +@mixin media-breakpoint-down($name, $breakpoints: $screen-breakpoints) { + $max: breakpoint-max($name, $breakpoints); + @if $max { + @media (max-width: $max) { + @content; + } + } @else { + @content; + } +} + + +// Text Direction - ltr / rtl +// +// CSS defaults to use the ltr css, and adds [dir=rtl] selectors +// to override ltr defaults. +// ---------------------------------------------------------- + +@mixin multi-dir() { + @content; + + // $root: #{&}; + // @at-root [dir] { + // #{$root} { + // @content; + // } + // } +} + +@mixin rtl() { + $root: #{&}; + + @at-root [dir=rtl] { + #{$root} { + @content; + } + } +} + +@mixin ltr() { + @content; +} + + +// SVG Background Image Mixin +// @param {string} $svg +// ---------------------------------------------------------- +@mixin svg-background-image($svg, $flip-rtl: false) { + $url: url-encode($svg); + $viewBox: str-split(str-extract($svg, "viewBox='", "'"), " "); + + @if $flip-rtl != true or $viewBox == null { + @include multi-dir() { + background-image: url("data:image/svg+xml;charset=utf-8,#{$url}"); + } + } @else { + $transform: "transform='translate(#{nth($viewBox, 3)}, 0) scale(-1, 1)'"; + $flipped-url: $svg; + $flipped-url: str-replace($flipped-url, "