diff --git a/src/addon/mod/book/book.module.ts b/src/addon/mod/book/book.module.ts new file mode 100644 index 000000000..e1be5e8e2 --- /dev/null +++ b/src/addon/mod/book/book.module.ts @@ -0,0 +1,39 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { AddonModBookProvider } from './providers/book'; +import { AddonModBookModuleHandler } from './providers/module-handler'; +import { AddonModBookLinkHandler } from './providers/link-handler'; +import { CoreCourseModuleDelegate } from '../../../core/course/providers/module-delegate'; +import { CoreContentLinksDelegate } from '../../../core/contentlinks/providers/delegate'; + +@NgModule({ + declarations: [ + ], + imports: [ + ], + providers: [ + AddonModBookProvider, + AddonModBookModuleHandler, + AddonModBookLinkHandler + ] +}) +export class AddonModBookModule { + constructor(moduleDelegate: CoreCourseModuleDelegate, moduleHandler: AddonModBookModuleHandler, + contentLinksDelegate: CoreContentLinksDelegate, linkHandler: AddonModBookLinkHandler) { + moduleDelegate.registerHandler(moduleHandler); + contentLinksDelegate.registerHandler(linkHandler); + } +} diff --git a/src/addon/mod/book/lang/en.json b/src/addon/mod/book/lang/en.json new file mode 100644 index 000000000..5ea91c840 --- /dev/null +++ b/src/addon/mod/book/lang/en.json @@ -0,0 +1,3 @@ +{ + "errorchapter": "Error reading chapter of book." +} \ No newline at end of file diff --git a/src/addon/mod/book/providers/book.ts b/src/addon/mod/book/providers/book.ts new file mode 100644 index 000000000..ba93df02a --- /dev/null +++ b/src/addon/mod/book/providers/book.ts @@ -0,0 +1,399 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { Http, Response } from '@angular/http'; +import { CoreFileProvider } from '../../../../providers/file'; +import { CoreFilepoolProvider } from '../../../../providers/filepool'; +import { CoreLoggerProvider } from '../../../../providers/logger'; +import { CoreSitesProvider } from '../../../../providers/sites'; +import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; +import { CoreTextUtilsProvider } from '../../../../providers/utils/text'; +import { CoreUtilsProvider } from '../../../../providers/utils/utils'; +import { CoreCourseProvider } from '../../../../core/course/providers/course'; + +/** + * A book chapter inside the toc list. + */ +export interface AddonModBookTocChapter { + /** + * ID to identify the chapter. + * @type {string} + */ + id: string; + + /** + * Chapter's title. + * @type {string} + */ + title: string; + + /** + * The chapter's level. + * @type {number} + */ + level: number; +} + +/** + * 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}}}; + +/** + * Service that provides some features for books. + */ +@Injectable() +export class AddonModBookProvider { + static COMPONENT = 'mmaModBook'; + + protected ROOT_CACHE_KEY = 'mmaModBook:'; + protected logger; + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private textUtils: CoreTextUtilsProvider, + private fileProvider: CoreFileProvider, private filepoolProvider: CoreFilepoolProvider, private http: Http, + private utils: CoreUtilsProvider, private courseProvider: CoreCourseProvider, private domUtils: CoreDomUtilsProvider) { + this.logger = logger.getInstance('AddonModBookProvider'); + } + + /** + * Get a book by course module ID. + * + * @param {number} courseId Course ID. + * @param {number} cmId Course module ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the book is retrieved. + */ + getBook(courseId: number, cmId: number, siteId?: string): Promise { + return this.getBookByField(courseId, 'coursemodule', cmId, siteId); + } + + /** + * Get a book with key=value. If more than one is found, only the first will be returned. + * + * @param {number} courseId Course ID. + * @param {string} key Name of the property to check. + * @param {any} value Value to search. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the book is retrieved. + */ + protected getBookByField(courseId: number, key: string, value: any, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + courseids: [courseId] + }, + preSets = { + cacheKey: this.getBookDataCacheKey(courseId) + }; + + return site.read('mod_book_get_books_by_courses', params, preSets).then((response) => { + // Search the book. + if (response && response.books) { + for (const i in response.books) { + const book = response.books[i]; + if (book[key] == value) { + return book; + } + } + } + + return Promise.reject(null); + }); + }); + } + + /** + * Get cache key for get book data WS calls. + * + * @param {number} courseId Course ID. + * @return {string} Cache key. + */ + protected getBookDataCacheKey(courseId: number): string { + return this.ROOT_CACHE_KEY + 'book:' + courseId; + } + + /** + * Gets a chapter contents. + * + * @param {AddonModBookContentsMap} contentsMap Contents map returned by getContentsMap. + * @param {string} chapterId Chapter to retrieve. + * @param {number} moduleId The module ID. + * @return {Promise} Promise resolved with the contents. + */ + getChapterContent(contentsMap: AddonModBookContentsMap, chapterId: string, moduleId: number): Promise { + const indexUrl = contentsMap[chapterId] ? contentsMap[chapterId].indexUrl : undefined, + siteId = this.sitesProvider.getCurrentSiteId(); + let promise; + + if (!indexUrl) { + // It shouldn't happen. + this.logger.debug('Could not locate the index chapter'); + + return Promise.reject(null); + } + + if (this.fileProvider.isAvailable()) { + promise = this.filepoolProvider.downloadUrl(siteId, indexUrl, false, AddonModBookProvider.COMPONENT, moduleId); + } else { + // We return the live URL. + return Promise.resolve(this.sitesProvider.getCurrentSite().fixPluginfileURL(indexUrl)); + } + + return promise.then((url) => { + // Fetch the URL content. + const observable = this.http.get(url); + + return this.utils.observableToPromise(observable).then((response: Response): any => { + const content = response.text(); + if (typeof content !== 'string') { + return Promise.reject(null); + } else { + // Now that we have the content, we update the SRC to point back to the external resource. + return this.domUtils.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 {any[]} contents The module contents. + * @return {AddonModBookContentsMap} Contents map. + */ + getContentsMap(contents: any[]): AddonModBookContentsMap { + const map: AddonModBookContentsMap = {}; + + if (!contents) { + return map; + } + + contents.forEach((content) => { + if (this.isFileDownloadable(content)) { + let chapter, + matches, + split, + filepathIsChapter, + key; + + // Search the chapter number in the filepath. + matches = content.filepath.match(/\/(\d+)\//); + if (matches && matches[1]) { + chapter = matches[1]; + 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 of the chapter. + map[chapter].indexUrl = content.fileurl; + } else { + 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. + 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[this.textUtils.decodeURIComponent(key)] = content.fileurl; + } + } + } + }); + + return map; + } + + /** + * Get the first chapter of a book. + * + * @param {AddonModBookTocChapter[]} chapters The chapters list. + * @return {string} The chapter id. + */ + getFirstChapter(chapters: AddonModBookTocChapter[]): string { + if (!chapters || !chapters.length) { + return; + } + + return chapters[0].id; + } + + /** + * Get the next chapter to the given one. + * + * @param {AddonModBookTocChapter[]} chapters The chapters list. + * @param {string} chapterId The current chapter. + * @return {string} The next chapter id. + */ + getNextChapter(chapters: AddonModBookTocChapter[], chapterId: string): string { + let next = '0'; + + for (let i = 0; i < chapters.length; i++) { + if (chapters[i].id == chapterId) { + if (typeof chapters[i + 1] != 'undefined') { + next = chapters[i + 1].id; + break; + } + } + } + + return next; + } + + /** + * Get the previous chapter to the given one. + * + * @param {AddonModBookTocChapter[]} chapters The chapters list. + * @param {string} chapterId The current chapter. + * @return {string} The next chapter id. + */ + getPreviousChapter(chapters: AddonModBookTocChapter[], chapterId: string): string { + let previous = '0'; + + for (let i = 0; i < chapters.length; i++) { + if (chapters[i].id == chapterId) { + break; + } + previous = chapters[i].id; + } + + return previous; + } + + /** + * Get the book toc as an array. + * + * @param {any[]} contents The module contents. + * @return {any[]} The toc. + */ + getToc(contents: any[]): any[] { + if (!contents || !contents.length) { + return []; + } + + return JSON.parse(contents[0].content); + } + + /** + * Get the book toc as an array of chapters (not nested). + * + * @param {any[]} contents The module contents. + * @return {AddonModBookTocChapter[]} The toc as a list. + */ + getTocList(contents: any[]): AddonModBookTocChapter[] { + const chapters = [], + toc = this.getToc(contents); + + toc.forEach((chapter) => { + // Add the chapter to the list. + let chapterId = chapter.href.replace('/index.html', ''); + chapters.push({id: chapterId, title: chapter.title, level: chapter.level}); + + if (chapter.subitems) { + // Add all the subchapters to the list. + chapter.subitems.forEach((subChapter) => { + chapterId = subChapter.href.replace('/index.html', ''); + chapters.push({id: chapterId, title: subChapter.title, level: subChapter.level}); + }); + } + }); + + return chapters; + } + + /** + * Invalidates book data. + * + * @param {number} courseId Course ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateBookData(courseId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getBookDataCacheKey(courseId)); + }); + } + + /** + * Invalidate the prefetched content. + * + * @param {number} moduleId The module ID. + * @param {number} courseId Course ID of the module. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateContent(moduleId: number, courseId: number, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const promises = []; + + promises.push(this.invalidateBookData(courseId, siteId)); + promises.push(this.filepoolProvider.invalidateFilesByComponent(siteId, AddonModBookProvider.COMPONENT, moduleId)); + promises.push(this.courseProvider.invalidateModule(moduleId, siteId)); + + return this.utils.allPromises(promises); + } + + /** + * Check if a file is downloadable. The file param must have a 'type' attribute like in core_course_get_contents response. + * + * @param {any} file File to check. + * @return {boolean} Whether it's downloadable. + */ + isFileDownloadable(file: any): boolean { + return file.type === 'file'; + } + + /** + * Return whether or not the plugin is enabled. + * + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with true if plugin is enabled, rejected or resolved with false otherwise. + */ + isPluginEnabled(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.canDownloadFiles(); + }); + } + + /** + * Report a book as being viewed. + * + * @param {number} id Module ID. + * @param {string} chapterId Chapter ID. + * @return {Promise} Promise resolved when the WS call is successful. + */ + logView(id: number, chapterId: string): Promise { + if (id) { + const params = { + bookid: id, + chapterid: chapterId + }; + + return this.sitesProvider.getCurrentSite().write('mod_book_view_book', params).then((response) => { + if (!response.status) { + return Promise.reject(null); + } + }); + } + + return Promise.reject(null); + } +} diff --git a/src/addon/mod/book/providers/link-handler.ts b/src/addon/mod/book/providers/link-handler.ts new file mode 100644 index 000000000..4013d4226 --- /dev/null +++ b/src/addon/mod/book/providers/link-handler.ts @@ -0,0 +1,29 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { CoreContentLinksModuleIndexHandler } from '../../../../core/contentlinks/classes/module-index-handler'; +import { CoreCourseHelperProvider } from '../../../../core/course/providers/helper'; + +/** + * Handler to treat links to book. + */ +@Injectable() +export class AddonModBookLinkHandler extends CoreContentLinksModuleIndexHandler { + name = 'AddonModBookLinkHandler'; + + constructor(courseHelper: CoreCourseHelperProvider) { + super(courseHelper, 'mmaModBook', 'book'); + } +} diff --git a/src/addon/mod/book/providers/module-handler.ts b/src/addon/mod/book/providers/module-handler.ts new file mode 100644 index 000000000..179b6e536 --- /dev/null +++ b/src/addon/mod/book/providers/module-handler.ts @@ -0,0 +1,68 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { NavController, NavOptions } from 'ionic-angular'; +import { AddonModBookProvider } from './book'; +import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '../../../../core/course/providers/module-delegate'; +import { CoreCourseProvider } from '../../../../core/course/providers/course'; + +/** + * Handler to support book modules. + */ +@Injectable() +export class AddonModBookModuleHandler implements CoreCourseModuleHandler { + name = 'book'; + + constructor(protected bookProvider: AddonModBookProvider, private courseProvider: CoreCourseProvider) { } + + /** + * Check if the handler is enabled on a site level. + * + * @return {boolean|Promise} Whether or not the handler is enabled on a site level. + */ + isEnabled(): boolean | Promise { + return this.bookProvider.isPluginEnabled(); + } + + /** + * Get the data required to display the module in the course contents view. + * + * @param {any} module The module object. + * @param {number} courseId The course ID. + * @param {number} sectionId The section ID. + * @return {CoreCourseModuleHandlerData} Data to render the module. + */ + getData(module: any, courseId: number, sectionId: number): CoreCourseModuleHandlerData { + return { + icon: this.courseProvider.getModuleIconSrc('book'), + title: module.name, + class: 'addon-mod_book-handler', + action(event: Event, navCtrl: NavController, module: any, courseId: number, options: NavOptions): void { + // @todo + } + }; + } + + /** + * Get the component to render the module. This is needed to support singleactivity course format. + * + * @param {any} course The course object. + * @param {any} module The module object. + * @return {any} The component to use, undefined if not found. + */ + getMainComponent(course: any, module: any): any { + // @todo + } +} diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 5da362ffc..1e31d4e4e 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -66,6 +66,7 @@ import { CoreUserModule } from '../core/user/user.module'; import { AddonCalendarModule } from '../addon/calendar/calendar.module'; import { AddonUserProfileFieldModule } from '../addon/userprofilefield/userprofilefield.module'; import { AddonFilesModule } from '../addon/files/files.module'; +import { AddonModBookModule } from '../addon/mod/book/book.module'; import { AddonModLabelModule } from '../addon/mod/label/label.module'; // For translate loader. AoT requires an exported function for factories. @@ -105,6 +106,7 @@ export function createTranslateLoader(http: HttpClient): TranslateHttpLoader { AddonCalendarModule, AddonUserProfileFieldModule, AddonFilesModule, + AddonModBookModule, AddonModLabelModule ], bootstrap: [IonicApp], diff --git a/src/core/contentlinks/providers/helper.ts b/src/core/contentlinks/providers/helper.ts index 72d68da66..cdf1be398 100644 --- a/src/core/contentlinks/providers/helper.ts +++ b/src/core/contentlinks/providers/helper.ts @@ -21,6 +21,7 @@ import { CoreInitDelegate } from '../../../providers/init'; import { CoreLoggerProvider } from '../../../providers/logger'; import { CoreSitesProvider } from '../../../providers/sites'; import { CoreDomUtilsProvider } from '../../../providers/utils/dom'; +import { CoreTextUtilsProvider } from '../../../providers/utils/text'; import { CoreUrlUtilsProvider } from '../../../providers/utils/url'; import { CoreLoginHelperProvider } from '../../login/providers/helper'; import { CoreContentLinksDelegate, CoreContentLinksAction } from './delegate'; @@ -37,7 +38,7 @@ export class CoreContentLinksHelperProvider { constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private loginHelper: CoreLoginHelperProvider, private contentLinksDelegate: CoreContentLinksDelegate, private appProvider: CoreAppProvider, private domUtils: CoreDomUtilsProvider, private urlUtils: CoreUrlUtilsProvider, private translate: TranslateService, - private initDelegate: CoreInitDelegate, eventsProvider: CoreEventsProvider) { + private initDelegate: CoreInitDelegate, eventsProvider: CoreEventsProvider, private textUtils: CoreTextUtilsProvider) { this.logger = logger.getInstance('CoreContentLinksHelperProvider'); // Listen for app launched URLs. If we receive one, check if it's a content link. @@ -103,7 +104,7 @@ export class CoreContentLinksHelperProvider { const modal = this.domUtils.showModalLoading(); let username; - url = decodeURIComponent(url); + url = this.textUtils.decodeURIComponent(url); // App opened using custom URL scheme. this.logger.debug('Treating custom URL scheme: ' + url);