// (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, OnDestroy, OnInit, ViewChild } from '@angular/core'; import { CoreError } from '@classes/errors/error'; import { CoreSwipeSlidesItemsManager } from '@classes/items-management/swipe-slides-items-manager'; import { CoreSwipeSlidesItemsManagerSource } from '@classes/items-management/swipe-slides-items-manager-source'; import { CoreNavigationBarItem } from '@components/navigation-bar/navigation-bar'; import { CoreSwipeSlidesComponent, CoreSwipeSlidesOptions } from '@components/swipe-slides/swipe-slides'; import { CoreCourse, CoreCourseModuleContentFile } from '@features/course/services/course'; import { CoreCourseModuleData } from '@features/course/services/course-helper'; import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; import { CoreTag, CoreTagItem } from '@features/tag/services/tag'; import { CoreNetwork } from '@services/network'; import { CoreNavigator } from '@services/navigator'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreTextUtils } from '@services/utils/text'; import { CoreUtils } from '@services/utils/utils'; import { Translate } from '@singletons'; import { AddonModBook, AddonModBookBookWSData, AddonModBookContentsMap, AddonModBookTocChapter, } from '../../services/book'; import { CoreAnalytics, CoreAnalyticsEventType } from '@services/analytics'; import { CoreUrl } from '@singletons/url'; import { ADDON_MOD_BOOK_COMPONENT, AddonModBookNavStyle } from '../../constants'; import { CoreModals } from '@services/modals'; /** * Page that displays a book contents. */ @Component({ selector: 'page-addon-mod-book-contents', templateUrl: 'contents.html', styleUrls: ['contents.scss'], }) export class AddonModBookContentsPage implements OnInit, OnDestroy { @ViewChild(CoreSwipeSlidesComponent) swipeSlidesComponent?: CoreSwipeSlidesComponent; title = ''; cmId!: number; courseId!: number; initialChapterId?: number; component = ADDON_MOD_BOOK_COMPONENT; manager?: CoreSwipeSlidesItemsManager; displayNavBar = true; navigationItems: CoreNavigationBarItem[] = []; swiperOpts: CoreSwipeSlidesOptions = { autoHeight: true, observer: true, observeParents: true, scrollOnChange: 'top', }; loaded = false; protected managerUnsubscribe?: () => void; /** * @inheritdoc */ ngOnInit(): void { try { this.cmId = CoreNavigator.getRequiredRouteNumberParam('cmId'); this.courseId = CoreNavigator.getRequiredRouteNumberParam('courseId'); this.initialChapterId = CoreNavigator.getRouteNumberParam('chapterId'); } catch (error) { CoreDomUtils.showErrorModal(error); CoreNavigator.back(); return; } const source = new AddonModBookSlidesItemsManagerSource( this.courseId, this.cmId, CoreTag.areTagsAvailableInSite(), this.initialChapterId, ); this.manager = new CoreSwipeSlidesItemsManager(source); this.managerUnsubscribe = this.manager.addListener({ onSelectedItemUpdated: (item) => { this.onChapterViewed(item.id); }, }); this.fetchContent(); } get module(): CoreCourseModuleData | undefined { return this.manager?.getSource().module; } get book(): AddonModBookBookWSData | undefined { return this.manager?.getSource().book; } get chapters(): AddonModBookTocChapter[] { return this.manager?.getSource().chapters || []; } /** * Download book contents and load the current chapter. * * @param refresh Whether we're refreshing data. * @returns Promise resolved when done. */ protected async fetchContent(refresh = false): Promise { try { const source = this.manager?.getSource(); if (!source) { return; } const { book } = await source.loadBookData(); this.displayNavBar = book.navstyle != AddonModBookNavStyle.TOC_ONLY; this.title = book.name; await source.loadContents(refresh); await source.load(); } catch (error) { CoreDomUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true); } finally { this.loaded = true; } } /** * Change the current chapter. * * @param chapterId Chapter to load. */ changeChapter(chapterId: number): void { if (!chapterId) { return; } this.swipeSlidesComponent?.slideToItem({ id: chapterId }); } /** * Refresh the data. * * @param refresher Refresher. * @returns Promise resolved when done. */ async doRefresh(refresher?: HTMLIonRefresherElement): Promise { if (this.manager) { await CoreUtils.ignoreErrors(Promise.all([ this.manager.getSource().invalidateContent(), CoreCourseModulePrefetchDelegate.invalidateCourseUpdates(this.courseId), // To detect if book was updated. ])); } await CoreUtils.ignoreErrors(this.fetchContent(true)); refresher?.complete(); } /** * Show the TOC. */ async showToc(): Promise { // Create the toc modal. const visibleChapter = this.manager?.getSelectedItem(); const { AddonModBookTocComponent } = await import('../../components/toc/toc'); const modalData = await CoreModals.openSideModal({ component: AddonModBookTocComponent, componentProps: { moduleId: this.cmId, chapters: this.chapters, selected: visibleChapter?.id, courseId: this.courseId, book: this.book, }, }); if (modalData) { this.changeChapter(modalData); } } /** * Update data related to chapter being viewed. * * @param chapterId Chapter viewed. * @returns Promise resolved when done. */ protected async onChapterViewed(chapterId: number): Promise { if (this.displayNavBar) { this.navigationItems = this.getNavigationItems(chapterId); } if (this.book) { AddonModBook.storeLastChapterViewed(this.book.id, chapterId, this.courseId); } if (!this.module) { return; } // Chapter loaded, log view. await CoreUtils.ignoreErrors(AddonModBook.logView(this.module.instance, chapterId)); CoreAnalytics.logEvent({ type: CoreAnalyticsEventType.VIEW_ITEM, ws: 'mod_book_view_book', name: this.module.name, data: { id: this.module.instance, category: 'book', chapterid: chapterId }, url: CoreUrl.addParamsToUrl(`/mod/book/view.php?id=${this.module.id}`, { chapterid: chapterId }), }); const currentChapterIndex = this.chapters.findIndex((chapter) => chapter.id == chapterId); const isLastChapter = currentChapterIndex < 0 || this.chapters[currentChapterIndex + 1] === undefined; // Module is completed when last chapter is viewed, so we only check completion if the last is reached. if (isLastChapter) { CoreCourse.checkModuleCompletion(this.courseId, this.module.completiondata); } } /** * Converts chapters to navigation items. * * @param chapterId Current chapter Id. * @returns Navigation items. */ protected getNavigationItems(chapterId: number): CoreNavigationBarItem[] { return this.chapters.map((chapter) => ({ item: chapter, title: chapter.title, current: chapter.id == chapterId, enabled: true, })); } /** * @inheritdoc */ ngOnDestroy(): void { this.manager?.destroy(); this.managerUnsubscribe?.(); delete this.manager; } } type LoadedChapter = { id: number; content?: string; tags?: CoreTagItem[]; }; /** * Helper to manage swiping within a collection of chapters. */ class AddonModBookSlidesItemsManagerSource extends CoreSwipeSlidesItemsManagerSource { readonly COURSE_ID: number; readonly CM_ID: number; readonly TAGS_ENABLED: boolean; module?: CoreCourseModuleData; book?: AddonModBookBookWSData; chapters: AddonModBookTocChapter[] = []; contentsMap: AddonModBookContentsMap = {}; constructor(courseId: number, cmId: number, tagsEnabled: boolean, initialChapterId?: number) { super(initialChapterId ? { id: initialChapterId } : undefined); this.COURSE_ID = courseId; this.CM_ID = cmId; this.TAGS_ENABLED = tagsEnabled; } /** * @inheritdoc */ getItemId(item: LoadedChapter): string | number { return item.id; } /** * Load book data from WS. * * @returns Promise resolved when done. */ async loadBookData(): Promise<{ module: CoreCourseModuleData; book: AddonModBookBookWSData }> { this.module = await CoreCourse.getModule(this.CM_ID, this.COURSE_ID); this.book = await AddonModBook.getBook(this.COURSE_ID, this.CM_ID); if (!this.initialItem) { // No chapter ID specified. Calculate last viewed. const lastViewed = await AddonModBook.getLastChapterViewed(this.book.id); if (lastViewed) { this.initialItem = { id: lastViewed }; } } return { module: this.module, book: this.book, }; } /** * Load module contents. * * @param refresh Whether we're refreshing data. */ async loadContents(refresh = false): Promise { if (!this.module) { return; } const contents = await this.getModuleContents(refresh); this.contentsMap = AddonModBook.getContentsMap(contents); this.chapters = AddonModBook.getTocList(contents); } /** * Get module contents. * * @param refresh Whether we're refreshing data. * @returns Module contents. */ protected async getModuleContents(refresh = false): Promise { if (!this.module) { return []; } const ignoreCache = refresh && CoreNetwork.isOnline(); try { return await CoreCourse.getModuleContents(this.module, this.COURSE_ID, undefined, false, ignoreCache); } catch (error) { // Error loading contents. If we ignored cache, try to get the cached value. if (ignoreCache && !this.module.contents) { return await CoreCourse.getModuleContents(this.module); } else if (!this.module.contents) { // Not able to load contents, throw the error. throw error; } return this.module.contents; } } /** * @inheritdoc */ protected async loadItems(): Promise { try { const newChapters = await Promise.all(this.chapters.map(async (chapter) => { const content = await AddonModBook.getChapterContent(this.contentsMap, chapter.id, this.CM_ID); return { id: chapter.id, content, tags: this.TAGS_ENABLED ? this.contentsMap[chapter.id].tags : [], }; })); return newChapters; } catch (error) { if (!CoreTextUtils.getErrorMessageFromError(error)) { throw new CoreError(Translate.instant('addon.mod_book.errorchapter')); } throw error; } } /** * Perform the invalidate content function. * * @returns Resolved when done. */ invalidateContent(): Promise { return AddonModBook.invalidateContent(this.CM_ID, this.COURSE_ID); } }