diff --git a/src/addon/mod/book/book.module.ts b/src/addon/mod/book/book.module.ts new file mode 100644 index 000000000..cebd7bb9e --- /dev/null +++ b/src/addon/mod/book/book.module.ts @@ -0,0 +1,46 @@ +// (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 { AddonModBookComponentsModule } from './components/components.module'; +import { AddonModBookProvider } from './providers/book'; +import { AddonModBookModuleHandler } from './providers/module-handler'; +import { AddonModBookLinkHandler } from './providers/link-handler'; +import { AddonModBookPrefetchHandler } from './providers/prefetch-handler'; +import { CoreCourseModuleDelegate } from '../../../core/course/providers/module-delegate'; +import { CoreContentLinksDelegate } from '../../../core/contentlinks/providers/delegate'; +import { CoreCourseModulePrefetchDelegate } from '../../../core/course/providers/module-prefetch-delegate'; + +@NgModule({ + declarations: [ + ], + imports: [ + AddonModBookComponentsModule + ], + providers: [ + AddonModBookProvider, + AddonModBookModuleHandler, + AddonModBookLinkHandler, + AddonModBookPrefetchHandler + ] +}) +export class AddonModBookModule { + constructor(moduleDelegate: CoreCourseModuleDelegate, moduleHandler: AddonModBookModuleHandler, + contentLinksDelegate: CoreContentLinksDelegate, linkHandler: AddonModBookLinkHandler, + prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModBookPrefetchHandler) { + moduleDelegate.registerHandler(moduleHandler); + contentLinksDelegate.registerHandler(linkHandler); + prefetchDelegate.registerHandler(prefetchHandler); + } +} diff --git a/src/addon/mod/book/components/components.module.ts b/src/addon/mod/book/components/components.module.ts new file mode 100644 index 000000000..1ea1ff532 --- /dev/null +++ b/src/addon/mod/book/components/components.module.ts @@ -0,0 +1,52 @@ +// (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 { CommonModule } from '@angular/common'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '../../../../components/components.module'; +import { CoreDirectivesModule } from '../../../../directives/directives.module'; +import { CoreCourseComponentsModule } from '../../../../core/course/components/components.module'; +import { AddonModBookIndexComponent } from './index/index'; +import { AddonModBookTocPopoverComponent } from './toc-popover/toc-popover'; +import { AddonModBookNavigationArrowsComponent } from './navigation-arrows/navigation-arrows'; + +@NgModule({ + declarations: [ + AddonModBookIndexComponent, + AddonModBookTocPopoverComponent, + AddonModBookNavigationArrowsComponent + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CoreCourseComponentsModule + ], + providers: [ + ], + exports: [ + AddonModBookIndexComponent, + AddonModBookTocPopoverComponent, + AddonModBookNavigationArrowsComponent + ], + entryComponents: [ + AddonModBookIndexComponent, + AddonModBookTocPopoverComponent + ] +}) +export class AddonModBookComponentsModule {} diff --git a/src/addon/mod/book/components/index/index.html b/src/addon/mod/book/components/index/index.html new file mode 100644 index 000000000..5463e70a5 --- /dev/null +++ b/src/addon/mod/book/components/index/index.html @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + +
+ + + +
+ +
diff --git a/src/addon/mod/book/components/index/index.ts b/src/addon/mod/book/components/index/index.ts new file mode 100644 index 000000000..48e2592ed --- /dev/null +++ b/src/addon/mod/book/components/index/index.ts @@ -0,0 +1,250 @@ +// (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 { Component, OnInit, OnDestroy, Input, Output, EventEmitter, Optional } from '@angular/core'; +import { NavParams, NavController, Content, PopoverController } from 'ionic-angular'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreAppProvider } from '../../../../../providers/app'; +import { CoreDomUtilsProvider } from '../../../../../providers/utils/dom'; +import { CoreTextUtilsProvider } from '../../../../../providers/utils/text'; +import { CoreCourseProvider } from '../../../../../core/course/providers/course'; +import { CoreCourseHelperProvider } from '../../../../../core/course/providers/helper'; +import { CoreCourseModuleMainComponent } from '../../../../../core/course/providers/module-delegate'; +import { AddonModBookProvider, AddonModBookContentsMap, AddonModBookTocChapter } from '../../providers/book'; +import { AddonModBookPrefetchHandler } from '../../providers/prefetch-handler'; +import { AddonModBookTocPopoverComponent } from '../../components/toc-popover/toc-popover'; + +/** + * Component that displays a book. + */ +@Component({ + selector: 'addon-mod-book-index', + templateUrl: 'index.html', +}) +export class AddonModBookIndexComponent implements OnInit, OnDestroy, CoreCourseModuleMainComponent { + @Input() module: any; // The module of the book. + @Input() courseId: number; // Course ID the book belongs to. + @Output() bookRetrieved?: EventEmitter; + + loaded: boolean; + component = AddonModBookProvider.COMPONENT; + componentId: number; + chapterContent: string; + previousChapter: string; + nextChapter: string; + + // Data for context menu. + externalUrl: string; + description: string; + refreshIcon: string; + prefetchStatusIcon: string; + prefetchText: string; + size: string; + + protected chapters: AddonModBookTocChapter[]; + protected currentChapter: string; + protected contentsMap: AddonModBookContentsMap; + protected isDestroyed = false; + protected statusObserver; + + constructor(private bookProvider: AddonModBookProvider, private courseProvider: CoreCourseProvider, + private domUtils: CoreDomUtilsProvider, private appProvider: CoreAppProvider, private textUtils: CoreTextUtilsProvider, + private courseHelper: CoreCourseHelperProvider, private prefetchDelegate: AddonModBookPrefetchHandler, + private popoverCtrl: PopoverController, private translate: TranslateService, @Optional() private content: Content) { + this.bookRetrieved = new EventEmitter(); + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.description = this.module.description; + this.componentId = this.module.id; + this.externalUrl = this.module.url; + this.loaded = false; + this.refreshIcon = 'spinner'; + + this.fetchContent(); + } + + /** + * Refresh the data. + * + * @param {any} [refresher] Refresher. + * @param {Function} [done] Function to call when done. + * @return {Promise} Promise resolved when done. + */ + doRefresh(refresher?: any, done?: () => void): Promise { + this.refreshIcon = 'spinner'; + + return this.bookProvider.invalidateContent(this.module.id, this.courseId).catch(() => { + // Ignore errors. + }).then(() => { + return this.fetchContent(this.currentChapter, true); + }).finally(() => { + this.refreshIcon = 'refresh'; + refresher && refresher.complete(); + done && done(); + }); + } + + /** + * Show the TOC. + * + * @param {MouseEvent} event Event. + */ + showToc(event: MouseEvent): void { + const popover = this.popoverCtrl.create(AddonModBookTocPopoverComponent, { + chapters: this.chapters + }); + + popover.onDidDismiss((chapterId) => { + this.changeChapter(chapterId); + }); + + popover.present({ + ev: event + }); + } + + /** + * Change the current chapter. + * + * @param {string} chapterId Chapter to load. + * @return {Promise} Promise resolved when done. + */ + changeChapter(chapterId: string): void { + if (chapterId && chapterId != this.currentChapter) { + this.loaded = false; + this.refreshIcon = 'spinner'; + this.loadChapter(chapterId); + } + } + + /** + * Expand the description. + */ + expandDescription(): void { + this.textUtils.expandText(this.translate.instant('core.description'), this.description, this.component, this.module.id); + } + + /** + * Prefetch the module. + */ + prefetch(): void { + this.courseHelper.contextMenuPrefetch(this, this.module, this.courseId); + } + + /** + * Confirm and remove downloaded files. + */ + removeFiles(): void { + this.courseHelper.confirmAndRemoveFiles(this.module, this.courseId); + } + + /** + * Download book contents and load the current chapter. + * + * @param {string} [chapterId] Chapter to load. + * @param {boolean} [refresh] Whether we're refreshing data. + * @return {Promise} Promise resolved when done. + */ + protected fetchContent(chapterId?: string, refresh?: boolean): Promise { + const promises = []; + let downloadFailed = false; + + // Try to get the book data. + promises.push(this.bookProvider.getBook(this.courseId, this.module.id).then((book) => { + this.bookRetrieved.emit(book); + this.description = book.intro || this.description; + }).catch(() => { + // Ignore errors since this WS isn't available in some Moodle versions. + })); + + // Download content. This function also loads module contents if needed. + promises.push(this.prefetchDelegate.download(this.module, this.courseId).catch(() => { + // Mark download as failed but go on since the main files could have been downloaded. + downloadFailed = true; + + if (!this.module.contents.length) { + // Try to load module contents for offline usage. + return this.courseProvider.loadModuleContents(this.module, this.courseId); + } + })); + + return Promise.all(promises).then(() => { + this.contentsMap = this.bookProvider.getContentsMap(this.module.contents); + this.chapters = this.bookProvider.getTocList(this.module.contents); + + if (typeof this.currentChapter == 'undefined') { + this.currentChapter = this.bookProvider.getFirstChapter(this.chapters); + } + + // Show chapter. + return this.loadChapter(chapterId || this.currentChapter).then(() => { + if (downloadFailed && this.appProvider.isOnline()) { + // We could load the main file but the download failed. Show error message. + this.domUtils.showErrorModal('core.errordownloadingsomefiles', true); + } + + // All data obtained, now fill the context menu. + this.courseHelper.fillContextMenu(this, this.module, this.courseId, refresh, this.component); + }).catch(() => { + // Ignore errors, they're handled inside the loadChapter function. + }); + }).catch((error) => { + // Error getting data, fail. + this.loaded = true; + this.refreshIcon = 'refresh'; + this.domUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true); + }); + } + + /** + * Load a book chapter. + * + * @param {string} chapterId Chapter to load. + * @return {Promise} Promise resolved when done. + */ + protected loadChapter(chapterId: string): Promise { + this.currentChapter = chapterId; + this.content && this.content.scrollToTop(); + + return this.bookProvider.getChapterContent(this.contentsMap, chapterId, this.module.id).then((content) => { + this.chapterContent = content; + this.previousChapter = this.bookProvider.getPreviousChapter(this.chapters, chapterId); + this.nextChapter = this.bookProvider.getNextChapter(this.chapters, chapterId); + + // Chapter loaded, log view. We don't return the promise because we don't want to block the user for this. + this.bookProvider.logView(this.module.instance, chapterId).then(() => { + // Module is completed when last chapter is viewed, so we only check completion if the last is reached. + if (!this.nextChapter) { + this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus); + } + }); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'addon.mod_book.errorchapter', true); + + return Promise.reject(null); + }).finally(() => { + this.loaded = true; + this.refreshIcon = 'refresh'; + }); + } + + ngOnDestroy(): void { + this.isDestroyed = true; + this.statusObserver && this.statusObserver.off(); + } +} diff --git a/src/addon/mod/book/components/navigation-arrows/navigation-arrows.html b/src/addon/mod/book/components/navigation-arrows/navigation-arrows.html new file mode 100644 index 000000000..44e6a24d4 --- /dev/null +++ b/src/addon/mod/book/components/navigation-arrows/navigation-arrows.html @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/addon/mod/book/components/navigation-arrows/navigation-arrows.ts b/src/addon/mod/book/components/navigation-arrows/navigation-arrows.ts new file mode 100644 index 000000000..acb60d156 --- /dev/null +++ b/src/addon/mod/book/components/navigation-arrows/navigation-arrows.ts @@ -0,0 +1,32 @@ +// (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 { Component, Input, Output, EventEmitter } from '@angular/core'; + +/** + * Component to navigate to previous or next chapter in a book. + */ +@Component({ + selector: 'addon-mod-book-navigation-arrows', + templateUrl: 'navigation-arrows.html' +}) +export class AddonModBookNavigationArrowsComponent { + @Input() previous?: string; // Previous chapter ID. + @Input() next?: string; // Next chapter ID. + @Output() action?: EventEmitter; // Will emit an event when the item clicked. + + constructor() { + this.action = new EventEmitter(); + } +} diff --git a/src/addon/mod/book/components/toc-popover/toc-popover.html b/src/addon/mod/book/components/toc-popover/toc-popover.html new file mode 100644 index 000000000..6d3dca6f0 --- /dev/null +++ b/src/addon/mod/book/components/toc-popover/toc-popover.html @@ -0,0 +1,5 @@ + + +

{{chapter.title}}

+
+
diff --git a/src/addon/mod/book/components/toc-popover/toc-popover.ts b/src/addon/mod/book/components/toc-popover/toc-popover.ts new file mode 100644 index 000000000..01ce767ab --- /dev/null +++ b/src/addon/mod/book/components/toc-popover/toc-popover.ts @@ -0,0 +1,41 @@ +// (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 { Component } from '@angular/core'; +import { NavParams, ViewController } from 'ionic-angular'; +import { AddonModBookTocChapter } from '../../providers/book'; + +/** + * Component to display the TOC of a book. + */ +@Component({ + selector: 'addon-mod-book-toc-popover', + templateUrl: 'toc-popover.html' +}) +export class AddonModBookTocPopoverComponent { + chapters: AddonModBookTocChapter[]; + + constructor(navParams: NavParams, private viewCtrl: ViewController) { + this.chapters = navParams.get('chapters') || []; + } + + /** + * Function called when a course is clicked. + * + * @param {string} id ID of the clicked chapter. + */ + loadChapter(id: string): void { + this.viewCtrl.dismiss(id); + } +} 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/pages/index/index.html b/src/addon/mod/book/pages/index/index.html new file mode 100644 index 000000000..c0a0d5fa3 --- /dev/null +++ b/src/addon/mod/book/pages/index/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/addon/mod/book/pages/index/index.module.ts b/src/addon/mod/book/pages/index/index.module.ts new file mode 100644 index 000000000..02b409af8 --- /dev/null +++ b/src/addon/mod/book/pages/index/index.module.ts @@ -0,0 +1,33 @@ +// (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 { IonicPageModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreDirectivesModule } from '../../../../../directives/directives.module'; +import { AddonModBookComponentsModule } from '../../components/components.module'; +import { AddonModBookIndexPage } from './index'; + +@NgModule({ + declarations: [ + AddonModBookIndexPage, + ], + imports: [ + CoreDirectivesModule, + AddonModBookComponentsModule, + IonicPageModule.forChild(AddonModBookIndexPage), + TranslateModule.forChild() + ], +}) +export class AddonModBookIndexPageModule {} diff --git a/src/addon/mod/book/pages/index/index.ts b/src/addon/mod/book/pages/index/index.ts new file mode 100644 index 000000000..a45a34e1d --- /dev/null +++ b/src/addon/mod/book/pages/index/index.ts @@ -0,0 +1,48 @@ +// (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 { Component, ViewChild } from '@angular/core'; +import { IonicPage, NavParams } from 'ionic-angular'; +import { AddonModBookIndexComponent } from '../../components/index/index'; + +/** + * Page that displays a book. + */ +@IonicPage({ segment: 'addon-mod-book-index' }) +@Component({ + selector: 'page-addon-mod-book-index', + templateUrl: 'index.html', +}) +export class AddonModBookIndexPage { + @ViewChild(AddonModBookIndexComponent) bookComponent: AddonModBookIndexComponent; + + title: string; + module: any; + courseId: number; + + constructor(navParams: NavParams) { + this.module = navParams.get('module') || {}; + this.courseId = navParams.get('courseId'); + this.title = this.module.name; + } + + /** + * Update some data based on the book instance. + * + * @param {any} book Book instance. + */ + updateData(book: any): void { + this.title = book.name || this.title; + } +} 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..88fc25d7e --- /dev/null +++ b/src/addon/mod/book/providers/module-handler.ts @@ -0,0 +1,71 @@ +// (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 { AddonModBookIndexComponent } from '../components/index/index'; +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', + showDownloadButton: true, + action(event: Event, navCtrl: NavController, module: any, courseId: number, options: NavOptions): void { + navCtrl.push('AddonModBookIndexPage', {module: module, courseId: courseId}, options); + } + }; + } + + /** + * Get the component to render the module. This is needed to support singleactivity course format. + * The component returned must implement CoreCourseModuleMainComponent. + * + * @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 { + return AddonModBookIndexComponent; + } +} diff --git a/src/addon/mod/book/providers/prefetch-handler.ts b/src/addon/mod/book/providers/prefetch-handler.ts new file mode 100644 index 000000000..74bfd0aae --- /dev/null +++ b/src/addon/mod/book/providers/prefetch-handler.ts @@ -0,0 +1,105 @@ +// (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, Injector } from '@angular/core'; +import { CoreCourseModulePrefetchHandlerBase } from '../../../../core/course/classes/module-prefetch-handler'; +import { AddonModBookProvider } from './book'; + +/** + * Handler to prefetch books. + */ +@Injectable() +export class AddonModBookPrefetchHandler extends CoreCourseModulePrefetchHandlerBase { + name = 'book'; + component = AddonModBookProvider.COMPONENT; + updatesNames = /^configuration$|^.*files$|^entries$/; + isResource = true; + + constructor(injector: Injector, protected bookProvider: AddonModBookProvider) { + super(injector); + } + + /** + * Download or prefetch the content. + * + * @param {any} module The module object returned by WS. + * @param {number} courseId Course ID. + * @param {boolean} [prefetch] True to prefetch, false to download right away. + * @param {string} [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} Promise resolved when all content is downloaded. Data returned is not reliable. + */ + downloadOrPrefetch(module: any, courseId: number, prefetch?: boolean, dirPath?: string): Promise { + const promises = []; + + promises.push(super.downloadOrPrefetch(module, courseId, prefetch)); + promises.push(this.bookProvider.getBook(courseId, module.id).catch(() => { + // Ignore errors since this WS isn't available in some Moodle versions. + })); + + return Promise.all(promises); + } + + /** + * Returns module intro files. + * + * @param {any} module The module object returned by WS. + * @param {number} courseId Course ID. + * @return {Promise} Promise resolved with list of intro files. + */ + getIntroFiles(module: any, courseId: number): Promise { + return this.bookProvider.getBook(courseId, module.id).catch(() => { + // Not found, return undefined so module description is used. + }).then((book) => { + return this.getIntroFilesFromInstance(module, book); + }); + } + + /** + * Invalidate the prefetched content. + * + * @param {number} moduleId The module ID. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateContent(moduleId: number, courseId: number): Promise { + return this.bookProvider.invalidateContent(moduleId, courseId); + } + + /** + * Invalidate WS calls needed to determine module status. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved when invalidated. + */ + invalidateModule(module: any, courseId: number): Promise { + const promises = []; + + promises.push(this.bookProvider.invalidateBookData(courseId)); + promises.push(this.courseProvider.invalidateModule(module.id)); + + return Promise.all(promises); + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} A boolean, or a promise resolved with a boolean, indicating if the handler is enabled. + */ + isEnabled(): boolean | Promise { + return this.bookProvider.isPluginEnabled(); + } +} diff --git a/src/addon/mod/label/label.scss b/src/addon/mod/label/label.scss new file mode 100644 index 000000000..aa867a8f1 --- /dev/null +++ b/src/addon/mod/label/label.scss @@ -0,0 +1,3 @@ +a.core-course-module-handler.addon-mod-label-handler { + align-items: center; +} diff --git a/src/addon/mod/label/providers/module-handler.ts b/src/addon/mod/label/providers/module-handler.ts index a32b9474a..5b76b9818 100644 --- a/src/addon/mod/label/providers/module-handler.ts +++ b/src/addon/mod/label/providers/module-handler.ts @@ -57,6 +57,7 @@ export class AddonModLabelModuleHandler implements CoreCourseModuleHandler { /** * Get the component to render the module. This is needed to support singleactivity course format. + * The component returned must implement CoreCourseModuleMainComponent. * * @param {any} course The course object. * @param {any} module The module object. diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 919648b2d..188240118 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -67,6 +67,7 @@ import { CoreGradesModule } from '../core/grades/grades.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. @@ -107,6 +108,7 @@ export function createTranslateLoader(http: HttpClient): TranslateHttpLoader { AddonCalendarModule, AddonUserProfileFieldModule, AddonFilesModule, + AddonModBookModule, AddonModLabelModule ], bootstrap: [IonicApp], diff --git a/src/classes/delegate.ts b/src/classes/delegate.ts index cfbb953d4..b26d26a32 100644 --- a/src/classes/delegate.ts +++ b/src/classes/delegate.ts @@ -134,7 +134,7 @@ export class CoreDelegate { if (handler && handler[fnName]) { return handler[fnName].apply(handler, params); } else if (this.defaultHandler && this.defaultHandler[fnName]) { - return this.defaultHandler[fnName].apply(this, params); + return this.defaultHandler[fnName].apply(this.defaultHandler, params); } } @@ -143,9 +143,9 @@ export class CoreDelegate { * * @param {string} handlerName The handler name. * @param {boolean} [enabled] Only enabled, or any. - * @return {any} Handler. + * @return {CoreDelegateHandler} Handler. */ - protected getHandler(handlerName: string, enabled: boolean = false): any { + protected getHandler(handlerName: string, enabled: boolean = false): CoreDelegateHandler { return enabled ? this.enabledHandlers[handlerName] : this.handlers[handlerName]; } diff --git a/src/classes/sqlitedb.ts b/src/classes/sqlitedb.ts index a4a0b160a..efef5527d 100644 --- a/src/classes/sqlitedb.ts +++ b/src/classes/sqlitedb.ts @@ -649,11 +649,11 @@ export class SQLiteDB { * * @param {string} table The database table. * @param {object} data An object with the fields to insert/update: fieldname=>fieldvalue. - * @param {object} conditions The conditions to check if the record already exists. + * @param {object} conditions The conditions to check if the record already exists (and to update it). * @return {Promise} Promise resolved with done. */ insertOrUpdateRecord(table: string, data: object, conditions: object): Promise { - return this.getRecord(table, conditions || data).then(() => { + return this.getRecord(table, conditions).then(() => { // It exists, update it. return this.updateRecords(table, data, conditions); }).catch(() => { @@ -854,7 +854,7 @@ export class SQLiteDB { // Create the list of params using the "data" object and the params for the where clause. params = Object.keys(data).map((key) => data[key]); if (where && whereParams) { - params = params.concat(whereParams[1]); + params = params.concat(whereParams); } return this.execute(sql, params); @@ -868,7 +868,7 @@ export class SQLiteDB { */ whereClause(conditions: any = {}): any[] { if (!conditions || !Object.keys(conditions).length) { - return ['', []]; + return ['1 = 1', []]; } const where = [], diff --git a/src/components/context-menu/context-menu.scss b/src/components/context-menu/context-menu.scss new file mode 100644 index 000000000..9730ca1e4 --- /dev/null +++ b/src/components/context-menu/context-menu.scss @@ -0,0 +1,6 @@ +core-context-menu-popover { + .item-md ion-icon[item-start] + .item-inner, + .item-md ion-icon[item-start] + .item-input { + @include margin-horizontal(5px, null); + } +} diff --git a/src/components/context-menu/context-menu.ts b/src/components/context-menu/context-menu.ts index 89857016b..6ef697cc4 100644 --- a/src/components/context-menu/context-menu.ts +++ b/src/components/context-menu/context-menu.ts @@ -12,9 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, OnInit } from '@angular/core'; +import { Component, Input, OnInit, OnDestroy, ElementRef } from '@angular/core'; import { PopoverController } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; +import { CoreDomUtilsProvider } from '../../providers/utils/dom'; import { CoreContextMenuItemComponent } from './context-menu-item'; import { CoreContextMenuPopoverComponent } from './context-menu-popover'; import { Subject } from 'rxjs'; @@ -26,7 +27,7 @@ import { Subject } from 'rxjs'; selector: 'core-context-menu', templateUrl: 'context-menu.html' }) -export class CoreContextMenuComponent implements OnInit { +export class CoreContextMenuComponent implements OnInit, OnDestroy { @Input() icon?: string; // Icon to be shown on the navigation bar. Default: Kebab menu icon. @Input() title?: string; // Aria label and text to be shown on the top of the popover. @@ -34,8 +35,11 @@ export class CoreContextMenuComponent implements OnInit { ariaLabel: string; protected items: CoreContextMenuItemComponent[] = []; protected itemsChangedStream: Subject; // Stream to update the hideMenu boolean when items change. + protected instanceId: string; + protected parentContextMenu: CoreContextMenuComponent; - constructor(private translate: TranslateService, private popoverCtrl: PopoverController) { + constructor(private translate: TranslateService, private popoverCtrl: PopoverController, private elementRef: ElementRef, + private domUtils: CoreDomUtilsProvider) { // Create the stream and subscribe to it. We ignore successive changes during 250ms. this.itemsChangedStream = new Subject(); this.itemsChangedStream.auditTime(250).subscribe(() => { @@ -43,7 +47,14 @@ export class CoreContextMenuComponent implements OnInit { this.hideMenu = !this.items.some((item) => { return !item.hidden; }); + + // Sort the items by priority. + this.items.sort((a, b) => { + return a.priority <= b.priority ? 1 : -1; + }); }); + + this.instanceId = this.domUtils.storeInstanceByElement(elementRef.nativeElement, this); } /** @@ -60,15 +71,44 @@ export class CoreContextMenuComponent implements OnInit { * @param {CoreContextMenuItemComponent} item The item to add. */ addItem(item: CoreContextMenuItemComponent): void { - this.items.push(item); - this.itemsChanged(); + if (this.parentContextMenu) { + // All items were moved to the "parent" menu. Add the item in there. + this.parentContextMenu.addItem(item); + } else { + this.items.push(item); + this.itemsChanged(); + } } /** * Function called when the items change. */ itemsChanged(): void { - this.itemsChangedStream.next(); + if (this.parentContextMenu) { + // All items were moved to the "parent" menu, call the function in there. + this.parentContextMenu.itemsChanged(); + } else { + this.itemsChangedStream.next(); + } + } + + /** + * Merge the current context menu with the one passed as parameter. All the items in this menu will be moved to the + * one passed as parameter. + * + * @param {CoreContextMenuComponent} contextMenu The context menu where to move the items. + */ + mergeContextMenus(contextMenu: CoreContextMenuComponent): void { + this.parentContextMenu = contextMenu; + + // Add all the items to the other menu. + for (let i = 0; i < this.items.length; i++) { + contextMenu.addItem(this.items[i]); + } + + // Remove all items from the current menu. + this.items = []; + this.itemsChanged(); } /** @@ -77,11 +117,16 @@ export class CoreContextMenuComponent implements OnInit { * @param {CoreContextMenuItemComponent} item The item to remove. */ removeItem(item: CoreContextMenuItemComponent): void { - const index = this.items.indexOf(item); - if (index >= 0) { - this.items.splice(index, 1); + if (this.parentContextMenu) { + // All items were moved to the "parent" menu. Remove the item from there. + this.parentContextMenu.removeItem(item); + } else { + const index = this.items.indexOf(item); + if (index >= 0) { + this.items.splice(index, 1); + } + this.itemsChanged(); } - this.itemsChanged(); } /** @@ -95,4 +140,11 @@ export class CoreContextMenuComponent implements OnInit { ev: event }); } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.domUtils.removeInstanceById(this.instanceId); + } } diff --git a/src/components/dynamic-component/dynamic-component.ts b/src/components/dynamic-component/dynamic-component.ts index 01fa9532d..2c8e5898b 100644 --- a/src/components/dynamic-component/dynamic-component.ts +++ b/src/components/dynamic-component/dynamic-component.ts @@ -99,6 +99,19 @@ export class CoreDynamicComponent implements OnInit, OnChanges, DoCheck { } } + /** + * Call a certain function on the component. + * + * @param {string} name Name of the function to call. + * @param {any[]} params List of params to send to the function. + * @return {any} Result of the call. Undefined if no component instance or the function doesn't exist. + */ + callComponentFunction(name: string, params: any[]): any { + if (this.instance && typeof this.instance[name] == 'function') { + return this.instance[name].apply(this.instance, params); + } + } + /** * Create a component, add it to a container and set the input data. * diff --git a/src/components/navbar-buttons/navbar-buttons.ts b/src/components/navbar-buttons/navbar-buttons.ts index 98edb3421..3bdb50e26 100644 --- a/src/components/navbar-buttons/navbar-buttons.ts +++ b/src/components/navbar-buttons/navbar-buttons.ts @@ -14,6 +14,7 @@ import { Component, Input, OnInit, ContentChildren, ElementRef, QueryList } from '@angular/core'; import { Button } from 'ionic-angular'; +import { CoreLoggerProvider } from '../../providers/logger'; import { CoreDomUtilsProvider } from '../../providers/utils/dom'; /** @@ -63,41 +64,80 @@ export class CoreNavBarButtonsComponent implements OnInit { protected element: HTMLElement; protected _buttons: QueryList - - {{section.count}} / {{section.total}} + + diff --git a/src/core/course/components/format/format.scss b/src/core/course/components/format/format.scss new file mode 100644 index 000000000..11fd5d99a --- /dev/null +++ b/src/core/course/components/format/format.scss @@ -0,0 +1,6 @@ +ion-badge.core-course-download-section-progress { + display: block; + float: left; + margin-top: 12px; + margin-right: 12px; +} \ No newline at end of file diff --git a/src/core/course/components/format/format.ts b/src/core/course/components/format/format.ts index 999c78ad1..ceb8780ca 100644 --- a/src/core/course/components/format/format.ts +++ b/src/core/course/components/format/format.ts @@ -12,7 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChange, Output, EventEmitter } from '@angular/core'; +import { + Component, Input, OnInit, OnChanges, OnDestroy, SimpleChange, Output, EventEmitter, ViewChildren, QueryList +} from '@angular/core'; import { Content } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; import { CoreEventsProvider } from '../../../../providers/events'; @@ -22,6 +24,7 @@ import { CoreCourseProvider } from '../../../course/providers/course'; import { CoreCourseHelperProvider } from '../../../course/providers/helper'; import { CoreCourseFormatDelegate } from '../../../course/providers/format-delegate'; import { CoreCourseModulePrefetchDelegate } from '../../../course/providers/module-prefetch-delegate'; +import { CoreDynamicComponent } from '../../../../components/dynamic-component/dynamic-component'; /** * Component to display course contents using a certain format. If the format isn't found, use default one. @@ -46,6 +49,8 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { @Input() moduleId?: number; // The module ID to scroll to. Must be inside the initial selected section. @Output() completionChanged?: EventEmitter; // Will emit an event when any module completion changes. + @ViewChildren(CoreDynamicComponent) dynamicComponents: QueryList; + // All the possible component classes. courseFormatComponent: any; courseSummaryComponent: any; @@ -286,6 +291,23 @@ export class CoreCourseFormatComponent implements OnInit, OnChanges, OnDestroy { }); } + /** + * Refresh the data. + * + * @param {any} [refresher] Refresher. + * @param {Function} [done] Function to call when done. + * @return {Promise} Promise resolved when done. + */ + doRefresh(refresher?: any, done?: () => void): Promise { + const promises = []; + + this.dynamicComponents.forEach((component) => { + promises.push(Promise.resolve(component.callComponentFunction('doRefresh', [refresher, done]))); + }); + + return Promise.all(promises); + } + /** * Component destroyed. */ diff --git a/src/core/course/components/module/module.html b/src/core/course/components/module/module.html index 90fc35bb4..84cd92959 100644 --- a/src/core/course/components/module/module.html +++ b/src/core/course/components/module/module.html @@ -1,22 +1,39 @@ +
+ - + +
+ + -
- +
+ + - + + - + + + + + +
+
+ +
+ {{ 'core.course.hiddenfromstudents' | translate }} + +
- -
- {{ 'core.course.hiddenfromstudents' | translate }} - -
- +
\ No newline at end of file diff --git a/src/core/course/components/module/module.scss b/src/core/course/components/module/module.scss index 8eeee189e..c76d9e6f1 100644 --- a/src/core/course/components/module/module.scss +++ b/src/core/course/components/module/module.scss @@ -2,29 +2,116 @@ core-course-module { a.core-course-module-handler { align-items: flex-start; - item-inner { + min-height: 52px; + + &.item .item-inner { padding-right: 0; } + .label { + margin-top: 0; + margin-right: 0; + margin-bottom: 0; + } + .core-module-icon { + align-items: flex-start; + } } - .core-module-icon { - align-items: flex-start; - } - - .core-module-buttons { + .core-module-title { display: flex; flex-flow: row; - align-items: center; - z-index: 1; - cursor: pointer; - pointer-events: auto; - position: absolute; - right: 0; - top: 4px; + align-items: flex-start; - .spinner { - right: 7px; + core-format-text { + flex-grow: 2; + } + .core-module-buttons { + margin: 0; + } + + .core-module-buttons, + .core-module-buttons-more { + display: flex; + flex-flow: row; + align-items: center; + z-index: 1; + } + + .core-module-buttons core-course-module-completion, + .core-module-buttons-more button { + cursor: pointer; + pointer-events: auto; + } + + .core-module-buttons-more .spinner { + right: 13px; position: absolute; } } +} + +.md core-course-module { + .core-module-description, + .core-module-description .core-show-more { + padding-right: $label-md-margin-end; + } + + a.core-course-module-handler .core-module-icon { + margin-top: $label-md-margin-top; + margin-bottom: $label-md-margin-bottom; + } + + .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; + } +} + +.ios core-course-module { + .core-module-description, + .core-module-description .core-show-more { + padding-right: $label-ios-margin-end; + } + + a.core-course-module-handler .core-module-icon { + margin-top: $label-ios-margin-top; + margin-bottom: $label-ios-margin-bottom; + } + + .core-module-title core-format-text { + padding-top: $label-ios-margin-top + 3; + } + + .core-module-buttons-more { + min-height: 53px; + min-width: 58px; + } +} + +.wp core-course-module { + .core-module-description, + .core-module-description .core-show-more { + padding-right: ($item-wp-padding-end / 2); + } + + a.core-course-module-handler .core-module-icon { + margin-top: $item-wp-padding-top; + margin-bottom: $item-wp-padding-bottom; + } + + .core-module-title core-format-text { + padding-top: $item-wp-padding-top + 3; + } + + .button-wp { + margin-top: 8px; + margin-bottom: 8px; + } } \ No newline at end of file diff --git a/src/core/course/components/module/module.ts b/src/core/course/components/module/module.ts index b450093b1..15a66286b 100644 --- a/src/core/course/components/module/module.ts +++ b/src/core/course/components/module/module.ts @@ -12,9 +12,15 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core'; +import { Component, Input, Output, EventEmitter, OnInit, OnDestroy } from '@angular/core'; import { NavController } from 'ionic-angular'; +import { CoreEventsProvider } from '../../../../providers/events'; +import { CoreSitesProvider } from '../../../../providers/sites'; +import { CoreDomUtilsProvider } from '../../../../providers/utils/dom'; +import { CoreCourseHelperProvider } from '../../providers/helper'; import { CoreCourseModuleHandlerButton } from '../../providers/module-delegate'; +import { CoreCourseModulePrefetchDelegate, CoreCourseModulePrefetchHandler } from '../../providers/module-prefetch-delegate'; +import { CoreConstants } from '../../../constants'; /** * Component to display a module entry in a list of modules. @@ -27,12 +33,43 @@ import { CoreCourseModuleHandlerButton } from '../../providers/module-delegate'; selector: 'core-course-module', templateUrl: 'module.html' }) -export class CoreCourseModuleComponent implements OnInit { +export class CoreCourseModuleComponent implements OnInit, OnDestroy { @Input() module: any; // The module to render. @Input() courseId: number; // The course the module belongs to. + @Input('downloadEnabled') set enabled(value: boolean) { + this.downloadEnabled = value; + + if (this.module.handlerData.showDownloadButton && this.downloadEnabled && !this.statusObserver) { + // First time that the download is enabled. Initialize the data. + this.spinner = true; // Show spinner while calculating the status. + + this.prefetchHandler = this.prefetchDelegate.getPrefetchHandlerFor(this.module); + + // Get current status to decide which icon should be shown. + this.prefetchDelegate.getModuleStatus(this.module, this.courseId).then(this.showStatus.bind(this)); + + // Listen for changes on this module status. + this.statusObserver = this.eventsProvider.on(CoreEventsProvider.PACKAGE_STATUS_CHANGED, (data) => { + if (data.componentId === this.module.id && this.prefetchHandler && + data.component === this.prefetchHandler.component) { + this.showStatus(data.status); + } + }, this.sitesProvider.getCurrentSiteId()); + } + } @Output() completionChanged?: EventEmitter; // Will emit an event when the module completion changes. - constructor(private navCtrl: NavController) { + showDownload: boolean; // Whether to display the download button. + showRefresh: boolean; // Whether to display the refresh button. + spinner: boolean; // Whether to display a spinner. + downloadEnabled: boolean; // Whether the download of sections and modules is enabled. + + protected prefetchHandler: CoreCourseModulePrefetchHandler; + protected statusObserver; + + constructor(protected navCtrl: NavController, protected prefetchDelegate: CoreCourseModulePrefetchDelegate, + protected domUtils: CoreDomUtilsProvider, protected courseHelper: CoreCourseHelperProvider, + protected eventsProvider: CoreEventsProvider, protected sitesProvider: CoreSitesProvider) { this.completionChanged = new EventEmitter(); } @@ -68,4 +105,55 @@ export class CoreCourseModuleComponent implements OnInit { button.action(event, this.navCtrl, this.module, this.courseId); } } + + /** + * Download the module. + * + * @param {Event} event Click event. + * @param {boolean} refresh Whether it's refreshing. + */ + download(event: Event, refresh: boolean): void { + event.preventDefault(); + event.stopPropagation(); + + if (!this.prefetchHandler) { + return; + } + + // Show spinner since this operation might take a while. + this.spinner = true; + + // Get download size to ask for confirm if it's high. + this.prefetchHandler.getDownloadSize(module, this.courseId).then((size) => { + this.courseHelper.prefetchModule(this.prefetchHandler, this.module, size, this.courseId, refresh).catch((error) => { + // Error or cancelled. + this.spinner = false; + }); + }).catch((error) => { + // Error getting download size, hide spinner. + this.spinner = false; + this.domUtils.showErrorModalDefault(error, 'core.errordownloading', true); + }); + } + + /** + * Show download buttons according to module status. + * + * @param {string} status Module status. + */ + protected showStatus(status: string): void { + if (status) { + this.spinner = status === CoreConstants.DOWNLOADING; + this.showDownload = status === CoreConstants.NOT_DOWNLOADED; + this.showRefresh = status === CoreConstants.OUTDATED || + (!this.prefetchDelegate.canCheckUpdates() && status === CoreConstants.DOWNLOADED); + } + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.statusObserver && this.statusObserver.off(); + } } diff --git a/src/core/course/formats/singleactivity/components/singleactivity.ts b/src/core/course/formats/singleactivity/components/singleactivity.ts index cd5cae32c..a94f8730f 100644 --- a/src/core/course/formats/singleactivity/components/singleactivity.ts +++ b/src/core/course/formats/singleactivity/components/singleactivity.ts @@ -12,9 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, OnChanges, SimpleChange } from '@angular/core'; +import { Component, Input, OnChanges, SimpleChange, ViewChild } from '@angular/core'; import { CoreCourseModuleDelegate } from '../../../providers/module-delegate'; import { CoreCourseUnsupportedModuleComponent } from '../../../components/unsupported-module/unsupported-module'; +import { CoreDynamicComponent } from '../../../../../components/dynamic-component/dynamic-component'; /** * Component to display single activity format. It will determine the right component to use and instantiate it. @@ -30,6 +31,8 @@ export class CoreCourseFormatSingleActivityComponent implements OnChanges { @Input() sections: any[]; // List of course sections. @Input() downloadEnabled?: boolean; // Whether the download of sections and modules is enabled. + @ViewChild(CoreDynamicComponent) dynamicComponent: CoreDynamicComponent; + componentClass: any; // The class of the component to render. data: any = {}; // Data to pass to the component. @@ -52,4 +55,15 @@ export class CoreCourseFormatSingleActivityComponent implements OnChanges { this.data.module = module; } } + + /** + * Refresh the data. + * + * @param {any} [refresher] Refresher. + * @param {Function} [done] Function to call when done. + * @return {Promise} Promise resolved when done. + */ + doRefresh(refresher?: any, done?: () => void): Promise { + return Promise.resolve(this.dynamicComponent.callComponentFunction('doRefresh', [refresher, done])); + } } diff --git a/src/core/course/formats/singleactivity/providers/handler.ts b/src/core/course/formats/singleactivity/providers/handler.ts index 17f1a5734..ed7d44eef 100644 --- a/src/core/course/formats/singleactivity/providers/handler.ts +++ b/src/core/course/formats/singleactivity/providers/handler.ts @@ -62,6 +62,16 @@ export class CoreCourseFormatSingleActivityHandler implements CoreCourseFormatHa return course.fullname || ''; } + /** + * Whether the option to enable section/module download should be displayed. Defaults to true. + * + * @param {any} course The course to check. + * @return {boolean} Whether the option to enable section/module download should be displayed + */ + displayEnableDownload(course: any): boolean { + return false; + } + /** * Whether the default section selector should be displayed. Defaults to true. * diff --git a/src/core/course/pages/section/section.html b/src/core/course/pages/section/section.html index 2360f470f..8420d3fb3 100644 --- a/src/core/course/pages/section/section.html +++ b/src/core/course/pages/section/section.html @@ -4,8 +4,8 @@ - - + + diff --git a/src/core/course/pages/section/section.ts b/src/core/course/pages/section/section.ts index 35a434feb..d0cd1647c 100644 --- a/src/core/course/pages/section/section.ts +++ b/src/core/course/pages/section/section.ts @@ -24,6 +24,7 @@ import { CoreCourseHelperProvider } from '../../providers/helper'; import { CoreCourseFormatDelegate } from '../../providers/format-delegate'; import { CoreCourseModulePrefetchDelegate } from '../../providers/module-prefetch-delegate'; import { CoreCourseOptionsDelegate, CoreCourseOptionsHandlerToDisplay } from '../../providers/options-delegate'; +import { CoreCourseFormatComponent } from '../../components/format/format'; import { CoreCoursesProvider } from '../../../courses/providers/courses'; /** @@ -36,6 +37,7 @@ import { CoreCoursesProvider } from '../../../courses/providers/courses'; }) export class CoreCourseSectionPage implements OnDestroy { @ViewChild(Content) content: Content; + @ViewChild(CoreCourseFormatComponent) formatComponent: CoreCourseFormatComponent; title: string; course: any; @@ -51,6 +53,7 @@ export class CoreCourseSectionPage implements OnDestroy { prefetchCourseIcon: 'spinner' }; moduleId: number; + displayEnableDownload: boolean; protected module: any; protected completionObserver; @@ -71,6 +74,7 @@ export class CoreCourseSectionPage implements OnDestroy { // Get the title to display. We dont't have sections yet. this.title = courseFormatDelegate.getCourseTitle(this.course); + this.displayEnableDownload = courseFormatDelegate.displayEnableDownload(this.course); this.completionObserver = eventsProvider.on(CoreEventsProvider.COMPLETION_MODULE_VIEWED, (data) => { if (data && data.courseId == this.course.id) { @@ -150,7 +154,19 @@ export class CoreCourseSectionPage implements OnDestroy { promises.push(promise.then((completionStatus) => { // Get all the sections. - promises.push(this.courseProvider.getSections(this.course.id, false, true).then((sections) => { + return this.courseProvider.getSections(this.course.id, false, true).then((sections) => { + if (refresh) { + // Invalidate the recently downloaded module list. To ensure info can be prefetched. + const modules = this.courseProvider.getSectionsModules(sections); + + return this.prefetchDelegate.invalidateModules(modules, this.course.id).then(() => { + return sections; + }); + } else { + return sections; + } + }).then((sections) => { + this.courseHelper.addHandlerDataForModules(sections, this.course.id, completionStatus); // Format the name of each section and check if it has content. @@ -173,7 +189,7 @@ export class CoreCourseSectionPage implements OnDestroy { // Get the title again now that we have sections. this.title = this.courseFormatDelegate.getCourseTitle(this.course, this.sections); - })); + }); })); // Load the course handlers. @@ -195,7 +211,9 @@ export class CoreCourseSectionPage implements OnDestroy { doRefresh(refresher: any): void { this.invalidateData().finally(() => { this.loadData(true).finally(() => { - refresher.complete(); + this.formatComponent.doRefresh(refresher).finally(() => { + refresher.complete(); + }); }); }); } diff --git a/src/core/course/providers/default-format.ts b/src/core/course/providers/default-format.ts index 7112efa56..2bf1991d1 100644 --- a/src/core/course/providers/default-format.ts +++ b/src/core/course/providers/default-format.ts @@ -56,6 +56,16 @@ export class CoreCourseFormatDefaultHandler implements CoreCourseFormatHandler { return true; } + /** + * Whether the option to enable section/module download should be displayed. Defaults to true. + * + * @param {any} course The course to check. + * @return {boolean} Whether the option to enable section/module download should be displayed + */ + displayEnableDownload(course: any): boolean { + return true; + } + /** * Whether the default section selector should be displayed. Defaults to true. * diff --git a/src/core/course/providers/format-delegate.ts b/src/core/course/providers/format-delegate.ts index 27553d52b..48d43bd34 100644 --- a/src/core/course/providers/format-delegate.ts +++ b/src/core/course/providers/format-delegate.ts @@ -43,6 +43,14 @@ export interface CoreCourseFormatHandler extends CoreDelegateHandler { */ canViewAllSections?(course: any): boolean; + /** + * Whether the option to enable section/module download should be displayed. Defaults to true. + * + * @param {any} course The course to check. + * @type {boolean} Whether the option to enable section/module download should be displayed. + */ + displayEnableDownload?(course: any): boolean; + /** * Whether the default section selector should be displayed. Defaults to true. * @@ -150,6 +158,16 @@ export class CoreCourseFormatDelegate extends CoreDelegate { return this.executeFunction(course.format, 'canViewAllSections', [course]); } + /** + * Whether the option to enable section/module download should be displayed. Defaults to true. + * + * @param {any} course The course to check. + * @return {boolean} Whether the option to enable section/module download should be displayed + */ + displayEnableDownload(course: any): boolean { + return this.executeFunction(course.format, 'displayEnableDownload', [course]); + } + /** * Whether the default section selector should be displayed. Defaults to true. * diff --git a/src/core/course/providers/helper.ts b/src/core/course/providers/helper.ts index d4b76e357..36fbc2433 100644 --- a/src/core/course/providers/helper.ts +++ b/src/core/course/providers/helper.ts @@ -15,6 +15,7 @@ import { Injectable } from '@angular/core'; import { NavController } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; +import { CoreEventsProvider } from '../../../providers/events'; import { CoreFilepoolProvider } from '../../../providers/filepool'; import { CoreSitesProvider } from '../../../providers/sites'; import { CoreDomUtilsProvider } from '../../../providers/utils/dom'; @@ -114,7 +115,8 @@ export class CoreCourseHelperProvider { private filepoolProvider: CoreFilepoolProvider, private sitesProvider: CoreSitesProvider, private textUtils: CoreTextUtilsProvider, private timeUtils: CoreTimeUtilsProvider, private utils: CoreUtilsProvider, private translate: TranslateService, private loginHelper: CoreLoginHelperProvider, - private courseOptionsDelegate: CoreCourseOptionsDelegate, private siteHomeProvider: CoreSiteHomeProvider) { } + private courseOptionsDelegate: CoreCourseOptionsDelegate, private siteHomeProvider: CoreSiteHomeProvider, + private eventsProvider: CoreEventsProvider) { } /** * This function treats every module on the sections provided to load the handler data, treat completion @@ -358,8 +360,12 @@ export class CoreCourseHelperProvider { * @return {Promise} Promise resolved when done. */ confirmAndRemoveFiles(module: any, courseId: number): Promise { - return this.domUtils.showConfirm(this.translate.instant('course.confirmdeletemodulefiles')).then(() => { + return this.domUtils.showConfirm(this.translate.instant('core.course.confirmdeletemodulefiles')).then(() => { return this.prefetchDelegate.removeModuleFiles(module, courseId); + }).catch((error) => { + if (error) { + this.domUtils.showErrorModal(error); + } }); } @@ -405,6 +411,39 @@ export class CoreCourseHelperProvider { }); } + /** + * 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 {any} instance The component instance that has the context menu. It should have prefetchStatusIcon and isDestroyed. + * @param {any} module Module to be prefetched + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved when done. + */ + contextMenuPrefetch(instance: any, module: any, courseId: number): Promise { + const initialIcon = instance.prefetchStatusIcon; + let cancelled = false; + + instance.prefetchStatusIcon = 'spinner'; // Show spinner since this operation might take a while. + + // We need to call getDownloadSize, the package might have been updated. + return this.prefetchDelegate.getModuleDownloadSize(module, courseId, true).then((size) => { + return this.domUtils.confirmDownloadSize(size).catch(() => { + // User hasn't confirmed, stop. + cancelled = true; + + return Promise.reject(null); + }).then(() => { + return this.prefetchDelegate.prefetchModule(module, courseId, true); + }); + }).catch((error) => { + instance.prefetchStatusIcon = initialIcon; + if (!instance.isDestroyed && !cancelled) { + this.domUtils.showErrorModalDefault(error, 'core.errordownloading', true); + } + }); + } + /** * Determine the status of a list of courses. * @@ -431,6 +470,41 @@ export class CoreCourseHelperProvider { }); } + /** + * Fill the Context Menu for a certain module. + * + * @param {any} instance The component instance that has the context menu. + * @param {any} module Module to be prefetched + * @param {number} courseId Course ID the module belongs to. + * @param {boolean} [invalidateCache] Invalidates the cache first. + * @param {string} [component] Component of the module. + * @return {Promise} Promise resolved when done. + */ + fillContextMenu(instance: any, module: any, courseId: number, invalidateCache?: boolean, component?: string): Promise { + return this.getModulePrefetchInfo(module, courseId, invalidateCache, component).then((moduleInfo) => { + instance.size = moduleInfo.size > 0 ? moduleInfo.sizeReadable : 0; + instance.prefetchStatusIcon = moduleInfo.statusIcon; + + if (moduleInfo.status != CoreConstants.NOT_DOWNLOADABLE) { + // Module is downloadable, get the text to display to prefetch. + if (moduleInfo.downloadTime > 0) { + instance.prefetchText = this.translate.instant('core.lastdownloaded') + ': ' + moduleInfo.downloadTimeReadable; + } else { + // Module not downloaded, show a default text. + instance.prefetchText = this.translate.instant('core.download'); + } + } + + if (typeof instance.statusObserver == 'undefined' && component) { + instance.statusObserver = this.eventsProvider.on(CoreEventsProvider.PACKAGE_STATUS_CHANGED, (data) => { + if (data.componentId == module.id && data.component == component) { + this.fillContextMenu(instance, module, courseId, false, component); + } + }, this.sitesProvider.getCurrentSiteId()); + } + }); + } + /** * Get a course download promise (if any). * diff --git a/src/core/course/providers/module-delegate.ts b/src/core/course/providers/module-delegate.ts index dd2dec7c9..1a2e8c057 100644 --- a/src/core/course/providers/module-delegate.ts +++ b/src/core/course/providers/module-delegate.ts @@ -37,6 +37,7 @@ export interface CoreCourseModuleHandler extends CoreDelegateHandler { /** * Get the component to render the module. This is needed to support singleactivity course format. + * The component returned must implement CoreCourseModuleMainComponent. * * @param {any} course The course object. * @param {any} module The module object. @@ -67,6 +68,14 @@ export interface CoreCourseModuleHandlerData { */ class?: 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. + * @type {boolean} + */ + showDownloadButton?: boolean; + /** * The buttons to display in the module item. * @type {CoreCourseModuleHandlerButton[]} @@ -91,6 +100,20 @@ export interface CoreCourseModuleHandlerData { action?(event: Event, navCtrl: NavController, module: any, courseId: number, options?: NavOptions): void; } +/** + * Interface that all the components to render the module in singleactivity must implement. + */ +export interface CoreCourseModuleMainComponent { + /** + * Refresh the data. + * + * @param {any} [refresher] Refresher. + * @param {Function} [done] Function to call when done. + * @return {Promise} Promise resolved when done. + */ + doRefresh(refresher?: any, done?: () => void): Promise; +} + /** * A button to display in a module item. */ diff --git a/src/core/course/providers/module-prefetch-delegate.ts b/src/core/course/providers/module-prefetch-delegate.ts index 6980c3cad..a80e9c74f 100644 --- a/src/core/course/providers/module-prefetch-delegate.ts +++ b/src/core/course/providers/module-prefetch-delegate.ts @@ -202,9 +202,6 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { }; protected ROOT_CACHE_KEY = 'mmCourse:'; - - protected handlers: { [s: string]: CoreCourseModulePrefetchHandler } = {}; // All registered handlers. - protected enabledHandlers: { [s: string]: CoreCourseModulePrefetchHandler } = {}; // Handlers enabled for the current site. protected statusCache = new CoreCache(); // Promises for check updates, to prevent performing the same request twice at the same time. @@ -225,9 +222,14 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { private courseProvider: CoreCourseProvider, private filepoolProvider: CoreFilepoolProvider, private timeUtils: CoreTimeUtilsProvider, private fileProvider: CoreFileProvider, protected eventsProvider: CoreEventsProvider) { - super('CoreCourseModulePrefetchDelegate', loggerProvider, sitesProvider); + super('CoreCourseModulePrefetchDelegate', loggerProvider, sitesProvider, eventsProvider); this.sitesProvider.createTableFromSchema(this.checkUpdatesTableSchema); + + eventsProvider.on(CoreEventsProvider.LOGOUT, this.clearStatusCache.bind(this)); + eventsProvider.on(CoreEventsProvider.PACKAGE_STATUS_CHANGED, (data) => { + this.updateStatusCache(data.status, data.component, data.componentId); + }, this.sitesProvider.getCurrentSiteId()); } /** @@ -656,6 +658,8 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { promise; if (!refresh && typeof status != 'undefined') { + this.storeCourseAndSection(packageId, courseId, sectionId); + return Promise.resolve(this.determineModuleStatus(module, status, canCheck)); } @@ -667,7 +671,7 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { // Get the saved package status. return this.filepoolProvider.getPackageStatus(siteId, component, module.id).then((currentStatus) => { - status = handler.determineStatus ? handler.determineStatus(module, status, canCheck) : status; + status = handler.determineStatus ? handler.determineStatus(module, currentStatus, canCheck) : currentStatus; if (status != CoreConstants.DOWNLOADED) { return status; } @@ -699,7 +703,7 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { // Has updates, mark the module as outdated. status = CoreConstants.OUTDATED; - return this.filepoolProvider.storePackageStatus(siteId, component, module.id, status).catch(() => { + return this.filepoolProvider.storePackageStatus(siteId, status, component, module.id).catch(() => { // Ignore errors. }).then(() => { return status; @@ -713,13 +717,14 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { }, () => { // Error getting updates, show the stored status. updateStatus = false; + this.storeCourseAndSection(packageId, courseId, sectionId); return currentStatus; }); }); }).then((status) => { if (updateStatus) { - this.updateStatusCache(status, courseId, component, module.id, sectionId); + this.updateStatusCache(status, component, module.id, courseId, sectionId); } return this.determineModuleStatus(module, status, canCheck); @@ -773,11 +778,6 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { promises.push(this.getModuleStatus(module, courseId, updates, refresh).then((modStatus) => { if (modStatus != CoreConstants.NOT_DOWNLOADABLE) { - if (sectionId && sectionId > 0) { - // Store the section ID. - this.statusCache.setValue(packageId, 'sectionId', sectionId); - } - status = this.filepoolProvider.determinePackagesStatus(status, modStatus); result[modStatus].push(module); result.total++; @@ -859,7 +859,7 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { * @return {CoreCourseModulePrefetchHandler} Prefetch handler. */ getPrefetchHandlerFor(module: any): CoreCourseModulePrefetchHandler { - return this.enabledHandlers[module.modname]; + return this.getHandler(module.modname, true); } /** @@ -1126,7 +1126,8 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { // Update status of the module. const packageId = this.filepoolProvider.getPackageId(handler.component, module.id); this.statusCache.setValue(packageId, 'downloadedSize', 0); - this.filepoolProvider.storePackageStatus(siteId, handler.component, module.id, CoreConstants.NOT_DOWNLOADED); + + return this.filepoolProvider.storePackageStatus(siteId, CoreConstants.NOT_DOWNLOADED, handler.component, module.id); } }); } @@ -1147,6 +1148,22 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { } } + /** + * If courseId or sectionId is set, save them in the cache. + * + * @param {string} packageId The package ID. + * @param {number} [courseId] Course ID. + * @param {number} [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. * @@ -1184,12 +1201,12 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { * Update the status of a module in the "cache". * * @param {string} status New status. - * @param {number} courseId Course ID of the module. * @param {string} component Package's component. * @param {string|number} [componentId] An ID to use in conjunction with the component. + * @param {number} [courseId] Course ID of the module. * @param {number} [sectionId] Section ID of the module. */ - updateStatusCache(status: string, courseId: number, component: string, componentId?: string | number, sectionId?: number) + updateStatusCache(status: string, component: string, componentId?: string | number, courseId?: number, sectionId?: number) : void { const packageId = this.filepoolProvider.getPackageId(component, componentId), cachedStatus = this.statusCache.getValue(packageId, 'status', true); @@ -1198,7 +1215,13 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { // If the status has changed, notify that the section has changed. notify = typeof cachedStatus != 'undefined' && cachedStatus !== status; + // If courseId/sectionId is set, store it. + this.storeCourseAndSection(packageId, courseId, sectionId); + if (notify) { + if (!courseId) { + courseId = this.statusCache.getValue(packageId, 'courseId', true); + } if (!sectionId) { sectionId = this.statusCache.getValue(packageId, 'sectionId', true); } @@ -1208,8 +1231,6 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { this.statusCache.setValue(packageId, 'status', status); if (sectionId) { - this.statusCache.setValue(packageId, 'sectionId', sectionId); - this.eventsProvider.trigger(CoreEventsProvider.SECTION_STATUS_CHANGED, { sectionId: sectionId, courseId: courseId diff --git a/src/core/user/providers/user-profile-field-delegate.ts b/src/core/user/providers/user-profile-field-delegate.ts index 5f924896d..c3e8b7282 100644 --- a/src/core/user/providers/user-profile-field-delegate.ts +++ b/src/core/user/providers/user-profile-field-delegate.ts @@ -99,7 +99,8 @@ export class CoreUserProfileFieldDelegate extends CoreDelegate { */ getDataForField(field: any, signup: boolean, registerAuth: string, formValues: any): Promise { const type = field.type || field.datatype, - handler = this.getHandler(type, !signup); + handler = this.getHandler(type, !signup); + if (handler) { const name = 'profile_field_' + field.shortname; if (handler.getData) { diff --git a/src/directives/link.ts b/src/directives/link.ts index d565a90c5..2069ce479 100644 --- a/src/directives/link.ts +++ b/src/directives/link.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Directive, Input, OnInit, ElementRef } from '@angular/core'; +import { Directive, Input, OnInit, ElementRef, Optional } from '@angular/core'; import { NavController, Content } from 'ionic-angular'; import { CoreSitesProvider } from '../providers/sites'; import { CoreDomUtilsProvider } from '../providers/utils/dom'; @@ -40,7 +40,7 @@ export class CoreLinkDirective implements OnInit { constructor(element: ElementRef, private domUtils: CoreDomUtilsProvider, private utils: CoreUtilsProvider, private sitesProvider: CoreSitesProvider, private urlUtils: CoreUrlUtilsProvider, private contentLinksHelper: CoreContentLinksHelperProvider, private navCtrl: NavController, - private content: Content) { + @Optional() private content: Content) { // This directive can be added dynamically. In that case, the first param is the anchor HTMLElement. this.element = element.nativeElement || element; } @@ -81,7 +81,7 @@ export class CoreLinkDirective implements OnInit { protected navigate(href: string): void { const contentLinksScheme = CoreConfigConstants.customurlscheme + '://link='; - if (href.indexOf('cdvfile://') === 0 || href.indexOf('file://') === 0) { + if (href.indexOf('cdvfile://') === 0 || href.indexOf('file://') === 0 || href.indexOf('filesystem:') === 0) { // We have a local file. this.utils.openFile(href).catch((error) => { this.domUtils.showErrorModal(error); diff --git a/src/providers/filepool.ts b/src/providers/filepool.ts index 80388199e..ab2dba3e6 100644 --- a/src/providers/filepool.ts +++ b/src/providers/filepool.ts @@ -477,7 +477,7 @@ export class CoreFilepoolProvider { componentId: componentId || '' }; - return db.insertOrUpdateRecord(this.LINKS_TABLE, newEntry, undefined); + return db.insertOrUpdateRecord(this.LINKS_TABLE, newEntry, { fileId: fileId }); }); } @@ -1178,7 +1178,9 @@ export class CoreFilepoolProvider { return this.downloadForPoolByUrl(siteId, fileUrl, options, filePath, onProgress); }).then((response) => { if (typeof component != 'undefined') { - this.addFileLink(siteId, fileId, component, componentId); + this.addFileLink(siteId, fileId, component, componentId).catch(() => { + // Ignore errors. + }); } this.notifyFileDownloaded(siteId, fileId); @@ -2237,9 +2239,11 @@ export class CoreFilepoolProvider { }), whereAndParams = db.getInOrEqual(fileIds); + whereAndParams[0] = 'fileId ' + whereAndParams[0]; + if (onlyUnknown) { whereAndParams[0] += ' AND (isexternalfile = ? OR (revision < ? AND timemodified = ?))'; - whereAndParams[1] = whereAndParams[1].params.concat([0, 1, 0]); + whereAndParams[1] = whereAndParams[1].concat([0, 1, 0]); } return db.updateRecordsWhere(this.FILES_TABLE, { stale: 1 }, whereAndParams[0], whereAndParams[1]); @@ -2443,8 +2447,12 @@ export class CoreFilepoolProvider { if (entry && !this.isFileOutdated(entry, options.revision, options.timemodified)) { // We have the file, it is not stale, we can update links and remove from queue. this.logger.debug('Queued file already in store, ignoring...'); - this.addFileLinks(siteId, fileId, links); - this.removeFromQueue(siteId, fileId).finally(() => { + this.addFileLinks(siteId, fileId, links).catch(() => { + // Ignore errors. + }); + this.removeFromQueue(siteId, fileId).catch(() => { + // Ignore errors. + }).finally(() => { this.treatQueueDeferred(siteId, fileId, true); }); this.notifyFileDownloaded(siteId, fileId); @@ -2457,7 +2465,9 @@ export class CoreFilepoolProvider { return this.downloadForPoolByUrl(siteId, fileUrl, options, filePath, onProgress, entry).then(() => { // Success, we add links and remove from queue. - this.addFileLinks(siteId, fileId, links); + this.addFileLinks(siteId, fileId, links).catch(() => { + // Ignore errors. + }); this.treatQueueDeferred(siteId, fileId, true); this.notifyFileDownloaded(siteId, fileId); diff --git a/src/providers/utils/dom.ts b/src/providers/utils/dom.ts index 3578d8b9a..7706c15fd 100644 --- a/src/providers/utils/dom.ts +++ b/src/providers/utils/dom.ts @@ -32,9 +32,12 @@ export class CoreDomUtilsProvider { // List of input types that support keyboard. protected INPUT_SUPPORT_KEYBOARD = ['date', 'datetime', 'datetime-local', 'email', 'month', 'number', 'password', 'search', 'tel', 'text', 'time', 'url', 'week']; + protected INSTANCE_ID_ATTR_NAME = 'core-instance-id'; protected element = document.createElement('div'); // Fake element to use in some functions, to prevent creating it each time. protected matchesFn: string; // Name of the "matches" function to use when simulating a closest call. + protected instances: {[id: string]: any} = {}; // Store component/directive instances by id. + protected lastInstanceId = 0; constructor(private translate: TranslateService, private loadingCtrl: LoadingController, private toastCtrl: ToastController, private alertCtrl: AlertController, private textUtils: CoreTextUtilsProvider, private appProvider: CoreAppProvider, @@ -410,6 +413,20 @@ export class CoreDomUtilsProvider { return this.textUtils.decodeHTML(this.translate.instant('core.error')); } + /** + * Retrieve component/directive instance. + * Please use this function only if you cannot retrieve the instance using parent/child methods: ViewChild (or similar) + * or Angular's injection. + * + * @param {Element} element The root element of the component/directive. + * @return {any} The instance, undefined if not found. + */ + getInstanceByElement(element: Element): any { + const id = element.getAttribute(this.INSTANCE_ID_ATTR_NAME); + + return this.instances[id]; + } + /** * Check if an element is outside of screen (viewport). * @@ -513,6 +530,25 @@ export class CoreDomUtilsProvider { return this.element.innerHTML; } + /** + * Remove a component/directive instance using the DOM Element. + * + * @param {Element} element The root element of the component/directive. + */ + removeInstanceByElement(element: Element): void { + const id = element.getAttribute(this.INSTANCE_ID_ATTR_NAME); + delete this.instances[id]; + } + + /** + * Remove a component/directive instance using the ID. + * + * @param {string} id The ID to remove. + */ + removeInstanceById(id: string): void { + delete this.instances[id]; + } + /** * Search for certain classes in an element contents and replace them with the specified new values. * @@ -547,28 +583,26 @@ export class CoreDomUtilsProvider { // Treat elements with src (img, audio, video, ...). media = this.element.querySelectorAll('img, video, audio, source, track'); - for (const i in media) { - const el = media[i]; - let newSrc = paths[this.textUtils.decodeURIComponent(el.getAttribute('src'))]; + media.forEach((media: HTMLElement) => { + let newSrc = paths[this.textUtils.decodeURIComponent(media.getAttribute('src'))]; if (typeof newSrc != 'undefined') { - el.setAttribute('src', newSrc); + media.setAttribute('src', newSrc); } // Treat video posters. - if (el.tagName == 'VIDEO' && el.getAttribute('poster')) { - newSrc = paths[this.textUtils.decodeURIComponent(el.getAttribute('poster'))]; + if (media.tagName == 'VIDEO' && media.getAttribute('poster')) { + newSrc = paths[this.textUtils.decodeURIComponent(media.getAttribute('poster'))]; if (typeof newSrc !== 'undefined') { - el.setAttribute('poster', newSrc); + media.setAttribute('poster', newSrc); } } - } + }); // Now treat links. anchors = this.element.querySelectorAll('a'); - for (const i in anchors) { - const anchor = anchors[i], - href = this.textUtils.decodeURIComponent(anchor.getAttribute('href')), + anchors.forEach((anchor: HTMLElement) => { + const href = this.textUtils.decodeURIComponent(anchor.getAttribute('href')), newUrl = paths[href]; if (typeof newUrl != 'undefined') { @@ -578,7 +612,7 @@ export class CoreDomUtilsProvider { anchorFn(anchor, href); } } - } + }); return this.element.innerHTML; } @@ -885,6 +919,22 @@ export class CoreDomUtilsProvider { return loader; } + /** + * Stores a component/directive instance. + * + * @param {Element} element The root element of the component/directive. + * @param {any} instance The instance to store. + * @return {string} ID to identify the instance. + */ + storeInstanceByElement(element: Element, instance: any): string { + const id = String(this.lastInstanceId++); + + element.setAttribute(this.INSTANCE_ID_ATTR_NAME, id); + this.instances[id] = instance; + + return id; + } + /** * Check if an element supports input via keyboard. *