diff --git a/src/addon/mod/book/components/index/index.html b/src/addon/mod/book/components/index/index.html index 5463e70a5..0f9b9a735 100644 --- a/src/addon/mod/book/components/index/index.html +++ b/src/addon/mod/book/components/index/index.html @@ -13,7 +13,7 @@ - + diff --git a/src/addon/mod/resource/components/components.module.ts b/src/addon/mod/resource/components/components.module.ts new file mode 100644 index 000000000..8e791eaec --- /dev/null +++ b/src/addon/mod/resource/components/components.module.ts @@ -0,0 +1,45 @@ +// (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'; +import { CoreCourseComponentsModule } from '@core/course/components/components.module'; +import { AddonModResourceIndexComponent } from './index/index'; + +@NgModule({ + declarations: [ + AddonModResourceIndexComponent + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreComponentsModule, + CoreDirectivesModule, + CoreCourseComponentsModule + ], + providers: [ + ], + exports: [ + AddonModResourceIndexComponent + ], + entryComponents: [ + AddonModResourceIndexComponent + ] +}) +export class AddonModResourceComponentsModule {} diff --git a/src/addon/mod/resource/components/index/index.html b/src/addon/mod/resource/components/index/index.html new file mode 100644 index 000000000..5b4289d28 --- /dev/null +++ b/src/addon/mod/resource/components/index/index.html @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + +
+ +
+ +
+ + + {{ 'addon.mod_resource.openthefile' | translate }} + +
+ +
diff --git a/src/addon/mod/resource/components/index/index.ts b/src/addon/mod/resource/components/index/index.ts new file mode 100644 index 000000000..4278194f5 --- /dev/null +++ b/src/addon/mod/resource/components/index/index.ts @@ -0,0 +1,221 @@ +// (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 } 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 { AddonModResourceProvider } from '../../providers/resource'; +import { AddonModResourcePrefetchHandler } from '../../providers/prefetch-handler'; +import { AddonModResourceHelperProvider } from '../../providers/helper'; + +/** + * Component that displays a resource. + */ +@Component({ + selector: 'addon-mod-resource-index', + templateUrl: 'index.html', +}) +export class AddonModResourceIndexComponent implements OnInit, OnDestroy, CoreCourseModuleMainComponent { + @Input() module: any; // The module of the resource. + @Input() courseId: number; // Course ID the resource belongs to. + @Output() resourceRetrieved?: EventEmitter; + + loaded: boolean; + component = AddonModResourceProvider.COMPONENT; + componentId: number; + + canGetResource: boolean; + mode: string; + src: string; + contentText: string; + + // Data for context menu. + externalUrl: string; + description: string; + refreshIcon: string; + prefetchStatusIcon: string; + prefetchText: string; + size: string; + + protected isDestroyed = false; + protected statusObserver; + + constructor(private resourceProvider: AddonModResourceProvider, private courseProvider: CoreCourseProvider, + private domUtils: CoreDomUtilsProvider, private appProvider: CoreAppProvider, private textUtils: CoreTextUtilsProvider, + private courseHelper: CoreCourseHelperProvider, private translate: TranslateService, + @Optional() private content: Content, private prefetchHandler: AddonModResourcePrefetchHandler, + private resourceHelper: AddonModResourceHelperProvider) { + this.resourceRetrieved = 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.canGetResource = this.resourceProvider.isGetResourceWSAvailable(); + + this.fetchContent().then(() => { + this.resourceProvider.logView(this.module.instance).then(() => { + this.courseProvider.checkModuleCompletion(this.courseId, this.module.completionstatus); + }); + }); + } + + /** + * 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 { + if (this.loaded) { + this.refreshIcon = 'spinner'; + + return this.resourceProvider.invalidateContent(this.module.id, this.courseId).catch(() => { + // Ignore errors. + }).then(() => { + return this.fetchContent(true); + }).finally(() => { + this.refreshIcon = 'refresh'; + refresher && refresher.complete(); + done && done(); + }); + } + } + + /** + * 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 resource contents. + * + * @param {boolean} [refresh] Whether we're refreshing data. + * @return {Promise} Promise resolved when done. + */ + protected fetchContent(refresh?: boolean): Promise { + // Load module contents if needed. Passing refresh is needed to force reloading contents. + return this.courseProvider.loadModuleContents(this.module, this.courseId, null, false, refresh).then(() => { + if (!this.module.contents || !this.module.contents.length) { + return Promise.reject(null); + } + + // Get the resource instance to get the latest name/description and to know if it's embedded. + if (this.canGetResource) { + return this.resourceProvider.getResourceData(this.courseId, this.module.id).catch(() => { + // Ignore errors. + }); + } + + return this.courseProvider.getModule(this.module.id, this.courseId).catch(() => { + // Ignore errors. + }); + }).then((resource) => { + if (resource) { + this.description = resource.intro || resource.description; + this.resourceRetrieved.emit(resource); + } + + if (this.resourceHelper.isDisplayedInIframe(this.module)) { + let downloadFailed = false; + + return this.prefetchHandler.download(this.module, this.courseId).catch(() => { + // Mark download as failed but go on since the main files could have been downloaded. + downloadFailed = true; + }).then(() => { + return this.resourceHelper.getIframeSrc(this.module).then((src) => { + console.error(src); + this.mode = 'iframe'; + + if (this.src && src.toString() == this.src.toString()) { + // Re-loading same page. + // Set it to empty and then re-set the src in the next digest so it detects it has changed. + this.src = ''; + setTimeout(() => { + this.src = src; + }); + } else { + this.src = src; + } + + if (downloadFailed && this.appProvider.isOnline()) { + // We could load the main file but the download failed. Show error message. + this.domUtils.showErrorModal('core.errordownloadingsomefiles', true); + } + }); + }); + } else if (this.resourceHelper.isDisplayedEmbedded(this.module, resource && resource.display)) { + this.mode = 'embedded'; + + return this.resourceHelper.getEmbeddedHtml(this.module).then((html) => { + this.contentText = html; + }); + } else { + this.mode = 'external'; + } + }).then(() => { + // All data obtained, now fill the context menu. + this.courseHelper.fillContextMenu(this, this.module, this.courseId, refresh, this.component); + }).catch((error) => { + // Error getting data, fail. + this.domUtils.showErrorModalDefault(error, 'core.course.errorgetmodule', true); + }).finally(() => { + this.loaded = true; + this.refreshIcon = 'refresh'; + }); + } + + /** + * Opens a file. + */ + open(): void { + this.resourceHelper.openModuleFile(this.module, this.courseId); + } + + ngOnDestroy(): void { + this.isDestroyed = true; + this.statusObserver && this.statusObserver.off(); + } +} diff --git a/src/addon/mod/resource/lang/en.json b/src/addon/mod/resource/lang/en.json new file mode 100644 index 000000000..33c872d40 --- /dev/null +++ b/src/addon/mod/resource/lang/en.json @@ -0,0 +1,4 @@ +{ + "errorwhileloadingthecontent": "Error while loading the content.", + "openthefile": "Open the file" +} \ No newline at end of file diff --git a/src/addon/mod/resource/pages/index/index.html b/src/addon/mod/resource/pages/index/index.html new file mode 100644 index 000000000..dad3b8a4b --- /dev/null +++ b/src/addon/mod/resource/pages/index/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/src/addon/mod/resource/pages/index/index.module.ts b/src/addon/mod/resource/pages/index/index.module.ts new file mode 100644 index 000000000..2e40a2c1e --- /dev/null +++ b/src/addon/mod/resource/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'; +import { AddonModResourceComponentsModule } from '../../components/components.module'; +import { AddonModResourceIndexPage } from './index'; + +@NgModule({ + declarations: [ + AddonModResourceIndexPage, + ], + imports: [ + CoreDirectivesModule, + AddonModResourceComponentsModule, + IonicPageModule.forChild(AddonModResourceIndexPage), + TranslateModule.forChild() + ], +}) +export class AddonModResourceIndexPageModule {} diff --git a/src/addon/mod/resource/pages/index/index.ts b/src/addon/mod/resource/pages/index/index.ts new file mode 100644 index 000000000..0bc1de345 --- /dev/null +++ b/src/addon/mod/resource/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 { AddonModResourceIndexComponent } from '../../components/index/index'; + +/** + * Page that displays a resource. + */ +@IonicPage({ segment: 'addon-mod-resource-index' }) +@Component({ + selector: 'page-addon-mod-resource-index', + templateUrl: 'index.html', +}) +export class AddonModResourceIndexPage { + @ViewChild(AddonModResourceIndexComponent) resourceComponent: AddonModResourceIndexComponent; + + 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 resource instance. + * + * @param {any} resource Resource instance. + */ + updateData(resource: any): void { + this.title = resource.name || this.title; + } +} diff --git a/src/addon/mod/resource/providers/helper.ts b/src/addon/mod/resource/providers/helper.ts new file mode 100644 index 000000000..7ffb1377d --- /dev/null +++ b/src/addon/mod/resource/providers/helper.ts @@ -0,0 +1,356 @@ +// (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 { CoreDomUtilsProvider } from '@providers/utils/dom'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { AddonModResourceProvider } from './resource'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreFilepoolProvider } from '@providers/filepool'; +import { CoreFileProvider } from '@providers/file'; +import { CoreAppProvider } from '@providers/app'; +import { CoreMimetypeUtilsProvider } from '@providers/utils/mimetype'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreConstants } from '@core/constants'; + +/** + * Service that provides helper functions for resources. + */ +@Injectable() +export class AddonModResourceHelperProvider { + + /* Constants to determine how a resource should be displayed in Moodle. */ + // Try the best way. + protected DISPLAY_AUTO = 0; + // Display using object tag. + protected DISPLAY_EMBED = 1; + // Display inside frame. + protected DISPLAY_FRAME = 2; + // Display normal link in new window. + protected DISPLAY_NEW = 3; + // Force download of file instead of display. + protected DISPLAY_DOWNLOAD = 4; + // Open directly. + protected DISPLAY_OPEN = 5; + // Open in "emulated" pop-up without navigation. + protected DISPLAY_POPUP = 6; + + constructor(private courseProvider: CoreCourseProvider, private domUtils: CoreDomUtilsProvider, + private resourceProvider: AddonModResourceProvider, + private textUtils: CoreTextUtilsProvider, private mimetypeUtils: CoreMimetypeUtilsProvider, + private fileProvider: CoreFileProvider, private appProvider: CoreAppProvider, + private filepoolProvider: CoreFilepoolProvider, private utils: CoreUtilsProvider, + private sitesProvider: CoreSitesProvider, private translate: TranslateService) { + } + + /** + * Get the HTML to display an embedded resource. + * + * @param {any} module The module object. + * @return {Promise} Promise resolved with the iframe src. + * @since 3.3 + */ + getEmbeddedHtml(module: any): Promise { + if (!module.contents || !module.contents.length) { + return Promise.reject(null); + } + + const file = module.contents[0]; + + return this.treatResourceMainFile(file, module.id).then((result) => { + const ext = this.mimetypeUtils.getFileExtension(file.filename), + type = this.mimetypeUtils.getExtensionType(ext), + mimeType = this.mimetypeUtils.getMimeType(ext); + + if (type == 'image') { + return ''; + } + + if (type == 'audio' || type == 'video') { + return '<' + type + ' controls title="' + file.filename + '"" src="' + result.path + '">' + + '' + + ''; + } + + // Shouldn't reach here, the user should have called $mmFS#canBeEmbedded. + return ''; + }); + } + + /** + * Download all the files needed and returns the src of the iframe. + * + * @param {any} module The module object. + * @return {Promise} Promise resolved with the iframe src. + */ + getIframeSrc(module: any): Promise { + if (!module.contents.length) { + return Promise.reject(null); + } + + const mainFile = module.contents[0]; + let mainFilePath = mainFile.filename; + + if (mainFile.filepath !== '/') { + mainFilePath = mainFile.filepath.substr(1) + mainFilePath; + } + + return this.filepoolProvider.getPackageDirUrlByUrl(this.sitesProvider.getCurrentSiteId(), module.url).then((dirPath) => { + // This URL is going to be injected in an iframe, we need trustAsResourceUrl to make it work in a browser. + return this.textUtils.concatenatePaths(dirPath, mainFilePath); + }).catch(() => { + // Error getting directory, there was an error downloading or we're in browser. Return online URL. + if (this.appProvider.isOnline() && mainFile.fileurl) { + // This URL is going to be injected in an iframe, we need this to make it work. + return Promise.resolve(this.sitesProvider.getCurrentSite().fixPluginfileURL(mainFile.fileurl)); + } + + return Promise.reject(null); + }); + } + + /** + * Whether the resource has to be displayed embedded. + * + * @param {any} module The module object. + * @param {number} [display] The display mode (if available). + * @return {boolean} Whether the resource should be displayed in an iframe. + * @since 3.3 + */ + isDisplayedEmbedded(module: any, display: number): boolean { + if (!module.contents.length || !this.fileProvider.isAvailable()) { + return false; + } + + const ext = this.mimetypeUtils.getFileExtension(module.contents[0].filename); + + return (display == this.DISPLAY_EMBED || display == this.DISPLAY_AUTO) && this.mimetypeUtils.canBeEmbedded(ext); + } + + /** + * Whether the resource has to be displayed in an iframe. + * + * @param {any} module The module object. + * @return {boolean} Whether the resource should be displayed in an iframe. + */ + isDisplayedInIframe(module: any): boolean { + if (!module.contents.length || !this.fileProvider.isAvailable()) { + return false; + } + + const ext = this.mimetypeUtils.getFileExtension(module.contents[0].filename), + mimetype = this.mimetypeUtils.getMimeType(ext); + + return mimetype == 'text/html'; + } + + /** + * Opens a file of the resource activity. + * + * @param {any} module Module where to get the contents. + * @param {number} courseId Course Id, used for completion purposes. + * @return {Promise} Resolved when done. + */ + openModuleFile(module: any, courseId: number): Promise { + const modal = this.domUtils.showModalLoading(); + + return this.openFile(module.contents, module.id).then(() => { + this.resourceProvider.logView(module.instance).then(() => { + this.courseProvider.checkModuleCompletion(courseId, module.completionstatus); + }); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'addon.mod_resource.errorwhileloadingthecontent', true); + }).finally(() => { + modal.dismiss(); + }); + } + + /** + * Download and open the file from the resource. + * + * @param {any} contents Array of content objects. + * @param {number} moduleId The module ID. + * @return {Promise} + */ + protected openFile(contents: any, moduleId: number): Promise { + if (!contents || !contents.length) { + return Promise.reject(null); + } + + const siteId = this.sitesProvider.getCurrentSiteId(), + file = contents[0], + files = [file], + component = AddonModResourceProvider.COMPONENT; + + if (this.shouldOpenInBrowser(contents[0])) { + if (this.appProvider.isOnline()) { + // Open in browser. + let fixedUrl = this.sitesProvider.getCurrentSite().fixPluginfileURL(file.fileurl).replace('&offline=1', ''); + fixedUrl = fixedUrl.replace(/forcedownload=\d+&/, ''); // Remove forcedownload when followed by another param. + fixedUrl = fixedUrl.replace(/[\?|\&]forcedownload=\d+/, ''); // Remove forcedownload when not followed by any param. + 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.filepoolProvider.downloadPackage(siteId, files, component, moduleId); + } + + return Promise.resolve(); + } + + // Not online, get the offline file. It will fail if not found. + return this.filepoolProvider.getInternalUrlByUrl(siteId, file.fileurl).then((path) => { + return this.utils.openFile(path); + }).catch(() => { + return Promise.reject(this.translate.instant('core.networkerrormsg')); + }); + } + + return this.treatResourceMainFile(file, moduleId).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); + } + + let subPromise; + if (result.status === CoreConstants.DOWNLOADING) { + subPromise = Promise.reject(this.translate.instant('core.erroropenfiledownloading')); + } else if (result.status === CoreConstants.NOT_DOWNLOADED) { + subPromise = this.filepoolProvider.downloadPackage(siteId, files, AddonModResourceProvider.COMPONENT, + moduleId).then(() => { + return this.filepoolProvider.getInternalUrlByUrl(siteId, file.fileurl); + }); + } else { + // File is outdated or stale and can't be opened in online, return the local URL. + subPromise = this.filepoolProvider.getInternalUrlByUrl(siteId, file.fileurl); + } + + return subPromise.then((path) => { + return this.utils.openFile(path); + }); + }); + } + + return this.utils.openFile(result.path); + }); + } + + /** + * Treat the main file of a resource, downloading it if needed and returning the URL to use and the status of the resource. + * + * @param {any} file Resource's main file. + * @param {number} moduleId The module ID. + * @return {Promise} Promise resolved with an object containing: + * * path: The URL to use; can be an online URL or an offline path. + * * status: The status of the resource. + */ + protected treatResourceMainFile(file: any, moduleId: number): Promise { + const files = [file], + url = file.fileurl, + fixedUrl = this.sitesProvider.getCurrentSite().fixPluginfileURL(url), + result = { + status: '', + path: fixedUrl + }; + + if (!this.fileProvider.isAvailable()) { + // We use the live URL. + return Promise.resolve(result); + } + + const siteId = this.sitesProvider.getCurrentSiteId(), + component = AddonModResourceProvider.COMPONENT; + + // The file system is available. + return this.filepoolProvider.getPackageStatus(siteId, component, moduleId).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, url); + } + + if (status === CoreConstants.DOWNLOADING && !this.appProvider.isDesktop()) { + // Return the online URL. + return fixedUrl; + } + + if (!isOnline && status === CoreConstants.NOT_DOWNLOADED) { + // Not downloaded and we're offline, reject. + return Promise.reject(null); + } + + return this.filepoolProvider.shouldDownloadBeforeOpen(fixedUrl, file.filesize).then(() => { + // Download and then return the local URL. + return this.filepoolProvider.downloadPackage(siteId, files, component, moduleId).then(() => { + return this.filepoolProvider.getInternalUrlByUrl(siteId, url); + }); + }).catch(() => { + // Start the download if in wifi, but return the URL right away so the file is opened. + if (isWifi && isOnline) { + this.filepoolProvider.downloadPackage(siteId, files, component, moduleId); + } + + if (status === CoreConstants.NOT_DOWNLOADED || isOnline) { + // Not downloaded or outdated and online, return the online URL. + return fixedUrl; + } + + const timeMod = this.filepoolProvider.getTimemodifiedFromFileList(files); + + // Outdated but offline, so we return the local URL. + return this.filepoolProvider.getUrlByUrl(siteId, url, component, moduleId, timeMod, false, false, file); + }); + }).then((path) => { + result.path = path; + + return result; + }); + } + + /** + * Whether the resource has to be opened in browser. + * + * @param {any} file Module's main file. + * @return {boolean} Whether the resource should be opened in browser. + * @since 3.3 + */ + 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/addon/mod/resource/providers/module-handler.ts b/src/addon/mod/resource/providers/module-handler.ts new file mode 100644 index 000000000..358e25fea --- /dev/null +++ b/src/addon/mod/resource/providers/module-handler.ts @@ -0,0 +1,98 @@ +// (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 { AddonModResourceProvider } from './resource'; +import { AddonModResourceIndexComponent } from '../components/index/index'; +import { CoreCourseModuleHandler, CoreCourseModuleHandlerData } from '@core/course/providers/module-delegate'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreMimetypeUtilsProvider } from '@providers/utils/mimetype'; + +/** + * Handler to support resource modules. + */ +@Injectable() +export class AddonModResourceModuleHandler implements CoreCourseModuleHandler { + name = 'resource'; + + constructor(protected resourceProvider: AddonModResourceProvider, private courseProvider: CoreCourseProvider, + protected mimetypeUtils: CoreMimetypeUtilsProvider) { } + + /** + * 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.resourceProvider.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 { + this.getIcon(module, courseId); + + return { + icon: this.courseProvider.getModuleIconSrc('resource'), + title: module.name, + class: 'addon-mod_resource-handler', + showDownloadButton: true, + action(event: Event, navCtrl: NavController, module: any, courseId: number, options: NavOptions): void { + navCtrl.push('AddonModResourceIndexPage', {module: module, courseId: courseId}, options); + } + }; + } + + /** + * Returns the activity icon. + * + * @param {any} module The module object. + * @param {number} courseId The course ID. + * @return {string} Icon URL. + */ + protected getIcon(module: any, courseId: number): string { + this.courseProvider.loadModuleContents(module, courseId).then(() => { + if (module.contents.length) { + const filename = module.contents[0].filename, + extension = this.mimetypeUtils.getFileExtension(filename); + if (module.contents.length == 1 || (extension != 'html' && extension != 'htm')) { + return this.mimetypeUtils.getFileIcon(filename); + } + } + + return this.courseProvider.getModuleIconSrc('resource'); + }); + + return this.courseProvider.getModuleIconSrc('resource'); + } + + /** + * 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 AddonModResourceIndexComponent; + } +} diff --git a/src/addon/mod/resource/providers/prefetch-handler.ts b/src/addon/mod/resource/providers/prefetch-handler.ts new file mode 100644 index 000000000..a17dc360e --- /dev/null +++ b/src/addon/mod/resource/providers/prefetch-handler.ts @@ -0,0 +1,103 @@ +// (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 { AddonModResourceProvider } from './resource'; +import { AddonModResourceHelperProvider } from './helper'; +import { CoreFilepoolProvider } from '@providers/filepool'; + +/** + * Handler to prefetch resources. + */ +@Injectable() +export class AddonModResourcePrefetchHandler extends CoreCourseModulePrefetchHandlerBase { + name = 'resource'; + component = AddonModResourceProvider.COMPONENT; + isResource = true; + + constructor(injector: Injector, protected resourceProvider: AddonModResourceProvider, + protected filepoolProvider: CoreFilepoolProvider, protected resourceHelper: AddonModResourceHelperProvider) { + 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 { + let promise; + + if (this.resourceHelper.isDisplayedInIframe(module)) { + promise = this.filepoolProvider.getPackageDirPathByUrl(this.sitesProvider.getCurrentSiteId(), module.url); + } else { + promise = Promise.resolve(); + } + + return promise.then((dirPath) => { + const promises = []; + + promises.push(super.downloadOrPrefetch(module, courseId, prefetch, dirPath)); + + if (this.resourceProvider.isGetResourceWSAvailable()) { + promises.push(this.resourceProvider.getResourceData(courseId, module.id)); + } + + return Promise.all(promises); + }); + } + + /** + * 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.resourceProvider.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.resourceProvider.invalidateResourceData(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.resourceProvider.isPluginEnabled(); + } +} diff --git a/src/addon/mod/resource/providers/resource.ts b/src/addon/mod/resource/providers/resource.ts new file mode 100644 index 000000000..485ad3572 --- /dev/null +++ b/src/addon/mod/resource/providers/resource.ts @@ -0,0 +1,167 @@ +// (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 { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreFilepoolProvider } from '@providers/filepool'; + +/** + * Service that provides some features for resources. + */ +@Injectable() +export class AddonModResourceProvider { + static COMPONENT = 'mmaModResource'; + + protected ROOT_CACHE_KEY = 'mmaModResource:'; + protected logger; + + constructor(logger: CoreLoggerProvider, private sitesProvider: CoreSitesProvider, private courseProvider: CoreCourseProvider, + private filepoolProvider: CoreFilepoolProvider, private utils: CoreUtilsProvider) { + this.logger = logger.getInstance('AddonModResourceProvider'); + } + + /** + * Get cache key for resource data WS calls. + * + * @param {number} courseId Course ID. + * @return {string} Cache key. + */ + protected getResourceCacheKey(courseId: number): string { + return this.ROOT_CACHE_KEY + ':resource:' + courseId; + } + + /** + * Get a resource data. + * + * @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 resource is retrieved. + */ + protected getResourceDataByKey(courseId: number, key: string, value: any, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const params = { + courseids: [courseId] + }, + preSets = { + cacheKey: this.getResourceCacheKey(courseId) + }; + + return site.read('mod_resource_get_resources_by_courses', params, preSets).then((response) => { + if (response && response.resources) { + let currentResource; + response.resources.forEach((resource) => { + if (!currentResource && resource[key] == value) { + currentResource = resource; + } + }); + if (currentResource) { + return currentResource; + } + } + + return Promise.reject(null); + }); + }); + } + + /** + * Get a resource 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 resource is retrieved. + */ + getResourceData(courseId: number, cmId: number, siteId?: string): Promise { + return this.getResourceDataByKey(courseId, 'coursemodule', cmId, siteId); + } + + /** + * 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.invalidateResourceData(courseId, siteId)); + promises.push(this.filepoolProvider.invalidateFilesByComponent(siteId, AddonModResourceProvider.COMPONENT, moduleId)); + promises.push(this.courseProvider.invalidateModule(moduleId, siteId)); + + return this.utils.allPromises(promises); + } + + /** + * Invalidates resource 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. + */ + invalidateResourceData(courseId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getResourceCacheKey(courseId)); + }); + } + + /** + * Returns whether or not getResource WS available or not. + * + * @return {boolean} If WS is abalaible. + * @since 3.3 + */ + isGetResourceWSAvailable(): boolean { + return this.sitesProvider.wsAvailableInCurrentSite('mod_resource_get_resources_by_courses'); + } + + /** + * 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 the resource as being viewed. + * + * @param {number} id Module ID. + * @return {Promise} Promise resolved when the WS call is successful. + */ + logView(id: number): Promise { + if (id) { + const params = { + resourceid: id + }; + + return this.sitesProvider.getCurrentSite().write('mod_resource_view_resource', params); + } + + return Promise.reject(null); + } +} diff --git a/src/addon/mod/resource/resource.module.ts b/src/addon/mod/resource/resource.module.ts new file mode 100644 index 000000000..e95a37aa8 --- /dev/null +++ b/src/addon/mod/resource/resource.module.ts @@ -0,0 +1,43 @@ +// (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 { AddonModResourceComponentsModule } from './components/components.module'; +import { AddonModResourceModuleHandler } from './providers/module-handler'; +import { AddonModResourceProvider } from './providers/resource'; +import { AddonModResourcePrefetchHandler } from './providers/prefetch-handler'; +import { AddonModResourceHelperProvider } from './providers/helper'; +import { CoreCourseModuleDelegate } from '@core/course/providers/module-delegate'; +import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; + +@NgModule({ + declarations: [ + ], + imports: [ + AddonModResourceComponentsModule + ], + providers: [ + AddonModResourceProvider, + AddonModResourceModuleHandler, + AddonModResourceHelperProvider, + AddonModResourcePrefetchHandler + ] +}) +export class AddonModResourceModule { + constructor(moduleDelegate: CoreCourseModuleDelegate, moduleHandler: AddonModResourceModuleHandler, + prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModResourcePrefetchHandler) { + moduleDelegate.registerHandler(moduleHandler); + prefetchDelegate.registerHandler(prefetchHandler); + } +} diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 406450a66..d863e9515 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -74,6 +74,7 @@ import { AddonUserProfileFieldModule } from '@addon/userprofilefield/userprofile import { AddonFilesModule } from '@addon/files/files.module'; import { AddonModBookModule } from '@addon/mod/book/book.module'; import { AddonModLabelModule } from '@addon/mod/label/label.module'; +import { AddonModResourceModule } from '@addon/mod/resource/resource.module'; import { AddonMessagesModule } from '@addon/messages/messages.module'; import { AddonPushNotificationsModule } from '@addon/pushnotifications/pushnotifications.module'; @@ -151,6 +152,7 @@ export const CORE_PROVIDERS: any[] = [ AddonFilesModule, AddonModBookModule, AddonModLabelModule, + AddonModResourceModule, AddonMessagesModule, AddonPushNotificationsModule ], diff --git a/src/components/iframe/iframe.scss b/src/components/iframe/iframe.scss index 12c75ceb3..562b80430 100644 --- a/src/components/iframe/iframe.scss +++ b/src/components/iframe/iframe.scss @@ -2,4 +2,7 @@ core-iframe { > div { height: 100%; } + iframe { + border: 0; + } } diff --git a/src/components/iframe/iframe.ts b/src/components/iframe/iframe.ts index 005ec72a9..fa61c91fe 100644 --- a/src/components/iframe/iframe.ts +++ b/src/components/iframe/iframe.ts @@ -59,7 +59,7 @@ export class CoreIframeComponent implements OnInit { this.iframeHeight = this.domUtils.formatPixelsSize(this.iframeHeight) || '100%'; // Show loading only with external URLs. - this.loading = !!this.src.match(/^https?:\/\//i); + this.loading = !this.src || !!this.src.match(/^https?:\/\//i); this.treatFrame(iframe); diff --git a/src/core/course/components/module/module.scss b/src/core/course/components/module/module.scss index 8a15d7dd8..ecc368e10 100644 --- a/src/core/course/components/module/module.scss +++ b/src/core/course/components/module/module.scss @@ -59,6 +59,8 @@ core-course-module { a.core-course-module-handler .core-module-icon { margin-top: $label-md-margin-top; margin-bottom: $label-md-margin-bottom; + width: 24px; + height: 24px; } .core-module-title core-format-text {