diff --git a/src/addons/mod/book/book-lazy.module.ts b/src/addons/mod/book/book-lazy.module.ts
new file mode 100644
index 000000000..cdaec7474
--- /dev/null
+++ b/src/addons/mod/book/book-lazy.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 { RouterModule, Routes } from '@angular/router';
+
+const routes: Routes = [
+ {
+ path: ':courseId/:cmdId',
+ loadChildren: () => import('./pages/index/index.module').then( m => m.AddonModBookIndexPageModule),
+ },
+];
+
+@NgModule({
+ imports: [RouterModule.forChild(routes)],
+})
+export class AddonModBookLazyModule {}
diff --git a/src/addons/mod/book/book.module.ts b/src/addons/mod/book/book.module.ts
new file mode 100644
index 000000000..af8ec5278
--- /dev/null
+++ b/src/addons/mod/book/book.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 { APP_INITIALIZER, NgModule } from '@angular/core';
+import { Routes } from '@angular/router';
+import { CoreContentLinksDelegate } from '@features/contentlinks/services/contentlinks-delegate';
+import { CoreCourseModuleDelegate } from '@features/course/services/module-delegate';
+import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate';
+import { CoreMainMenuTabRoutingModule } from '@features/mainmenu/mainmenu-tab-routing.module';
+import { CoreTagAreaDelegate } from '@features/tag/services/tag-area-delegate';
+import { AddonModBookComponentsModule } from './components/components.module';
+import { AddonModBookModuleHandler, AddonModBookModuleHandlerService } from './services/handlers/module';
+import { AddonModBookIndexLinkHandler } from './services/handlers/index-link';
+import { AddonModBookListLinkHandler } from './services/handlers/list-link';
+import { AddonModBookPrefetchHandler } from './services/handlers/prefetch';
+import { AddonModBookTagAreaHandler } from './services/handlers/tag-area';
+
+
+const routes: Routes = [
+ {
+ path: AddonModBookModuleHandlerService.PAGE_NAME,
+ loadChildren: () => import('./book-lazy.module').then(m => m.AddonModBookLazyModule),
+ },
+];
+
+@NgModule({
+ imports: [
+ CoreMainMenuTabRoutingModule.forChild(routes),
+ AddonModBookComponentsModule,
+ ],
+ providers: [
+ {
+ provide: APP_INITIALIZER,
+ multi: true,
+ deps: [],
+ useFactory: () => () => {
+ CoreCourseModuleDelegate.instance.registerHandler(AddonModBookModuleHandler.instance);
+ CoreContentLinksDelegate.instance.registerHandler(AddonModBookIndexLinkHandler.instance);
+ CoreContentLinksDelegate.instance.registerHandler(AddonModBookListLinkHandler.instance);
+ CoreCourseModulePrefetchDelegate.instance.registerHandler(AddonModBookPrefetchHandler.instance);
+ CoreTagAreaDelegate.instance.registerHandler(AddonModBookTagAreaHandler.instance);
+ },
+ },
+ ],
+})
+export class AddonModBookModule {}
diff --git a/src/addons/mod/book/components/components.module.ts b/src/addons/mod/book/components/components.module.ts
new file mode 100644
index 000000000..4d59d249d
--- /dev/null
+++ b/src/addons/mod/book/components/components.module.ts
@@ -0,0 +1,47 @@
+// (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 { FormsModule } from '@angular/forms';
+import { IonicModule } from '@ionic/angular';
+import { TranslateModule } from '@ngx-translate/core';
+
+import { CoreSharedModule } from '@/core/shared.module';
+import { CoreCourseComponentsModule } from '@features/course/components/components.module';
+import { CoreTagComponentsModule } from '@features/tag/components/components.module';
+
+import { AddonModBookIndexComponent } from './index/index';
+import { AddonModBookTocComponent } from './toc/toc';
+
+@NgModule({
+ declarations: [
+ AddonModBookIndexComponent,
+ AddonModBookTocComponent,
+ ],
+ imports: [
+ CommonModule,
+ IonicModule,
+ TranslateModule.forChild(),
+ FormsModule,
+ CoreSharedModule,
+ CoreCourseComponentsModule,
+ CoreTagComponentsModule,
+ ],
+ exports: [
+ AddonModBookIndexComponent,
+ AddonModBookTocComponent,
+ ],
+})
+export class AddonModBookComponentsModule {}
diff --git a/src/addons/mod/book/components/index/addon-mod-book-index.html b/src/addons/mod/book/components/index/addon-mod-book-index.html
new file mode 100644
index 000000000..0aaef2eb6
--- /dev/null
+++ b/src/addons/mod/book/components/index/addon-mod-book-index.html
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
0">
+ {{ 'core.tag.tags' | translate }}:
+
+
+
+
+
+
+
diff --git a/src/addons/mod/book/components/index/index.ts b/src/addons/mod/book/components/index/index.ts
new file mode 100644
index 000000000..3951b9242
--- /dev/null
+++ b/src/addons/mod/book/components/index/index.ts
@@ -0,0 +1,251 @@
+// (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, Optional, Input, OnInit } from '@angular/core';
+import { IonContent } from '@ionic/angular';
+import {
+ CoreCourseModuleMainResourceComponent, CoreCourseResourceDownloadResult,
+} from '@features/course/classes/main-resource-component';
+import {
+ AddonModBookProvider,
+ AddonModBookContentsMap,
+ AddonModBookTocChapter,
+ AddonModBookNavStyle,
+ AddonModBook,
+ AddonModBookBookWSData,
+} from '../../services/book';
+import { CoreTag, CoreTagItem } from '@features/tag/services/tag';
+import { CoreDomUtils } from '@services/utils/dom';
+import { CoreCourseContentsPage } from '@features/course/pages/contents/contents';
+import { ModalController, Translate } from '@singletons';
+import { CoreUtils } from '@services/utils/utils';
+import { CoreCourse } from '@features/course/services/course';
+import { AddonModBookTocComponent } from '../toc/toc';
+import { CoreConstants } from '@/core/constants';
+
+/**
+ * Component that displays a book.
+ */
+@Component({
+ selector: 'addon-mod-book-index',
+ templateUrl: 'addon-mod-book-index.html',
+})
+export class AddonModBookIndexComponent extends CoreCourseModuleMainResourceComponent implements OnInit {
+
+ @Input() initialChapterId?: number; // The initial chapter ID to load.
+
+ component = AddonModBookProvider.COMPONENT;
+ chapterContent?: string;
+ previousChapter?: AddonModBookTocChapter;
+ nextChapter?: AddonModBookTocChapter;
+ tagsEnabled = false;
+ displayNavBar = true;
+ previousNavBarTitle?: string;
+ nextNavBarTitle?: string;
+ warning = '';
+ tags?: CoreTagItem[];
+
+ protected chapters: AddonModBookTocChapter[] = [];
+ protected currentChapter?: number;
+ protected book?: AddonModBookBookWSData;
+ protected displayTitlesInNavBar = false;
+ protected contentsMap: AddonModBookContentsMap = {};
+
+ constructor(
+ protected content?: IonContent,
+ @Optional() courseContentsPage?: CoreCourseContentsPage,
+ ) {
+ super('AddonModBookIndexComponent', courseContentsPage);
+ }
+
+ /**
+ * Component being initialized.
+ */
+ async ngOnInit(): Promise {
+ super.ngOnInit();
+
+ this.tagsEnabled = CoreTag.instance.areTagsAvailableInSite();
+ this.loadContent();
+ }
+
+ /**
+ * Show the TOC.
+ */
+ async showToc(): Promise {
+ // Create the toc modal.
+ const modal = await ModalController.instance.create({
+ component: AddonModBookTocComponent,
+ componentProps: {
+ moduleId: this.module!.id,
+ chapters: this.chapters,
+ selected: this.currentChapter,
+ courseId: this.courseId,
+ book: this.book,
+ },
+ cssClass: 'core-modal-lateral',
+ showBackdrop: true,
+ backdropDismiss: true,
+ // @todo enterAnimation: 'core-modal-lateral-transition',
+ // @todo leaveAnimation: 'core-modal-lateral-transition',
+ });
+
+
+ await modal.present();
+
+ const result = await modal.onDidDismiss();
+
+ if (result.data) {
+ this.changeChapter(result.data);
+ }
+ }
+
+ /**
+ * Change the current chapter.
+ *
+ * @param chapterId Chapter to load.
+ * @return Promise resolved when done.
+ */
+ changeChapter(chapterId: number): void {
+ if (chapterId && chapterId != this.currentChapter) {
+ this.loaded = false;
+ this.refreshIcon = CoreConstants.ICON_LOADING;
+ this.loadChapter(chapterId, true);
+ }
+ }
+
+ /**
+ * Perform the invalidate content function.
+ *
+ * @return Resolved when done.
+ */
+ protected invalidateContent(): Promise {
+ return AddonModBook.instance.invalidateContent(this.module!.id, this.courseId!);
+ }
+
+ /**
+ * Download book contents and load the current chapter.
+ *
+ * @param refresh Whether we're refreshing data.
+ * @return Promise resolved when done.
+ */
+ protected async fetchContent(refresh = false): Promise {
+ const promises: Promise[] = [];
+ let downloadResult: CoreCourseResourceDownloadResult | undefined;
+
+ // Try to get the book data. Ignore errors since this WS isn't available in some Moodle versions.
+ promises.push(CoreUtils.instance.ignoreErrors(AddonModBook.instance.getBook(this.courseId!, this.module!.id))
+ .then((book) => {
+ if (!book) {
+ return;
+ }
+
+ this.book = book;
+ this.dataRetrieved.emit(book);
+
+ this.description = book.intro;
+ this.displayNavBar = book.navstyle != AddonModBookNavStyle.TOC_ONLY;
+ this.displayTitlesInNavBar = book.navstyle == AddonModBookNavStyle.TEXT;
+
+ return;
+ }));
+
+ // Get module status to determine if it needs to be downloaded.
+ promises.push(this.downloadResourceIfNeeded(refresh).then((result) => {
+ downloadResult = result;
+
+ return;
+ }));
+
+ try {
+ await Promise.all(promises);
+
+ this.contentsMap = AddonModBook.instance.getContentsMap(this.module!.contents);
+ this.chapters = AddonModBook.instance.getTocList(this.module!.contents);
+
+ if (typeof this.currentChapter == 'undefined' && typeof this.initialChapterId != 'undefined' && this.chapters) {
+ // Initial chapter set. Validate that the chapter exists.
+ const chapter = this.chapters.find((chapter) => chapter.id == this.initialChapterId);
+
+ if (chapter) {
+ this.currentChapter = this.initialChapterId;
+ }
+ }
+
+ if (typeof this.currentChapter == 'undefined') {
+ // Load the first chapter.
+ this.currentChapter = AddonModBook.instance.getFirstChapter(this.chapters);
+ }
+
+ // Show chapter.
+ try {
+ await this.loadChapter(this.currentChapter!, refresh);
+
+ this.warning = downloadResult?.failed ? this.getErrorDownloadingSomeFilesMessage(downloadResult.error!) : '';
+ } catch {
+ // Ignore errors, they're handled inside the loadChapter function.
+ }
+ } finally {
+ this.fillContextMenu(refresh);
+ }
+ }
+
+ /**
+ * Load a book chapter.
+ *
+ * @param chapterId Chapter to load.
+ * @param logChapterId Whether chapter ID should be passed to the log view function.
+ * @return Promise resolved when done.
+ */
+ protected async loadChapter(chapterId: number, logChapterId: boolean): Promise {
+ this.currentChapter = chapterId;
+ this.content?.scrollToTop();
+
+ try {
+ const content = await AddonModBook.instance.getChapterContent(this.contentsMap, chapterId, this.module!.id);
+
+ this.tags = this.tagsEnabled ? this.contentsMap[this.currentChapter].tags : [];
+
+ this.chapterContent = content;
+ this.previousChapter = AddonModBook.instance.getPreviousChapter(this.chapters, chapterId);
+ this.nextChapter = AddonModBook.instance.getNextChapter(this.chapters, chapterId);
+
+ this.previousNavBarTitle = this.previousChapter && this.displayTitlesInNavBar
+ ? Translate.instance.instant('addon.mod_book.navprevtitle', { $a: this.previousChapter.title })
+ : '';
+ this.nextNavBarTitle = this.nextChapter && this.displayTitlesInNavBar
+ ? Translate.instance.instant('addon.mod_book.navnexttitle', { $a: this.nextChapter.title })
+ : '';
+
+ // Chapter loaded, log view. We don't return the promise because we don't want to block the user for this.
+ await CoreUtils.instance.ignoreErrors(AddonModBook.instance.logView(
+ this.module!.instance!,
+ logChapterId ? chapterId : undefined,
+ this.module!.name,
+ ));
+
+ // Module is completed when last chapter is viewed, so we only check completion if the last is reached.
+ if (!this.nextChapter) {
+ CoreCourse.instance.checkModuleCompletion(this.courseId!, this.module!.completiondata);
+ }
+ } catch (error) {
+ CoreDomUtils.instance.showErrorModalDefault(error, 'addon.mod_book.errorchapter', true);
+
+ throw error;
+ } finally {
+ this.loaded = true;
+ this.refreshIcon = CoreConstants.ICON_REFRESH;
+ }
+ }
+
+}
diff --git a/src/addons/mod/book/components/toc/toc.html b/src/addons/mod/book/components/toc/toc.html
new file mode 100644
index 000000000..b54fbbf32
--- /dev/null
+++ b/src/addons/mod/book/components/toc/toc.html
@@ -0,0 +1,32 @@
+
+
+
+
+
+ {{ 'addon.mod_book.toc' | translate }}
+
+
+
+
+
+
+
+
+
+
diff --git a/src/addons/mod/book/components/toc/toc.scss b/src/addons/mod/book/components/toc/toc.scss
new file mode 100644
index 000000000..199375cef
--- /dev/null
+++ b/src/addons/mod/book/components/toc/toc.scss
@@ -0,0 +1,5 @@
+.addon-mod-book-bullet {
+ font-weight: bold;
+ font-size: 1.5em;
+ margin-right: 3px;
+}
diff --git a/src/addons/mod/book/components/toc/toc.ts b/src/addons/mod/book/components/toc/toc.ts
new file mode 100644
index 000000000..d83c59e25
--- /dev/null
+++ b/src/addons/mod/book/components/toc/toc.ts
@@ -0,0 +1,66 @@
+// (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 { ModalController } from '@singletons';
+import { AddonModBookTocChapter, AddonModBookBookWSData, AddonModBookNumbering } from '../../services/book';
+
+/**
+ * Modal to display the TOC of a book.
+ */
+@Component({
+ selector: 'addon-mod-book-toc',
+ templateUrl: 'toc.html',
+ styleUrls: ['toc.scss'],
+})
+export class AddonModBookTocComponent implements OnInit {
+
+ @Input() moduleId?: number;
+ @Input() chapters: AddonModBookTocChapter[] = [];
+ @Input() selected?: number;
+ @Input() courseId?: number;
+ showNumbers = true;
+ addPadding = true;
+ showBullets = false;
+
+ @Input() protected book?: AddonModBookBookWSData;
+
+ /**
+ * Component loaded.
+ */
+ ngOnInit(): void {
+ if (this.book) {
+ this.showNumbers = this.book.numbering == AddonModBookNumbering.NUMBERS;
+ this.showBullets = this.book.numbering == AddonModBookNumbering.BULLETS;
+ this.addPadding = this.book.numbering != AddonModBookNumbering.NONE;
+ }
+ }
+
+ /**
+ * Function called when a course is clicked.
+ *
+ * @param id ID of the clicked chapter.
+ */
+ loadChapter(id: number): void {
+ ModalController.instance.dismiss(id);
+ }
+
+ /**
+ * Close modal.
+ */
+ closeModal(): void {
+ ModalController.instance.dismiss();
+ }
+
+}
diff --git a/src/addons/mod/book/lang.json b/src/addons/mod/book/lang.json
new file mode 100644
index 000000000..200e96ce1
--- /dev/null
+++ b/src/addons/mod/book/lang.json
@@ -0,0 +1,8 @@
+{
+ "errorchapter": "Error reading chapter of book.",
+ "modulenameplural": "Books",
+ "navnexttitle": "Next: {{$a}}",
+ "navprevtitle": "Previous: {{$a}}",
+ "tagarea_book_chapters": "Book chapters",
+ "toc": "Table of contents"
+}
\ No newline at end of file
diff --git a/src/addons/mod/book/pages/index/index.html b/src/addons/mod/book/pages/index/index.html
new file mode 100644
index 000000000..afea617fb
--- /dev/null
+++ b/src/addons/mod/book/pages/index/index.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/addons/mod/book/pages/index/index.module.ts b/src/addons/mod/book/pages/index/index.module.ts
new file mode 100644
index 000000000..cc22a2160
--- /dev/null
+++ b/src/addons/mod/book/pages/index/index.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 { RouterModule, Routes } from '@angular/router';
+import { CommonModule } from '@angular/common';
+import { IonicModule } from '@ionic/angular';
+import { TranslateModule } from '@ngx-translate/core';
+
+import { CoreSharedModule } from '@/core/shared.module';
+import { AddonModBookComponentsModule } from '../../components/components.module';
+import { AddonModBookIndexPage } from './index';
+
+const routes: Routes = [
+ {
+ path: '',
+ component: AddonModBookIndexPage,
+ },
+];
+
+@NgModule({
+ imports: [
+ RouterModule.forChild(routes),
+ CommonModule,
+ IonicModule,
+ TranslateModule.forChild(),
+ CoreSharedModule,
+ AddonModBookComponentsModule,
+ ],
+ declarations: [
+ AddonModBookIndexPage,
+ ],
+ exports: [RouterModule],
+})
+export class AddonModBookIndexPageModule {}
diff --git a/src/addons/mod/book/pages/index/index.ts b/src/addons/mod/book/pages/index/index.ts
new file mode 100644
index 000000000..102fd5a94
--- /dev/null
+++ b/src/addons/mod/book/pages/index/index.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 { Component, OnInit, ViewChild } from '@angular/core';
+import { CoreCourseWSModule } from '@features/course/services/course';
+import { CoreNavigator } from '@services/navigator';
+import { AddonModBookIndexComponent } from '../../components/index/index';
+import { AddonModBookBookWSData } from '../../services/book';
+
+/**
+ * Page that displays a book.
+ */
+@Component({
+ selector: 'page-addon-mod-book-index',
+ templateUrl: 'index.html',
+})
+export class AddonModBookIndexPage implements OnInit {
+
+ @ViewChild(AddonModBookIndexComponent) bookComponent?: AddonModBookIndexComponent;
+
+ title?: string;
+ module?: CoreCourseWSModule;
+ courseId?: number;
+ chapterId?: number;
+
+
+ /**
+ * Component being initialized.
+ */
+ ngOnInit(): void {
+ this.module = CoreNavigator.instance.getRouteParam('module');
+ this.courseId = CoreNavigator.instance.getRouteNumberParam('courseId');
+ this.chapterId = CoreNavigator.instance.getRouteNumberParam('chapterId');
+ this.title = this.module?.name;
+ }
+
+ /**
+ * Update some data based on the book instance.
+ *
+ * @param book Book instance.
+ */
+ updateData(book: AddonModBookBookWSData): void {
+ this.title = book.name || this.title;
+ }
+
+}
diff --git a/src/addons/mod/book/services/book.ts b/src/addons/mod/book/services/book.ts
new file mode 100644
index 000000000..bc27c60b2
--- /dev/null
+++ b/src/addons/mod/book/services/book.ts
@@ -0,0 +1,479 @@
+// (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 { CoreSites, CoreSitesCommonWSOptions } from '@services/sites';
+import { CoreSite, CoreSiteWSPreSets } from '@classes/site';
+import { CoreTagItem } from '@features/tag/services/tag';
+import { CoreWSExternalWarning, CoreWSExternalFile, CoreWS } from '@services/ws';
+import { makeSingleton } from '@singletons';
+import { CoreCourseLogHelper } from '@features/course/services/log-helper';
+import { CoreCourse, CoreCourseModuleContentFile } from '@features/course/services/course';
+import { CoreUtils } from '@services/utils/utils';
+import { CoreFilepool } from '@services/filepool';
+import { CoreTextUtils } from '@services/utils/text';
+import { CoreDomUtils } from '@services/utils/dom';
+import { CoreFile } from '@services/file';
+import { CoreWSError } from '@classes/errors/wserror';
+
+/**
+ * Constants to define how the chapters and subchapters of a book should be displayed in that table of contents.
+ */
+export const enum AddonModBookNumbering {
+ NONE = 0,
+ NUMBERS = 1,
+ BULLETS = 2,
+ INDENTED = 3,
+}
+
+/**
+ * Constants to define the navigation style used within a book.
+ */
+export const enum AddonModBookNavStyle {
+ TOC_ONLY = 0,
+ IMAGE = 1,
+ TEXT = 2,
+}
+
+const ROOT_CACHE_KEY = 'mmaModBook:';
+
+
+/**
+ * Service that provides some features for books.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModBookProvider {
+
+ static readonly COMPONENT = 'mmaModBook';
+
+ /**
+ * Get a book by course module ID.
+ *
+ * @param courseId Course ID.
+ * @param cmId Course module ID.
+ * @param options Other options.
+ * @return Promise resolved when the book is retrieved.
+ */
+ getBook(courseId: number, cmId: number, options: CoreSitesCommonWSOptions = {}): Promise {
+ return this.getBookByField(courseId, 'coursemodule', cmId, options);
+ }
+
+ /**
+ * Get a book with key=value. If more than one is found, only the first will be returned.
+ *
+ * @param courseId Course ID.
+ * @param key Name of the property to check.
+ * @param value Value to search.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the book is retrieved.
+ */
+ protected async getBookByField(
+ courseId: number,
+ key: string,
+ value: number,
+ options: CoreSitesCommonWSOptions = {},
+ ): Promise {
+
+ const site = await CoreSites.instance.getSite(options.siteId);
+ const params: AddonModBookGetBooksByCoursesWSParams = {
+ courseids: [courseId],
+ };
+ const preSets: CoreSiteWSPreSets = {
+ cacheKey: this.getBookDataCacheKey(courseId),
+ updateFrequency: CoreSite.FREQUENCY_RARELY,
+ component: AddonModBookProvider.COMPONENT,
+ ...CoreSites.instance.getReadingStrategyPreSets(options.readingStrategy),
+ };
+
+ const response: AddonModBookGetBooksByCoursesWSResponse = await site.read('mod_book_get_books_by_courses', params, preSets);
+
+ // Search the book.
+ const book = response.books.find((book) => book[key] == value);
+ if (book) {
+ return book;
+ }
+
+ throw new CoreWSError('Book not found');
+ }
+
+ /**
+ * Get cache key for get book data WS calls.
+ *
+ * @param courseId Course ID.
+ * @return Cache key.
+ */
+ protected getBookDataCacheKey(courseId: number): string {
+ return ROOT_CACHE_KEY + 'book:' + courseId;
+ }
+
+ /**
+ * Gets a chapter contents.
+ *
+ * @param contentsMap Contents map returned by getContentsMap.
+ * @param chapterId Chapter to retrieve.
+ * @param moduleId The module ID.
+ * @return Promise resolved with the contents.
+ */
+ async getChapterContent(contentsMap: AddonModBookContentsMap, chapterId: number, moduleId: number): Promise {
+
+ const indexUrl = contentsMap[chapterId] ? contentsMap[chapterId].indexUrl : undefined;
+ if (!indexUrl) {
+ // It shouldn't happen.
+ throw new CoreWSError('Could not locate the index chapter.');
+ }
+
+ if (!CoreFile.instance.isAvailable()) {
+ // We return the live URL.
+ return CoreSites.instance.getCurrentSite()!.checkAndFixPluginfileURL(indexUrl);
+ }
+
+ const siteId = CoreSites.instance.getCurrentSiteId();
+
+ const url = await CoreFilepool.instance.downloadUrl(siteId, indexUrl, false, AddonModBookProvider.COMPONENT, moduleId);
+
+ const content = await CoreWS.instance.getText(url);
+
+ // Now that we have the content, we update the SRC to point back to the external resource.
+ return CoreDomUtils.instance.restoreSourcesInHtml(content, contentsMap[chapterId].paths);
+ }
+
+ /**
+ * Convert an array of book contents into an object where contents are organized in chapters.
+ * Each chapter has an indexUrl and the list of contents in that chapter.
+ *
+ * @param contents The module contents.
+ * @return Contents map.
+ */
+ getContentsMap(contents: CoreCourseModuleContentFile[]): AddonModBookContentsMap {
+ const map: AddonModBookContentsMap = {};
+
+ if (!contents) {
+ return map;
+ }
+
+ contents.forEach((content) => {
+ if (!this.isFileDownloadable(content)) {
+ return;
+ }
+
+ // Search the chapter number in the filepath.
+ const matches = content.filepath.match(/\/(\d+)\//);
+ if (!matches || !matches[1]) {
+ return;
+ }
+ let key: string;
+ const chapter: string = matches[1];
+ const filepathIsChapter = content.filepath == '/' + chapter + '/';
+
+ // Init the chapter if it's not defined yet.
+ map[chapter] = map[chapter] || { paths: {} };
+
+ if (content.filename == 'index.html' && filepathIsChapter) {
+ // Index of the chapter, set indexUrl and tags of the chapter.
+ map[chapter].indexUrl = content.fileurl;
+ map[chapter].tags = content.tags;
+
+ return;
+ }
+
+ if (filepathIsChapter) {
+ // It's a file in the root folder OR the WS isn't returning the filepath as it should (MDL-53671).
+ // Try to get the path to the file from the URL.
+ const split = content.fileurl.split('mod_book/chapter' + content.filepath);
+ key = split[1] || content.filename; // Use filename if we couldn't find the path.
+ } else {
+ // Remove the chapter folder from the path and add the filename.
+ key = content.filepath.replace('/' + chapter + '/', '') + content.filename;
+ }
+
+ map[chapter].paths[CoreTextUtils.instance.decodeURIComponent(key)] = content.fileurl;
+ });
+
+ return map;
+ }
+
+ /**
+ * Get the first chapter of a book.
+ *
+ * @param chapters The chapters list.
+ * @return The chapter id.
+ */
+ getFirstChapter(chapters: AddonModBookTocChapter[]): number | undefined {
+ if (!chapters || !chapters.length) {
+ return;
+ }
+
+ return chapters[0].id;
+ }
+
+ /**
+ * Get the next chapter to the given one.
+ *
+ * @param chapters The chapters list.
+ * @param chapterId The current chapter.
+ * @return The next chapter.
+ */
+ getNextChapter(chapters: AddonModBookTocChapter[], chapterId: number): AddonModBookTocChapter | undefined {
+ const currentChapterIndex = chapters.findIndex((chapter) => chapter.id == chapterId);
+
+ if (currentChapterIndex >= 0 && typeof chapters[currentChapterIndex + 1] != 'undefined') {
+ return chapters[currentChapterIndex + 1];
+ }
+ }
+
+ /**
+ * Get the previous chapter to the given one.
+ *
+ * @param chapters The chapters list.
+ * @param chapterId The current chapter.
+ * @return The next chapter.
+ */
+ getPreviousChapter(chapters: AddonModBookTocChapter[], chapterId: number): AddonModBookTocChapter | undefined {
+ const currentChapterIndex = chapters.findIndex((chapter) => chapter.id == chapterId);
+
+ if (currentChapterIndex > 0) {
+ return chapters[currentChapterIndex - 1];
+ }
+ }
+
+ /**
+ * Get the book toc as an array.
+ *
+ * @param contents The module contents.
+ * @return The toc.
+ */
+ getToc(contents: CoreCourseModuleContentFile[]): AddonModBookTocChapterParsed[] {
+ if (!contents || !contents.length || typeof contents[0].content == 'undefined') {
+ return [];
+ }
+
+ return CoreTextUtils.instance.parseJSON(contents[0].content, []);
+ }
+
+ /**
+ * Get the book toc as an array of chapters (not nested).
+ *
+ * @param contents The module contents.
+ * @return The toc as a list.
+ */
+ getTocList(contents: CoreCourseModuleContentFile[]): AddonModBookTocChapter[] {
+ // Convenience function to get chapter info.
+ const getChapterInfo = (
+ chapter: AddonModBookTocChapterParsed,
+ chapterNumber: number,
+ previousNumber: string = '',
+ ): AddonModBookTocChapter => {
+ const hidden = !!parseInt(chapter.hidden, 10);
+
+ const fullChapterNumber = previousNumber + (hidden ? 'x.' : chapterNumber + '.');
+
+ return {
+ id: parseInt(chapter.href.replace('/index.html', ''), 10),
+ title: chapter.title,
+ level: chapter.level,
+ indexNumber: fullChapterNumber,
+ hidden: hidden,
+ };
+ };
+
+ const chapters: AddonModBookTocChapter[] = [];
+ const toc = this.getToc(contents);
+
+ let chapterNumber = 1;
+ toc.forEach((chapter) => {
+ const tocChapter = getChapterInfo(chapter, chapterNumber);
+
+ // Add the chapter to the list.
+ chapters.push(tocChapter);
+
+ if (chapter.subitems) {
+ let subChapterNumber = 1;
+ // Add all the subchapters to the list.
+ chapter.subitems.forEach((subChapter) => {
+ chapters.push(getChapterInfo(subChapter, subChapterNumber, tocChapter.indexNumber));
+ subChapterNumber++;
+ });
+ }
+
+ chapterNumber++;
+ });
+
+ return chapters;
+ }
+
+ /**
+ * Invalidates book data.
+ *
+ * @param courseId Course ID.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ async invalidateBookData(courseId: number, siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ await site.invalidateWsCacheForKey(this.getBookDataCacheKey(courseId));
+ }
+
+ /**
+ * Invalidate the prefetched content.
+ *
+ * @param moduleId The module ID.
+ * @param courseId Course ID of the module.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the data is invalidated.
+ */
+ invalidateContent(moduleId: number, courseId: number, siteId?: string): Promise {
+ siteId = siteId || CoreSites.instance.getCurrentSiteId();
+
+ const promises: Promise[] = [];
+
+ promises.push(this.invalidateBookData(courseId, siteId));
+ promises.push(CoreFilepool.instance.invalidateFilesByComponent(siteId, AddonModBookProvider.COMPONENT, moduleId));
+ promises.push(CoreCourse.instance.invalidateModule(moduleId, siteId));
+
+ return CoreUtils.instance.allPromises(promises);
+ }
+
+ /**
+ * Check if a file is downloadable. The file param must have a 'type' attribute like in core_course_get_contents response.
+ *
+ * @param file File to check.
+ * @return Whether it's downloadable.
+ */
+ isFileDownloadable(file: CoreCourseModuleContentFile): boolean {
+ return file.type === 'file';
+ }
+
+ /**
+ * Return whether or not the plugin is enabled.
+ *
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved with true if plugin is enabled, rejected or resolved with false otherwise.
+ */
+ async isPluginEnabled(siteId?: string): Promise {
+ const site = await CoreSites.instance.getSite(siteId);
+
+ return site.canDownloadFiles();
+ }
+
+ /**
+ * Report a book as being viewed.
+ *
+ * @param id Module ID.
+ * @param chapterId Chapter ID.
+ * @param name Name of the book.
+ * @param siteId Site ID. If not defined, current site.
+ * @return Promise resolved when the WS call is successful.
+ */
+ logView(id: number, chapterId?: number, name?: string, siteId?: string): Promise {
+ const params: AddonModBookViewBookWSParams = {
+ bookid: id,
+ chapterid: chapterId,
+ };
+
+ return CoreCourseLogHelper.instance.logSingle(
+ 'mod_book_view_book',
+ params,
+ AddonModBookProvider.COMPONENT,
+ id,
+ name,
+ 'book',
+ { chapterid: chapterId },
+ siteId,
+ );
+ }
+
+}
+
+export class AddonModBook extends makeSingleton(AddonModBookProvider) {}
+
+/**
+ * A book chapter inside the toc list.
+ */
+export type AddonModBookTocChapter = {
+ id: number; // ID to identify the chapter.
+ title: string; // Chapter's title.
+ level: number; // The chapter's level.
+ hidden: boolean; // The chapter is hidden.
+ indexNumber: string; // The chapter's number'.
+};
+
+/**
+ * A book chapter parsed from JSON.
+ */
+type AddonModBookTocChapterParsed = {
+ title: string; // Chapter's title.
+ level: number; // The chapter's level.
+ hidden: string; // The chapter is hidden.
+ href: string;
+ subitems: AddonModBookTocChapterParsed[];
+};
+
+/**
+ * Map of book contents. For each chapter it has its index URL and the list of paths of the files the chapter has. Each path
+ * is identified by the relative path in the book, and the value is the URL of the file.
+ */
+export type AddonModBookContentsMap = {
+ [chapter: string]: {
+ indexUrl?: string;
+ paths: {[path: string]: string};
+ tags?: CoreTagItem[];
+ };
+};
+
+/**
+ * Book returned by mod_book_get_books_by_courses.
+ */
+export type AddonModBookBookWSData = {
+ id: number; // Book id.
+ coursemodule: number; // Course module id.
+ course: number; // Course id.
+ name: string; // Book name.
+ intro: string; // The Book intro.
+ introformat: number; // Intro format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN).
+ introfiles?: CoreWSExternalFile[]; // @since 3.2.
+ numbering: number; // Book numbering configuration.
+ navstyle: number; // Book navigation style configuration.
+ customtitles: number; // Book custom titles type.
+ revision?: number; // Book revision.
+ timecreated?: number; // Time of creation.
+ timemodified?: number; // Time of last modification.
+ section?: number; // Course section id.
+ visible?: boolean; // Visible.
+ groupmode?: number; // Group mode.
+ groupingid?: number; // Group id.
+};
+
+/**
+ * Params of mod_book_get_books_by_courses WS.
+ */
+type AddonModBookGetBooksByCoursesWSParams = {
+ courseids?: number[]; // Array of course ids.
+};
+
+/**
+ * Data returned by mod_book_get_books_by_courses WS.
+ */
+type AddonModBookGetBooksByCoursesWSResponse = {
+ books: AddonModBookBookWSData[];
+ warnings?: CoreWSExternalWarning[];
+};
+
+/**
+ * Params of mod_book_view_book WS.
+ */
+type AddonModBookViewBookWSParams = {
+ bookid: number; // Book instance id.
+ chapterid?: number; // Chapter id.
+};
diff --git a/src/addons/mod/book/services/handlers/index-link.ts b/src/addons/mod/book/services/handlers/index-link.ts
new file mode 100644
index 000000000..cb1d39657
--- /dev/null
+++ b/src/addons/mod/book/services/handlers/index-link.ts
@@ -0,0 +1,55 @@
+// (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 { CoreContentLinksModuleIndexHandler } from '@features/contentlinks/classes/module-index-handler';
+import { makeSingleton } from '@singletons';
+import { AddonModBook } from '../book';
+
+/**
+ * Handler to treat links to book.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModBookIndexLinkHandlerService extends CoreContentLinksModuleIndexHandler {
+
+ name = 'AddonModBookLinkHandler';
+
+ constructor() {
+ super('AddonModBook', 'book', 'b');
+ }
+
+ /**
+ * Get the mod params necessary to open an activity.
+ *
+ * @param url The URL to treat.
+ * @param params The params of the URL. E.g. 'mysite.com?id=1' -> {id: 1}
+ * @return List of params to pass to navigateToModule / navigateToModuleByInstance.
+ */
+ getPageParams(url: string, params: Record): Params {
+ return params.chapterid ? { chapterId: parseInt(params.chapterid, 10) } : {};
+ }
+
+ /**
+ * Check if the handler is enabled for a certain site (site + user) and a URL.
+ *
+ * @return Whether the handler is enabled for the URL and site.
+ */
+ isEnabled(siteId: string): Promise {
+ return AddonModBook.instance.isPluginEnabled(siteId);
+ }
+
+}
+
+export class AddonModBookIndexLinkHandler extends makeSingleton(AddonModBookIndexLinkHandlerService) {}
diff --git a/src/addons/mod/book/services/handlers/list-link.ts b/src/addons/mod/book/services/handlers/list-link.ts
new file mode 100644
index 000000000..96903dc74
--- /dev/null
+++ b/src/addons/mod/book/services/handlers/list-link.ts
@@ -0,0 +1,44 @@
+// (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 { CoreContentLinksModuleListHandler } from '@features/contentlinks/classes/module-list-handler';
+import { makeSingleton } from '@singletons';
+import { AddonModBook } from '../book';
+
+/**
+ * Handler to treat links to book list page.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModBookListLinkHandlerService extends CoreContentLinksModuleListHandler {
+
+ name = 'AddonModBookListLinkHandler';
+
+ constructor() {
+ super('AddonModBook', 'book');
+ }
+
+ /**
+ * Check if the handler is enabled for a certain site (site + user) and a URL.
+ * If not defined, defaults to true.
+ *
+ * @return Whether the handler is enabled for the URL and site.
+ */
+ isEnabled(): Promise {
+ return AddonModBook.instance.isPluginEnabled();
+ }
+
+}
+
+export class AddonModBookListLinkHandler extends makeSingleton(AddonModBookListLinkHandlerService) {}
diff --git a/src/addons/mod/book/services/handlers/module.ts b/src/addons/mod/book/services/handlers/module.ts
new file mode 100644
index 000000000..224fb766f
--- /dev/null
+++ b/src/addons/mod/book/services/handlers/module.ts
@@ -0,0 +1,94 @@
+// (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 { AddonModBookIndexComponent } from '../../components/index';
+import { AddonModBook } from '../book';
+import { CoreCourse, CoreCourseAnyModuleData } from '@features/course/services/course';
+import { CoreNavigationOptions, CoreNavigator } from '@services/navigator';
+import { CoreCourseModule } from '@features/course/services/course-helper';
+import { CoreConstants } from '@/core/constants';
+import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@features/course/services/module-delegate';
+import { makeSingleton } from '@singletons';
+
+/**
+ * Handler to support book modules.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModBookModuleHandlerService implements CoreCourseModuleHandler {
+
+ static readonly PAGE_NAME = 'mod_book';
+
+ name = 'AddonModBook';
+ modName = 'book';
+
+ supportedFeatures = {
+ [CoreConstants.FEATURE_MOD_ARCHETYPE]: CoreConstants.MOD_ARCHETYPE_RESOURCE,
+ [CoreConstants.FEATURE_GROUPS]: false,
+ [CoreConstants.FEATURE_GROUPINGS]: false,
+ [CoreConstants.FEATURE_MOD_INTRO]: true,
+ [CoreConstants.FEATURE_COMPLETION_TRACKS_VIEWS]: true,
+ [CoreConstants.FEATURE_GRADE_HAS_GRADE]: false,
+ [CoreConstants.FEATURE_GRADE_OUTCOMES]: false,
+ [CoreConstants.FEATURE_BACKUP_MOODLE2]: true,
+ [CoreConstants.FEATURE_SHOW_DESCRIPTION]: true,
+ };
+
+ /**
+ * Check if the handler is enabled on a site level.
+ *
+ * @return Whether or not the handler is enabled on a site level.
+ */
+ isEnabled(): Promise {
+ return AddonModBook.instance.isPluginEnabled();
+ }
+
+ /**
+ * 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: CoreCourseAnyModuleData): CoreCourseModuleHandlerData {
+ return {
+ icon: CoreCourse.instance.getModuleIconSrc(this.modName, 'modicon' in module ? module.modicon : undefined),
+ title: module.name,
+ class: 'addon-mod_book-handler',
+ showDownloadButton: true,
+ action(event: Event, module: CoreCourseModule, courseId: number, options?: CoreNavigationOptions): void {
+ options = options || {};
+ options.params = options.params || {};
+ Object.assign(options.params, { module });
+ const routeParams = '/' + courseId + '/' + module.id;
+
+ CoreNavigator.instance.navigateToSitePath(AddonModBookModuleHandlerService.PAGE_NAME + routeParams, options);
+ },
+ };
+ }
+
+ /**
+ * 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.
+ *
+ * @return The component (or promise resolved with component) to use, undefined if not found.
+ */
+ async getMainComponent(): Promise | undefined> {
+ return AddonModBookIndexComponent;
+ }
+
+}
+export class AddonModBookModuleHandler extends makeSingleton(AddonModBookModuleHandlerService) {}
diff --git a/src/addons/mod/book/services/handlers/prefetch.ts b/src/addons/mod/book/services/handlers/prefetch.ts
new file mode 100644
index 000000000..bc8475fe5
--- /dev/null
+++ b/src/addons/mod/book/services/handlers/prefetch.ts
@@ -0,0 +1,86 @@
+// (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 { CoreCourseResourcePrefetchHandlerBase } from '@features/course/classes/resource-prefetch-handler';
+import { CoreCourseAnyModuleData, CoreCourseWSModule } from '@features/course/services/course';
+import { CoreUtils } from '@services/utils/utils';
+import { CoreWSExternalFile } from '@services/ws';
+import { makeSingleton } from '@singletons';
+import { AddonModBook, AddonModBookProvider } from '../book';
+
+/**
+ * Handler to prefetch books.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModBookPrefetchHandlerService extends CoreCourseResourcePrefetchHandlerBase {
+
+ name = 'AddonModBook';
+ modName = 'book';
+ component = AddonModBookProvider.COMPONENT;
+ updatesNames = /^configuration$|^.*files$|^entries$/;
+
+ /**
+ * 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.
+ * @return Promise resolved when all content is downloaded. Data returned is not reliable.
+ */
+ async downloadOrPrefetch(module: CoreCourseWSModule, courseId: number, prefetch?: boolean): Promise {
+ const promises: Promise[] = [];
+
+ promises.push(super.downloadOrPrefetch(module, courseId, prefetch));
+ // Ignore errors since this WS isn't available in some Moodle versions.
+ promises.push(CoreUtils.instance.ignoreErrors(AddonModBook.instance.getBook(courseId, module.id)));
+ await Promise.all(promises);
+ }
+
+ /**
+ * Returns module intro files.
+ *
+ * @param module The module object returned by WS.
+ * @param courseId Course ID.
+ * @return Promise resolved with list of intro files.
+ */
+ async getIntroFiles(module: CoreCourseAnyModuleData, courseId: number): Promise {
+ const book = await CoreUtils.instance.ignoreErrors(AddonModBook.instance.getBook(courseId, module.id));
+
+ return this.getIntroFilesFromInstance(module, book);
+ }
+
+ /**
+ * 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.
+ */
+ async invalidateContent(moduleId: number, courseId: number): Promise {
+ await AddonModBook.instance.invalidateContent(moduleId, courseId);
+ }
+
+ /**
+ * 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.
+ */
+ isEnabled(): Promise {
+ return AddonModBook.instance.isPluginEnabled();
+ }
+
+}
+
+export class AddonModBookPrefetchHandler extends makeSingleton(AddonModBookPrefetchHandlerService) {}
diff --git a/src/addons/mod/book/services/handlers/tag-area.ts b/src/addons/mod/book/services/handlers/tag-area.ts
new file mode 100644
index 000000000..e416707ee
--- /dev/null
+++ b/src/addons/mod/book/services/handlers/tag-area.ts
@@ -0,0 +1,79 @@
+// (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 { CoreCourse } from '@features/course/services/course';
+import { CoreTagFeedComponent } from '@features/tag/components/feed/feed';
+import { CoreTagAreaHandler } from '@features/tag/services/tag-area-delegate';
+import { CoreTagFeedElement, CoreTagHelper } from '@features/tag/services/tag-helper';
+import { CoreUrlUtils } from '@services/utils/url';
+import { makeSingleton } from '@singletons';
+import { AddonModBook } from '../book';
+
+/**
+ * Handler to support tags.
+ */
+@Injectable({ providedIn: 'root' })
+export class AddonModBookTagAreaHandlerService implements CoreTagAreaHandler {
+
+ name = 'AddonModBookTagAreaHandler';
+ type = 'mod_book/book_chapters';
+
+ /**
+ * Whether or not the handler is enabled on a site level.
+ *
+ * @return Whether or not the handler is enabled on a site level.
+ */
+ isEnabled(): Promise {
+ return AddonModBook.instance.isPluginEnabled();
+ }
+
+ /**
+ * 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).
+ */
+ async parseContent(content: string): Promise {
+ const items = CoreTagHelper.instance.parseFeedContent(content);
+
+ // Find module ids of the returned books, they are needed by the link delegate.
+ await Promise.all(items.map((item) => {
+ const params = item.url ? CoreUrlUtils.instance.extractUrlParams(item.url) : {};
+ if (params.b && !params.id) {
+ const bookId = parseInt(params.b, 10);
+
+ return CoreCourse.instance.getModuleBasicInfoByInstance(bookId, 'book').then((module) => {
+ item.url += '&id=' + module.id;
+
+ return;
+ });
+ }
+ }));
+
+ return items;
+ }
+
+ /**
+ * Get the component to use to display items.
+ *
+ * @return The component (or promise resolved with component) to use, undefined if not found.
+ */
+ getComponent(): Type | Promise> {
+ return CoreTagFeedComponent;
+ }
+
+}
+
+export class AddonModBookTagAreaHandler extends makeSingleton(AddonModBookTagAreaHandlerService) {}
diff --git a/src/addons/mod/mod.module.ts b/src/addons/mod/mod.module.ts
index fad22f4d3..92edb902e 100644
--- a/src/addons/mod/mod.module.ts
+++ b/src/addons/mod/mod.module.ts
@@ -14,11 +14,13 @@
import { NgModule } from '@angular/core';
+import { AddonModBookModule } from './book/book.module';
import { AddonModLessonModule } from './lesson/lesson.module';
@NgModule({
declarations: [],
imports: [
+ AddonModBookModule,
AddonModLessonModule,
],
providers: [],
diff --git a/src/core/constants.ts b/src/core/constants.ts
index 0f43c62e3..f1e6723fc 100644
--- a/src/core/constants.ts
+++ b/src/core/constants.ts
@@ -77,12 +77,18 @@ export class CoreConstants {
static readonly OUTDATED = 'outdated';
static readonly NOT_DOWNLOADABLE = 'notdownloadable';
+ // Download / prefetch status icon. @todo
static readonly DOWNLOADED_ICON = 'cloud-done';
static readonly DOWNLOADING_ICON = 'spinner';
static readonly NOT_DOWNLOADED_ICON = 'cloud-download';
static readonly OUTDATED_ICON = 'fas-redo-alt';
static readonly NOT_DOWNLOADABLE_ICON = '';
+ // General download and sync icons.
+ static readonly ICON_LOADING = 'spinner';
+ static readonly ICON_REFRESH = 'fas-redo-alt';
+ static readonly ICON_SYNC = 'fas-sync-alt';
+
// Constants from Moodle's resourcelib.
static readonly RESOURCELIB_DISPLAY_AUTO = 0; // Try the best way.
static readonly RESOURCELIB_DISPLAY_EMBED = 1; // Display using object tag.
diff --git a/src/core/features/course/classes/main-resource-component.ts b/src/core/features/course/classes/main-resource-component.ts
index b6647d3a3..92fc58eb9 100644
--- a/src/core/features/course/classes/main-resource-component.ts
+++ b/src/core/features/course/classes/main-resource-component.ts
@@ -58,7 +58,7 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy,
// Data for context menu.
externalUrl?: string; // External URL to open in browser.
description?: string; // Module description.
- refreshIcon = 'spinner'; // Refresh icon, normally spinner or refresh.
+ refreshIcon = CoreConstants.ICON_LOADING; // Refresh icon, normally spinner or refresh.
prefetchStatusIcon?: string; // Used when calling fillContextMenu.
prefetchStatus?: string; // Used when calling fillContextMenu.
prefetchText?: string; // Used when calling fillContextMenu.
@@ -132,14 +132,14 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy,
return;
}
- this.refreshIcon = 'spinner';
+ this.refreshIcon = CoreConstants.ICON_LOADING;
try {
await CoreUtils.instance.ignoreErrors(this.invalidateContent());
await this.loadContent(true);
} finally {
- this.refreshIcon = 'fas-redo';
+ this.refreshIcon = CoreConstants.ICON_REFRESH;
}
}
@@ -181,7 +181,7 @@ export class CoreCourseModuleMainResourceComponent implements OnInit, OnDestroy,
CoreDomUtils.instance.showErrorModalDefault(error, this.fetchContentDefaultError, true);
} finally {
this.loaded = true;
- this.refreshIcon = 'fas-redo';
+ this.refreshIcon = CoreConstants.ICON_REFRESH;
}
}
diff --git a/src/core/features/h5p/classes/framework.ts b/src/core/features/h5p/classes/framework.ts
index d10eeafcd..0e966a259 100644
--- a/src/core/features/h5p/classes/framework.ts
+++ b/src/core/features/h5p/classes/framework.ts
@@ -64,9 +64,9 @@ export class CoreH5PFramework {
const db = await CoreSites.instance.getSiteDb(siteId);
const whereAndParams = db.getInOrEqual(libraryIds);
- whereAndParams[0] = 'mainlibraryid ' + whereAndParams[0];
+ whereAndParams.sql = 'mainlibraryid ' + whereAndParams.sql;
- await db.updateRecordsWhere(CONTENT_TABLE_NAME, { filtered: null }, whereAndParams[0], whereAndParams[1]);
+ await db.updateRecordsWhere(CONTENT_TABLE_NAME, { filtered: null }, whereAndParams.sql, whereAndParams.params);
}
/**
@@ -919,4 +919,3 @@ type LibraryDependency = {
type LibraryAddonDBData = Omit & {
addTo: string;
};
-
diff --git a/src/core/services/filepool.ts b/src/core/services/filepool.ts
index 211bc7107..46cf7ce2e 100644
--- a/src/core/services/filepool.ts
+++ b/src/core/services/filepool.ts
@@ -2196,15 +2196,16 @@ export class CoreFilepoolProvider {
}
const fileIds = items.map((item) => item.fileId);
+
const whereAndParams = db.getInOrEqual(fileIds);
- whereAndParams[0] = 'fileId ' + whereAndParams[0];
+ whereAndParams.sql = 'fileId ' + whereAndParams.sql;
if (onlyUnknown) {
- whereAndParams[0] += ' AND (' + CoreFilepoolProvider.FILE_UPDATE_UNKNOWN_WHERE_CLAUSE + ')';
+ whereAndParams.sql += ' AND (' + CoreFilepoolProvider.FILE_UPDATE_UNKNOWN_WHERE_CLAUSE + ')';
}
- await db.updateRecordsWhere(FILES_TABLE_NAME, { stale: 1 }, whereAndParams[0], whereAndParams[1]);
+ await db.updateRecordsWhere(FILES_TABLE_NAME, { stale: 1 }, whereAndParams.sql, whereAndParams.params);
}
/**