diff --git a/src/components/compile-html/compile-html.ts b/src/components/compile-html/compile-html.ts index 26b3cc480..44cb24bac 100644 --- a/src/components/compile-html/compile-html.ts +++ b/src/components/compile-html/compile-html.ts @@ -28,6 +28,7 @@ import { CoreComponentsModule } from '../components.module'; import { CoreDirectivesModule } from '../../directives/directives.module'; import { CorePipesModule } from '../../pipes/pipes.module'; import { CoreCourseComponentsModule } from '../../core/course/components/components.module'; +import { CoreCourseDirectivesModule } from '../../core/course/directives/directives.module'; import { CoreCoursesComponentsModule } from '../../core/courses/components/components.module'; import { CoreSiteHomeComponentsModule } from '../../core/sitehome/components/components.module'; import { CoreUserComponentsModule } from '../../core/user/components/components.module'; @@ -80,7 +81,8 @@ export class CoreCompileHtmlComponent implements OnChanges, OnDestroy { // List of imports for dynamic module. Since the template can have any component we need to import all core components modules. protected IMPORTS = [ IonicModule, TranslateModule.forChild(), CoreComponentsModule, CoreDirectivesModule, CorePipesModule, - CoreCourseComponentsModule, CoreCoursesComponentsModule, CoreSiteHomeComponentsModule, CoreUserComponentsModule + CoreCourseComponentsModule, CoreCoursesComponentsModule, CoreSiteHomeComponentsModule, CoreUserComponentsModule, + CoreCourseDirectivesModule ]; // Other Ionic/Angular providers that don't depend on where they are injected. diff --git a/src/core/course/directives/directives.module.ts b/src/core/course/directives/directives.module.ts new file mode 100644 index 000000000..9b43d41cc --- /dev/null +++ b/src/core/course/directives/directives.module.ts @@ -0,0 +1,27 @@ +// (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 { CoreCourseDownloadModuleMainFileDirective } from './download-module-main-file'; + +@NgModule({ + declarations: [ + CoreCourseDownloadModuleMainFileDirective + ], + imports: [], + exports: [ + CoreCourseDownloadModuleMainFileDirective + ] +}) +export class CoreCourseDirectivesModule {} diff --git a/src/core/course/directives/download-module-main-file.ts b/src/core/course/directives/download-module-main-file.ts new file mode 100644 index 000000000..b2a8d4324 --- /dev/null +++ b/src/core/course/directives/download-module-main-file.ts @@ -0,0 +1,83 @@ +// (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 { Directive, Input, OnInit, ElementRef } from '@angular/core'; +import { NavController } from 'ionic-angular'; +import { CoreCourseProvider } from '../providers/course'; +import { CoreCourseHelperProvider } from '../providers/helper'; +import { CoreDomUtilsProvider } from '../../../providers/utils/dom'; +import { CoreUtilsProvider } from '../../../providers/utils/utils'; + +/** + * Directive to allow downloading and open the main file of a module. + * When the item with this directive is clicked, the module will be downloaded (if needed) and opened. + * This is meant for modules like mod_resource. + * + * This directive must receive either a module or a moduleId. If no files are provided, it will use module.contents. + */ +@Directive({ + selector: '[core-course-download-module-main-file]' +}) +export class CoreCourseDownloadModuleMainFileDirective implements OnInit { + @Input() module: any; // The module. + @Input() moduleId: string | number; // The module ID. Required if module is not supplied. + @Input() courseId: string | number; // The course ID. + @Input() component?: string; // Component to link the file to. + @Input() componentId?: string | number; // Component ID to use in conjunction with the component. If not defined, use moduleId. + @Input() files?: any[]; // List of files of the module. If not provided, use module.contents. + + protected element: HTMLElement; + + constructor(element: ElementRef, protected domUtils: CoreDomUtilsProvider, protected courseHelper: CoreCourseHelperProvider, + protected courseProvider: CoreCourseProvider) { + this.element = element.nativeElement || element; + } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.element.addEventListener('click', (ev: Event): void => { + if (!this.module && !this.moduleId) { + return; + } + + ev.preventDefault(); + ev.stopPropagation(); + + const modal = this.domUtils.showModalLoading(), + courseId = typeof this.courseId == 'string' ? parseInt(this.courseId, 10) : this.courseId; + let promise; + + if (this.module) { + // We already have the module. + promise = Promise.resolve(module); + } else { + // Try to get the module from cache. + this.moduleId = typeof this.moduleId == 'string' ? parseInt(this.moduleId, 10) : this.moduleId; + promise = this.courseProvider.getModule(this.moduleId, courseId); + } + + promise.then((module) => { + const componentId = this.componentId || module.id; + + return this.courseHelper.downloadModuleAndOpenFile(module, courseId, this.component, componentId, this.files); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.errordownloading', true); + }).finally(() => { + modal.dismiss(); + }); + }); + } +} diff --git a/src/core/course/providers/course.ts b/src/core/course/providers/course.ts index 9239b8921..083ed039f 100644 --- a/src/core/course/providers/course.ts +++ b/src/core/course/providers/course.ts @@ -201,7 +201,7 @@ export class CoreCourseProvider { * @return {Promise} Promise resolved with the module. */ getModule(moduleId: number, courseId?: number, sectionId?: number, preferCache?: boolean, ignoreCache?: boolean, - siteId?: string): Promise { + siteId?: string): Promise { siteId = siteId || this.sitesProvider.getCurrentSiteId(); let promise; diff --git a/src/core/course/providers/helper.ts b/src/core/course/providers/helper.ts index fedffa140..5bd0893d9 100644 --- a/src/core/course/providers/helper.ts +++ b/src/core/course/providers/helper.ts @@ -15,8 +15,11 @@ import { Injectable } from '@angular/core'; import { NavController } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; +import { CoreAppProvider } from '@providers/app'; import { CoreEventsProvider } from '@providers/events'; +import { CoreFileProvider } from '@providers/file'; import { CoreFilepoolProvider } from '@providers/filepool'; +import { CoreFileHelperProvider } from '@providers/file-helper'; import { CoreSitesProvider } from '@providers/sites'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTextUtilsProvider } from '@providers/utils/text'; @@ -116,7 +119,8 @@ export class CoreCourseHelperProvider { private textUtils: CoreTextUtilsProvider, private timeUtils: CoreTimeUtilsProvider, private utils: CoreUtilsProvider, private translate: TranslateService, private loginHelper: CoreLoginHelperProvider, private courseOptionsDelegate: CoreCourseOptionsDelegate, private siteHomeProvider: CoreSiteHomeProvider, - private eventsProvider: CoreEventsProvider) { } + private eventsProvider: CoreEventsProvider, private fileHelper: CoreFileHelperProvider, + private appProvider: CoreAppProvider, private fileProvider: CoreFileProvider) { } /** * This function treats every module on the sections provided to load the handler data, treat completion @@ -470,6 +474,227 @@ export class CoreCourseHelperProvider { }); } + /** + * Convenience function to open a module main file, downloading the package if needed. + * This is meant for modules like mod_resource. + * + * @param {any} module The module to download. + * @param {number} courseId The course ID of the module. + * @param {string} [component] The component to link the files to. + * @param {string|number} [componentId] An ID to use in conjunction with the component. + * @param {any[]} [files] List of files of the module. If not provided, use module.contents. + * @param {string} [siteId] The site ID. If not defined, current site. + * @return {Promise} Resolved on success. + */ + downloadModuleAndOpenFile(module: any, courseId: number, component?: string, componentId?: string | number, files?: any[], + siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + let promise; + if (files) { + promise = Promise.resolve(files); + } else { + promise = this.courseProvider.loadModuleContents(module, courseId).then(() => { + files = module.contents; + }); + } + + // Make sure that module contents are loaded. + return promise.then(() => { + if (!files || !files.length) { + return Promise.reject(null); + } + + return this.sitesProvider.getSite(siteId); + }).then((site) => { + const mainFile = files[0], + fileUrl = this.fileHelper.getFileUrl(mainFile); + + // Check if the file should be opened in browser. + if (this.fileHelper.shouldOpenInBrowser(mainFile)) { + if (this.appProvider.isOnline()) { + // Open in browser. + let fixedUrl = site.fixPluginfileURL(fileUrl).replace('&offline=1', ''); + // Remove forcedownload when followed by another param. + fixedUrl = fixedUrl.replace(/forcedownload=\d+&/, ''); + // Remove forcedownload when not followed by any param. + fixedUrl = fixedUrl.replace(/[\?|\&]forcedownload=\d+/, ''); + + this.utils.openInBrowser(fixedUrl); + + if (this.fileProvider.isAvailable()) { + // Download the file if needed (file outdated or not downloaded). + // Download will be in background, don't return the promise. + this.downloadModule(module, courseId, component, componentId, files, siteId); + } + + return; + } else { + // Not online, get the offline file. It will fail if not found. + return this.filepoolProvider.getInternalUrlByUrl(siteId, fileUrl).then((path) => { + return this.utils.openFile(path); + }).catch((error) => { + return Promise.reject(this.translate.instant('core.networkerrormsg')); + }); + } + } + + // File shouldn't be opened in browser. Download the module if it needs to be downloaded. + return this.downloadModuleWithMainFileIfNeeded(module, courseId, component, componentId, files, siteId) + .then((result) => { + if (result.path.indexOf('http') === 0) { + return this.utils.openOnlineFile(result.path).catch((error) => { + // Error opening the file, some apps don't allow opening online files. + if (!this.fileProvider.isAvailable()) { + return Promise.reject(error); + } else if (result.status === CoreConstants.DOWNLOADING) { + return Promise.reject(this.translate.instant('core.erroropenfiledownloading')); + } + + let promise; + if (result.status === CoreConstants.NOT_DOWNLOADED) { + // Not downloaded, download it now and return the local file. + promise = this.downloadModule(module, courseId, component, componentId, files, siteId).then(() => { + return this.filepoolProvider.getInternalUrlByUrl(siteId, fileUrl); + }); + } else { + // File is outdated or stale and can't be opened in online, return the local URL. + promise = this.filepoolProvider.getInternalUrlByUrl(siteId, fileUrl); + } + + return promise.then((path) => { + return this.utils.openFile(path); + }); + }); + } else { + return this.utils.openFile(result.path); + } + }); + }); + } + + /** + * Convenience function to download a module that has a main file and return the local file's path and other info. + * This is meant for modules like mod_resource. + * + * @param {any} module The module to download. + * @param {number} courseId The course ID of the module. + * @param {string} [component] The component to link the files to. + * @param {string|number} [componentId] An ID to use in conjunction with the component. + * @param {any[]} [files] List of files of the module. If not provided, use module.contents. + * @param {string} [siteId] The site ID. If not defined, current site. + * @return {Promise<{fixedUrl: string, path: string, status: string}>} Promise resolved when done. + */ + protected downloadModuleWithMainFileIfNeeded(module: any, courseId: number, component?: string, componentId?: string | number, + files?: any[], siteId?: string): Promise<{fixedUrl: string, path: string, status: string}> { + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + if (!files || !files.length) { + // Module not valid, stop. + return Promise.reject(null); + } + + const mainFile = files[0], + fileUrl = this.fileHelper.getFileUrl(mainFile), + timemodified = this.fileHelper.getFileTimemodified(mainFile), + prefetchHandler = this.prefetchDelegate.getPrefetchHandlerFor(module), + result = { + fixedUrl: undefined, + path: undefined, + status: undefined + }; + + return this.sitesProvider.getSite(siteId).then((site) => { + const fixedUrl = site.fixPluginfileURL(fileUrl); + result.fixedUrl = fixedUrl; + + if (this.fileProvider.isAvailable()) { + // The file system is available. + return this.filepoolProvider.getPackageStatus(siteId, component, componentId).then((status) => { + result.status = status; + + const isWifi = !this.appProvider.isNetworkAccessLimited(), + isOnline = this.appProvider.isOnline(); + + if (status === CoreConstants.DOWNLOADED) { + // Get the local file URL. + return this.filepoolProvider.getInternalUrlByUrl(siteId, fileUrl); + } else if (status === CoreConstants.DOWNLOADING && !this.appProvider.isDesktop()) { + // Return the online URL. + return fixedUrl; + } else { + if (!isOnline && status === CoreConstants.NOT_DOWNLOADED) { + // Not downloaded and we're offline, reject. + return Promise.reject(this.translate.instant('core.networkerrormsg')); + } + + return this.filepoolProvider.shouldDownloadBeforeOpen(fixedUrl, mainFile.filesize).then(() => { + // Download and then return the local URL. + return this.downloadModule(module, courseId, component, componentId, files, siteId).then(() => { + return this.filepoolProvider.getInternalUrlByUrl(siteId, fileUrl); + }); + }, () => { + // Start the download if in wifi, but return the URL right away so the file is opened. + if (isWifi && isOnline) { + this.downloadModule(module, courseId, component, componentId, files, siteId); + } + + if (!this.fileHelper.isStateDownloaded(status) || isOnline) { + // Not downloaded or online, return the online URL. + return fixedUrl; + } else { + // Outdated but offline, so we return the local URL. Use getUrlByUrl so it's added to the queue. + return this.filepoolProvider.getUrlByUrl(siteId, fileUrl, component, componentId, timemodified, + false, false, mainFile); + } + }); + } + }).then((path) => { + result.path = path; + + return result; + }); + } else { + // We use the live URL. + result.path = fixedUrl; + + return result; + } + }); + } + + /** + * Convenience function to download a module. + * + * @param {any} module The module to download. + * @param {number} courseId The course ID of the module. + * @param {string} [component] The component to link the files to. + * @param {string|number} [componentId] An ID to use in conjunction with the component. + * @param {any[]} [files] List of files of the module. If not provided, use module.contents. + * @param {string} [siteId] The site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + downloadModule(module: any, courseId: number, component?: string, componentId?: string | number, files?: any[], siteId?: string) + : Promise { + + const prefetchHandler = this.prefetchDelegate.getPrefetchHandlerFor(module); + + if (prefetchHandler) { + // Use the prefetch handler to download the module. + if (prefetchHandler.download) { + return prefetchHandler.download(module, courseId); + } else { + return prefetchHandler.prefetch(module, courseId, true); + } + } + + // There's no prefetch handler for the module, just download the files. + files = files || module.contents; + + return this.filepoolProvider.downloadOrPrefetchFiles(siteId, files, false, false, component, componentId); + } + /** * Fill the Context Menu for a certain module. * diff --git a/src/core/course/providers/module-prefetch-delegate.ts b/src/core/course/providers/module-prefetch-delegate.ts index dce46a02a..84f4b0b15 100644 --- a/src/core/course/providers/module-prefetch-delegate.ts +++ b/src/core/course/providers/module-prefetch-delegate.ts @@ -86,9 +86,20 @@ export interface CoreCourseModulePrefetchHandler extends CoreDelegateHandler { * @param {any} module Module. * @param {number} courseId Course ID the module belongs to. * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @param {string} [dirPath] Path of the directory where to store all the content files. * @return {Promise} Promise resolved when done. */ - prefetch(module: any, courseId?: number, single?: boolean): Promise; + prefetch(module: any, courseId?: number, single?: boolean, dirPath?: string): Promise; + + /** + * Download the module. + * + * @param {any} module The module object returned by WS. + * @param {number} courseId Course ID. + * @param {string} [dirPath] Path of the directory where to store all the content files. + * @return {Promise} Promise resolved when all content is downloaded. + */ + download?(module: any, courseId: number, dirPath?: string): Promise; /** * Check if a certain module can use core_course_check_updates to check if it has updates. diff --git a/src/core/siteaddons/classes/module-prefetch-handler.ts b/src/core/siteaddons/classes/module-prefetch-handler.ts index 3972f0d56..f87750000 100644 --- a/src/core/siteaddons/classes/module-prefetch-handler.ts +++ b/src/core/siteaddons/classes/module-prefetch-handler.ts @@ -109,7 +109,17 @@ export class CoreSiteAddonsModulePrefetchHandler extends CoreCourseModulePrefetc promises.push(this.siteAddonsProvider.callWS(method, params, {cacheKey: cacheKey})); } else { // It's a method to get content. - promises.push(this.siteAddonsProvider.getContent(this.component, method, args)); + promises.push(this.siteAddonsProvider.getContent(this.component, method, args).then((result) => { + const subPromises = []; + + // Prefetch the files in the content. + if (result.files && result.files.length) { + subPromises.push(this.filepoolProvider.downloadOrPrefetchFiles(siteId, result.files, prefetch, false, + this.component, module.id, dirPath)); + } + + return Promise.all(subPromises); + })); } } diff --git a/src/core/siteaddons/providers/siteaddons.ts b/src/core/siteaddons/providers/siteaddons.ts index fdd3dff6d..37be3d7fe 100644 --- a/src/core/siteaddons/providers/siteaddons.ts +++ b/src/core/siteaddons/providers/siteaddons.ts @@ -43,6 +43,32 @@ export interface CoreSiteAddonsModuleHandler { handlerSchema: any; } +export interface CoreSiteAddonsGetContentResult { + /** + * The content in HTML. + * @type {string} + */ + html: string; + + /** + * The javascript for the content. + * @type {string} + */ + javascript: string; + + /** + * The files for the content. + * @type {any[]} + */ + files?: any[]; + + /** + * Other data. + * @type {any} + */ + otherdata?: any; +} + /** * Service to provide functionalities regarding site addons. */ @@ -104,9 +130,9 @@ export class CoreSiteAddonsProvider { * @param {string} method Method to execute in the class. * @param {any} args The params for the method. * @param {string} [siteId] Site ID. If not defined, current site. - * @return {Promise<{html: string, javascript: string}>} Promise resolved with the content and the javascript. + * @return {Promise} Promise resolved with the result. */ - getContent(component: string, method: string, args: any, siteId?: string): Promise<{html: string, javascript: string}> { + getContent(component: string, method: string, args: any, siteId?: string): Promise { this.logger.debug(`Get content for component '${component}' and method '${method}'`); return this.sitesProvider.getSite(siteId).then((site) => { @@ -129,6 +155,16 @@ export class CoreSiteAddonsProvider { }; return this.sitesProvider.getCurrentSite().read('tool_mobile_get_content', data, preSets); + }).then((result) => { + if (result.otherdata) { + try { + result.otherdata = JSON.parse(result.otherdata); + } catch (ex) { + // Ignore errors. + } + } + + return result; }); }); } diff --git a/src/providers/file-helper.ts b/src/providers/file-helper.ts index a7a936747..88feef0dc 100644 --- a/src/providers/file-helper.ts +++ b/src/providers/file-helper.ts @@ -237,4 +237,31 @@ export class CoreFileHelperProvider { isStateDownloaded(state: string): boolean { return state === CoreConstants.DOWNLOADED || state === CoreConstants.OUTDATED; } + + /** + * Whether the file has to be opened in browser (external repository). + * The file must have a mimetype attribute. + * + * @param {any} file The file to check. + * @return {boolean} Whether the file should be opened in browser. + */ + shouldOpenInBrowser(file: any): boolean { + if (!file || !file.isexternalfile || !file.mimetype) { + return false; + } + + const mimetype = file.mimetype; + if (mimetype.indexOf('application/vnd.google-apps.') != -1) { + // Google Docs file, always open in browser. + return true; + } + + if (file.repositorytype == 'onedrive') { + // In OneDrive, open in browser the office docs + return mimetype.indexOf('application/vnd.openxmlformats-officedocument') != -1 || + mimetype == 'text/plain' || mimetype == 'document/unknown'; + } + + return false; + } } diff --git a/src/providers/filepool.ts b/src/providers/filepool.ts index 259ba617f..c6fa08632 100644 --- a/src/providers/filepool.ts +++ b/src/providers/filepool.ts @@ -2718,7 +2718,7 @@ export class CoreFilepoolProvider { */ storePackageStatus(siteId: string, status: string, component: string, componentId?: string | number, extra?: string) : Promise { - this.logger.debug(`Set status '${status}'' for package ${component} ${componentId}`); + this.logger.debug(`Set status '${status}' for package ${component} ${componentId}`); componentId = this.fixComponentId(componentId); return this.sitesProvider.getSite(siteId).then((site) => {