diff --git a/config/copy.config.js b/config/copy.config.js index 77007db47..cf81edd35 100644 --- a/config/copy.config.js +++ b/config/copy.config.js @@ -41,4 +41,8 @@ module.exports = { src: ['{{ROOT}}/node_modules/mathjax/localization/**/*'], dest: '{{WWW}}/lib/mathjax/localization' }, + copyH5P: { + src: ['{{ROOT}}/src/core/h5p/assets/**/*'], + dest: '{{WWW}}/h5p/' + }, }; diff --git a/scripts/langindex.json b/scripts/langindex.json index f6be29269..2b696380e 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1552,6 +1552,95 @@ "core.group": "moodle", "core.groupsseparate": "moodle", "core.groupsvisible": "moodle", + "core.h5p.additionallicenseinfo": "h5p", + "core.h5p.author": "h5p", + "core.h5p.authorcomments": "h5p", + "core.h5p.authorcommentsdescription": "h5p", + "core.h5p.authorname": "h5p", + "core.h5p.authorrole": "h5p", + "core.h5p.by": "h5p", + "core.h5p.cancellabel": "h5p", + "core.h5p.ccattribution": "h5p", + "core.h5p.ccattributionnc": "h5p", + "core.h5p.ccattributionncnd": "h5p", + "core.h5p.ccattributionncsa": "h5p", + "core.h5p.ccattributionnd": "h5p", + "core.h5p.ccattributionsa": "h5p", + "core.h5p.ccpdd": "h5p", + "core.h5p.changedby": "h5p", + "core.h5p.changedescription": "h5p", + "core.h5p.changelog": "h5p", + "core.h5p.changeplaceholder": "h5p", + "core.h5p.close": "h5p", + "core.h5p.confirmdialogbody": "h5p", + "core.h5p.confirmdialogheader": "h5p", + "core.h5p.confirmlabel": "h5p", + "core.h5p.connectionLost": "h5p", + "core.h5p.connectionReestablished": "h5p", + "core.h5p.contentCopied": "h5p", + "core.h5p.contentchanged": "h5p", + "core.h5p.contenttype": "h5p", + "core.h5p.copyright": "h5p", + "core.h5p.copyrightinfo": "h5p", + "core.h5p.copyrightstring": "h5p", + "core.h5p.copyrighttitle": "h5p", + "core.h5p.creativecommons": "h5p", + "core.h5p.date": "h5p", + "core.h5p.disablefullscreen": "h5p", + "core.h5p.download": "h5p", + "core.h5p.downloadtitle": "h5p", + "core.h5p.editor": "h5p", + "core.h5p.embed": "h5p", + "core.h5p.embedtitle": "h5p", + "core.h5p.fullscreen": "h5p", + "core.h5p.gpl": "h5p", + "core.h5p.h5ptitle": "h5p", + "core.h5p.hideadvanced": "h5p", + "core.h5p.license": "h5p", + "core.h5p.licenseCC010": "h5p", + "core.h5p.licenseCC010U": "h5p", + "core.h5p.licenseCC10": "h5p", + "core.h5p.licenseCC20": "h5p", + "core.h5p.licenseCC25": "h5p", + "core.h5p.licenseCC30": "h5p", + "core.h5p.licenseCC40": "h5p", + "core.h5p.licenseGPL": "h5p", + "core.h5p.licenseV1": "h5p", + "core.h5p.licenseV2": "h5p", + "core.h5p.licenseV3": "h5p", + "core.h5p.licensee": "h5p", + "core.h5p.licenseextras": "h5p", + "core.h5p.licenseversion": "h5p", + "core.h5p.nocopyright": "h5p", + "core.h5p.offlineDialogBody": "h5p", + "core.h5p.offlineDialogHeader": "h5p", + "core.h5p.offlineDialogRetryButtonLabel": "h5p", + "core.h5p.offlineDialogRetryMessage": "h5p", + "core.h5p.offlineSuccessfulSubmit": "h5p", + "core.h5p.originator": "h5p", + "core.h5p.pd": "h5p", + "core.h5p.pddl": "h5p", + "core.h5p.play": "local_moodlemobileapp", + "core.h5p.pdm": "h5p", + "core.h5p.resizescript": "h5p", + "core.h5p.resubmitScores": "h5p", + "core.h5p.reuse": "h5p", + "core.h5p.reuseContent": "h5p", + "core.h5p.reuseDescription": "h5p", + "core.h5p.showadvanced": "h5p", + "core.h5p.showless": "h5p", + "core.h5p.showmore": "h5p", + "core.h5p.size": "h5p", + "core.h5p.source": "h5p", + "core.h5p.startingover": "h5p", + "core.h5p.sublevel": "h5p", + "core.h5p.thumbnail": "h5p", + "core.h5p.title": "h5p", + "core.h5p.undisclosed": "h5p", + "core.h5p.year": "h5p", + "core.h5p.years": "h5p", + "core.h5p.yearsfrom": "h5p", + "core.h5p.yearsto": "h5p", "core.hasdatatosync": "local_moodlemobileapp", "core.help": "moodle", "core.hide": "moodle", diff --git a/src/addon/filter/displayh5p/displayh5p.module.ts b/src/addon/filter/displayh5p/displayh5p.module.ts new file mode 100644 index 000000000..8a7197c71 --- /dev/null +++ b/src/addon/filter/displayh5p/displayh5p.module.ts @@ -0,0 +1,34 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; +import { IonicModule } from 'ionic-angular'; +import { CoreFilterDelegate } from '@core/filter/providers/delegate'; +import { AddonFilterDisplayH5PHandler } from './providers/handler'; + +@NgModule({ + declarations: [ + ], + imports: [ + IonicModule + ], + providers: [ + AddonFilterDisplayH5PHandler + ] +}) +export class AddonFilterDisplayH5PModule { + constructor(filterDelegate: CoreFilterDelegate, handler: AddonFilterDisplayH5PHandler) { + filterDelegate.registerHandler(handler); + } +} diff --git a/src/addon/filter/displayh5p/providers/handler.ts b/src/addon/filter/displayh5p/providers/handler.ts new file mode 100644 index 000000000..abbfdb957 --- /dev/null +++ b/src/addon/filter/displayh5p/providers/handler.ts @@ -0,0 +1,98 @@ + +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable, ViewContainerRef, ComponentFactoryResolver } from '@angular/core'; +import { CoreFilterDefaultHandler } from '@core/filter/providers/default-filter'; +import { CoreFilterFilter, CoreFilterFormatTextOptions } from '@core/filter/providers/filter'; +import { CoreH5PPlayerComponent } from '@core/h5p/components/h5p-player/h5p-player'; + +/** + * Handler to support the Display H5P filter. + */ +@Injectable() +export class AddonFilterDisplayH5PHandler extends CoreFilterDefaultHandler { + name = 'AddonFilterDisplayH5PHandler'; + filterName = 'displayh5p'; + + protected template = document.createElement('template'); // A template element to convert HTML to element. + + constructor(protected factoryResolver: ComponentFactoryResolver) { + super(); + } + + /** + * Filter some text. + * + * @param text The text to filter. + * @param filter The filter. + * @param options Options passed to the filters. + * @param siteId Site ID. If not defined, current site. + * @return Filtered text (or promise resolved with the filtered text). + */ + filter(text: string, filter: CoreFilterFilter, options: CoreFilterFormatTextOptions, siteId?: string) + : string | Promise { + + this.template.innerHTML = text; + + const h5pIframes = Array.from(this.template.content.querySelectorAll('iframe.h5p-iframe')); + + // Replace all iframes with an empty div that will be treated in handleHtml. + h5pIframes.forEach((iframe) => { + const placeholder = document.createElement('div'); + + placeholder.classList.add('core-h5p-tmp-placeholder'); + placeholder.setAttribute('data-player-src', iframe.src); + + iframe.parentElement.replaceChild(placeholder, iframe); + }); + + return this.template.innerHTML; + } + + /** + * Handle HTML. This function is called after "filter", and it will receive an HTMLElement containing the text that was + * filtered. + * + * @param container The HTML container to handle. + * @param filter The filter. + * @param options Options passed to the filters. + * @param viewContainerRef The ViewContainerRef where the container is. + * @param component Component. + * @param componentId Component ID. + * @param siteId Site ID. If not defined, current site. + * @return If async, promise resolved when done. + */ + handleHtml(container: HTMLElement, filter: CoreFilterFilter, options: CoreFilterFormatTextOptions, + viewContainerRef: ViewContainerRef, component?: string, componentId?: string | number, siteId?: string) + : void | Promise { + + const placeholders = Array.from(container.querySelectorAll('div.core-h5p-tmp-placeholder')); + + placeholders.forEach((placeholder) => { + const url = placeholder.getAttribute('data-player-src'); + + // Create the component to display the player. + const factory = this.factoryResolver.resolveComponentFactory(CoreH5PPlayerComponent), + componentRef = viewContainerRef.createComponent(factory); + + componentRef.instance.src = url; + componentRef.instance.component = component; + componentRef.instance.componentId = componentId; + + // Move the component to its right position. + placeholder.parentElement.replaceChild(componentRef.instance.elementRef.nativeElement, placeholder); + }); + } +} diff --git a/src/addon/filter/filter.module.ts b/src/addon/filter/filter.module.ts index 57e177598..29ab01243 100644 --- a/src/addon/filter/filter.module.ts +++ b/src/addon/filter/filter.module.ts @@ -17,6 +17,7 @@ import { AddonFilterActivityNamesModule } from './activitynames/activitynames.mo import { AddonFilterAlgebraModule } from './algebra/algebra.module'; import { AddonFilterCensorModule } from './censor/censor.module'; import { AddonFilterDataModule } from './data/data.module'; +import { AddonFilterDisplayH5PModule } from './displayh5p/displayh5p.module'; import { AddonFilterEmailProtectModule } from './emailprotect/emailprotect.module'; import { AddonFilterEmoticonModule } from './emoticon/emoticon.module'; import { AddonFilterGlossaryModule } from './glossary/glossary.module'; @@ -34,6 +35,7 @@ import { AddonFilterUrlToLinkModule } from './urltolink/urltolink.module'; AddonFilterAlgebraModule, AddonFilterCensorModule, AddonFilterDataModule, + AddonFilterDisplayH5PModule, AddonFilterEmailProtectModule, AddonFilterEmoticonModule, AddonFilterGlossaryModule, diff --git a/src/addon/filter/mathjaxloader/providers/handler.ts b/src/addon/filter/mathjaxloader/providers/handler.ts index 51c9d8d0c..0ef005bc7 100644 --- a/src/addon/filter/mathjaxloader/providers/handler.ts +++ b/src/addon/filter/mathjaxloader/providers/handler.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Injectable } from '@angular/core'; +import { Injectable, ViewContainerRef } from '@angular/core'; import { CoreFilterDefaultHandler } from '@core/filter/providers/default-filter'; import { CoreFilterFilter, CoreFilterFormatTextOptions } from '@core/filter/providers/filter'; import { CoreEventsProvider } from '@providers/events'; @@ -161,10 +161,14 @@ export class AddonFilterMathJaxLoaderHandler extends CoreFilterDefaultHandler { * @param container The HTML container to handle. * @param filter The filter. * @param options Options passed to the filters. + * @param viewContainerRef The ViewContainerRef where the container is. + * @param component Component. + * @param componentId Component ID. * @param siteId Site ID. If not defined, current site. * @return If async, promise resolved when done. */ - handleHtml(container: HTMLElement, filter: CoreFilterFilter, options: CoreFilterFormatTextOptions, siteId?: string) + handleHtml(container: HTMLElement, filter: CoreFilterFilter, options: CoreFilterFormatTextOptions, + viewContainerRef: ViewContainerRef, component?: string, componentId?: string | number, siteId?: string) : void | Promise { return this.waitForReady().then(() => { diff --git a/src/addon/filter/mediaplugin/providers/handler.ts b/src/addon/filter/mediaplugin/providers/handler.ts index bc8b5ce03..e1c5a93fc 100644 --- a/src/addon/filter/mediaplugin/providers/handler.ts +++ b/src/addon/filter/mediaplugin/providers/handler.ts @@ -27,6 +27,8 @@ export class AddonFilterMediaPluginHandler extends CoreFilterDefaultHandler { name = 'AddonFilterMediaPluginHandler'; filterName = 'mediaplugin'; + protected template = document.createElement('template'); // A template element to convert HTML to element. + constructor(private textUtils: CoreTextUtilsProvider, private urlUtils: CoreUrlUtilsProvider) { super(); @@ -44,16 +46,15 @@ export class AddonFilterMediaPluginHandler extends CoreFilterDefaultHandler { filter(text: string, filter: CoreFilterFilter, options: CoreFilterFormatTextOptions, siteId?: string) : string | Promise { - const div = document.createElement('div'); - div.innerHTML = text; + this.template.innerHTML = text; - const videos = Array.from(div.querySelectorAll('video')); + const videos = Array.from(this.template.content.querySelectorAll('video')); videos.forEach((video) => { this.treatVideoFilters(video); }); - return div.innerHTML; + return this.template.innerHTML; } /** diff --git a/src/addon/mod/assign/providers/prefetch-handler.ts b/src/addon/mod/assign/providers/prefetch-handler.ts index 9552032af..56d0b4ba0 100644 --- a/src/addon/mod/assign/providers/prefetch-handler.ts +++ b/src/addon/mod/assign/providers/prefetch-handler.ts @@ -32,6 +32,7 @@ import { AddonModAssignSyncProvider } from './assign-sync'; import { AddonModAssignFeedbackDelegate } from './feedback-delegate'; import { AddonModAssignSubmissionDelegate } from './submission-delegate'; import { CoreFilterHelperProvider } from '@core/filter/providers/helper'; +import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; /** * Handler to prefetch assigns. @@ -51,6 +52,7 @@ export class AddonModAssignPrefetchHandler extends CoreCourseActivityPrefetchHan sitesProvider: CoreSitesProvider, domUtils: CoreDomUtilsProvider, filterHelper: CoreFilterHelperProvider, + pluginFileDelegate: CorePluginFileDelegate, protected assignProvider: AddonModAssignProvider, protected textUtils: CoreTextUtilsProvider, protected feedbackDelegate: AddonModAssignFeedbackDelegate, @@ -62,7 +64,8 @@ export class AddonModAssignPrefetchHandler extends CoreCourseActivityPrefetchHan protected assignHelper: AddonModAssignHelperProvider, protected syncProvider: AddonModAssignSyncProvider) { - super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper); + super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper, + pluginFileDelegate); } /** diff --git a/src/addon/mod/book/providers/prefetch-handler.ts b/src/addon/mod/book/providers/prefetch-handler.ts index 22c6bf8bb..d4eb5903a 100644 --- a/src/addon/mod/book/providers/prefetch-handler.ts +++ b/src/addon/mod/book/providers/prefetch-handler.ts @@ -23,6 +23,7 @@ import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCourseResourcePrefetchHandlerBase } from '@core/course/classes/resource-prefetch-handler'; import { AddonModBookProvider } from './book'; import { CoreFilterHelperProvider } from '@core/filter/providers/helper'; +import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; /** * Handler to prefetch books. @@ -42,9 +43,11 @@ export class AddonModBookPrefetchHandler extends CoreCourseResourcePrefetchHandl sitesProvider: CoreSitesProvider, domUtils: CoreDomUtilsProvider, filterHelper: CoreFilterHelperProvider, + pluginFileDelegate: CorePluginFileDelegate, protected bookProvider: AddonModBookProvider) { - super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper); + super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper, + pluginFileDelegate); } /** diff --git a/src/addon/mod/chat/providers/prefetch-handler.ts b/src/addon/mod/chat/providers/prefetch-handler.ts index 556d34982..ecf8cde63 100644 --- a/src/addon/mod/chat/providers/prefetch-handler.ts +++ b/src/addon/mod/chat/providers/prefetch-handler.ts @@ -25,6 +25,7 @@ import { CoreCourseActivityPrefetchHandlerBase } from '@core/course/classes/acti import { CoreUserProvider } from '@core/user/providers/user'; import { AddonModChatProvider, AddonModChatChat } from './chat'; import { CoreFilterHelperProvider } from '@core/filter/providers/helper'; +import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; /** * Handler to prefetch chats. @@ -43,11 +44,13 @@ export class AddonModChatPrefetchHandler extends CoreCourseActivityPrefetchHandl sitesProvider: CoreSitesProvider, domUtils: CoreDomUtilsProvider, filterHelper: CoreFilterHelperProvider, + pluginFileDelegate: CorePluginFileDelegate, private groupsProvider: CoreGroupsProvider, private userProvider: CoreUserProvider, private chatProvider: AddonModChatProvider) { - super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper); + super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper, + pluginFileDelegate); } /** diff --git a/src/addon/mod/choice/providers/prefetch-handler.ts b/src/addon/mod/choice/providers/prefetch-handler.ts index cbe113b6a..656ab5efc 100644 --- a/src/addon/mod/choice/providers/prefetch-handler.ts +++ b/src/addon/mod/choice/providers/prefetch-handler.ts @@ -25,6 +25,7 @@ import { CoreUserProvider } from '@core/user/providers/user'; import { AddonModChoiceSyncProvider } from './sync'; import { AddonModChoiceProvider } from './choice'; import { CoreFilterHelperProvider } from '@core/filter/providers/helper'; +import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; /** * Handler to prefetch choices. @@ -46,11 +47,13 @@ export class AddonModChoicePrefetchHandler extends CoreCourseActivityPrefetchHan sitesProvider: CoreSitesProvider, domUtils: CoreDomUtilsProvider, filterHelper: CoreFilterHelperProvider, + pluginFileDelegate: CorePluginFileDelegate, protected choiceProvider: AddonModChoiceProvider, protected userProvider: CoreUserProvider, protected injector: Injector) { - super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper); + super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper, + pluginFileDelegate); } /** diff --git a/src/addon/mod/data/providers/prefetch-handler.ts b/src/addon/mod/data/providers/prefetch-handler.ts index 63d531b8a..ecdf8927a 100644 --- a/src/addon/mod/data/providers/prefetch-handler.ts +++ b/src/addon/mod/data/providers/prefetch-handler.ts @@ -28,6 +28,7 @@ import { AddonModDataProvider, AddonModDataEntry } from './data'; import { AddonModDataSyncProvider } from './sync'; import { AddonModDataHelperProvider } from './helper'; import { CoreFilterHelperProvider } from '@core/filter/providers/helper'; +import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; /** * Handler to prefetch databases. @@ -47,6 +48,7 @@ export class AddonModDataPrefetchHandler extends CoreCourseActivityPrefetchHandl sitesProvider: CoreSitesProvider, domUtils: CoreDomUtilsProvider, filterHelper: CoreFilterHelperProvider, + pluginFileDelegate: CorePluginFileDelegate, protected dataProvider: AddonModDataProvider, protected timeUtils: CoreTimeUtilsProvider, protected dataHelper: AddonModDataHelperProvider, @@ -54,7 +56,8 @@ export class AddonModDataPrefetchHandler extends CoreCourseActivityPrefetchHandl protected commentsProvider: CoreCommentsProvider, protected syncProvider: AddonModDataSyncProvider) { - super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper); + super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper, + pluginFileDelegate); } /** diff --git a/src/addon/mod/feedback/providers/prefetch-handler.ts b/src/addon/mod/feedback/providers/prefetch-handler.ts index 2aed13f52..01c846d74 100644 --- a/src/addon/mod/feedback/providers/prefetch-handler.ts +++ b/src/addon/mod/feedback/providers/prefetch-handler.ts @@ -27,6 +27,7 @@ import { CoreTimeUtilsProvider } from '@providers/utils/time'; import { CoreGroupsProvider } from '@providers/groups'; import { AddonModFeedbackSyncProvider } from './sync'; import { CoreFilterHelperProvider } from '@core/filter/providers/helper'; +import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; /** * Handler to prefetch feedbacks. @@ -48,13 +49,15 @@ export class AddonModFeedbackPrefetchHandler extends CoreCourseActivityPrefetchH sitesProvider: CoreSitesProvider, domUtils: CoreDomUtilsProvider, filterHelper: CoreFilterHelperProvider, + pluginFileDelegate: CorePluginFileDelegate, protected feedbackProvider: AddonModFeedbackProvider, protected feedbackHelper: AddonModFeedbackHelperProvider, protected timeUtils: CoreTimeUtilsProvider, protected groupsProvider: CoreGroupsProvider, protected injector: Injector) { - super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper); + super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper, + pluginFileDelegate); } /** diff --git a/src/addon/mod/folder/providers/pluginfile-handler.ts b/src/addon/mod/folder/providers/pluginfile-handler.ts index cc4190e0b..35e816d94 100644 --- a/src/addon/mod/folder/providers/pluginfile-handler.ts +++ b/src/addon/mod/folder/providers/pluginfile-handler.ts @@ -47,4 +47,13 @@ export class AddonModFolderPluginFileHandler implements CorePluginFileHandler { // Component + Filearea + Revision return '/mod_folder/content/0/'; } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return Whether or not the handler is enabled on a site level. + */ + isEnabled(): boolean | Promise { + return true; + } } diff --git a/src/addon/mod/folder/providers/prefetch-handler.ts b/src/addon/mod/folder/providers/prefetch-handler.ts index 3ad1f329e..9a5dfc307 100644 --- a/src/addon/mod/folder/providers/prefetch-handler.ts +++ b/src/addon/mod/folder/providers/prefetch-handler.ts @@ -23,6 +23,7 @@ import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCourseResourcePrefetchHandlerBase } from '@core/course/classes/resource-prefetch-handler'; import { AddonModFolderProvider } from './folder'; import { CoreFilterHelperProvider } from '@core/filter/providers/helper'; +import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; /** * Handler to prefetch folders. @@ -41,9 +42,11 @@ export class AddonModFolderPrefetchHandler extends CoreCourseResourcePrefetchHan sitesProvider: CoreSitesProvider, domUtils: CoreDomUtilsProvider, filterHelper: CoreFilterHelperProvider, + pluginFileDelegate: CorePluginFileDelegate, protected folderProvider: AddonModFolderProvider) { - super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper); + super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper, + pluginFileDelegate); } /** diff --git a/src/addon/mod/forum/providers/prefetch-handler.ts b/src/addon/mod/forum/providers/prefetch-handler.ts index f9ab412c2..743d99b97 100644 --- a/src/addon/mod/forum/providers/prefetch-handler.ts +++ b/src/addon/mod/forum/providers/prefetch-handler.ts @@ -26,6 +26,7 @@ import { CoreGroupsProvider } from '@providers/groups'; import { AddonModForumProvider } from './forum'; import { AddonModForumSyncProvider } from './sync'; import { CoreFilterHelperProvider } from '@core/filter/providers/helper'; +import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; /** * Handler to prefetch forums. @@ -45,12 +46,14 @@ export class AddonModForumPrefetchHandler extends CoreCourseActivityPrefetchHand sitesProvider: CoreSitesProvider, domUtils: CoreDomUtilsProvider, filterHelper: CoreFilterHelperProvider, + pluginFileDelegate: CorePluginFileDelegate, private userProvider: CoreUserProvider, private groupsProvider: CoreGroupsProvider, private forumProvider: AddonModForumProvider, private syncProvider: AddonModForumSyncProvider) { - super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper); + super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper, + pluginFileDelegate); } /** @@ -93,7 +96,7 @@ export class AddonModForumPrefetchHandler extends CoreCourseActivityPrefetchHand if (getInlineFiles && post.messageinlinefiles && post.messageinlinefiles.length) { files = files.concat(post.messageinlinefiles); } else if (post.message && !getInlineFiles) { - files = files.concat(this.domUtils.extractDownloadableFilesFromHtmlAsFakeFileObjects(post.message)); + files = files.concat(this.filepoolProvider.extractDownloadableFilesFromHtmlAsFakeFileObjects(post.message)); } }); diff --git a/src/addon/mod/glossary/providers/prefetch-handler.ts b/src/addon/mod/glossary/providers/prefetch-handler.ts index 3051f1467..590294ad7 100644 --- a/src/addon/mod/glossary/providers/prefetch-handler.ts +++ b/src/addon/mod/glossary/providers/prefetch-handler.ts @@ -25,6 +25,7 @@ import { CoreCourseActivityPrefetchHandlerBase } from '@core/course/classes/acti import { AddonModGlossaryProvider } from './glossary'; import { AddonModGlossarySyncProvider } from './sync'; import { CoreFilterHelperProvider } from '@core/filter/providers/helper'; +import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; /** * Handler to prefetch forums. @@ -44,11 +45,13 @@ export class AddonModGlossaryPrefetchHandler extends CoreCourseActivityPrefetchH sitesProvider: CoreSitesProvider, domUtils: CoreDomUtilsProvider, filterHelper: CoreFilterHelperProvider, + pluginFileDelegate: CorePluginFileDelegate, protected glossaryProvider: AddonModGlossaryProvider, protected commentsProvider: CoreCommentsProvider, protected syncProvider: AddonModGlossarySyncProvider) { - super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper); + super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper, + pluginFileDelegate); } /** @@ -90,7 +93,7 @@ export class AddonModGlossaryPrefetchHandler extends CoreCourseActivityPrefetchH if (getInlineFiles && entry.definitioninlinefiles && entry.definitioninlinefiles.length) { files = files.concat(entry.definitioninlinefiles); } else if (entry.definition && !getInlineFiles) { - files = files.concat(this.domUtils.extractDownloadableFilesFromHtmlAsFakeFileObjects(entry.definition)); + files = files.concat(this.filepoolProvider.extractDownloadableFilesFromHtmlAsFakeFileObjects(entry.definition)); } }); diff --git a/src/addon/mod/imscp/providers/pluginfile-handler.ts b/src/addon/mod/imscp/providers/pluginfile-handler.ts index d67c455a3..4d283a90e 100644 --- a/src/addon/mod/imscp/providers/pluginfile-handler.ts +++ b/src/addon/mod/imscp/providers/pluginfile-handler.ts @@ -54,4 +54,13 @@ export class AddonModImscpPluginFileHandler implements CorePluginFileHandler { // Component + Filearea + Revision return '/mod_imscp/' + args[2] + '/0/'; } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return Whether or not the handler is enabled on a site level. + */ + isEnabled(): boolean | Promise { + return true; + } } diff --git a/src/addon/mod/imscp/providers/prefetch-handler.ts b/src/addon/mod/imscp/providers/prefetch-handler.ts index 0b9c5b57e..6fe5ab2b9 100644 --- a/src/addon/mod/imscp/providers/prefetch-handler.ts +++ b/src/addon/mod/imscp/providers/prefetch-handler.ts @@ -23,6 +23,7 @@ import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCourseResourcePrefetchHandlerBase } from '@core/course/classes/resource-prefetch-handler'; import { AddonModImscpProvider } from './imscp'; import { CoreFilterHelperProvider } from '@core/filter/providers/helper'; +import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; /** * Handler to prefetch IMSCPs. @@ -41,9 +42,11 @@ export class AddonModImscpPrefetchHandler extends CoreCourseResourcePrefetchHand sitesProvider: CoreSitesProvider, domUtils: CoreDomUtilsProvider, filterHelper: CoreFilterHelperProvider, + pluginFileDelegate: CorePluginFileDelegate, protected imscpProvider: AddonModImscpProvider) { - super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper); + super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper, + pluginFileDelegate); } /** diff --git a/src/addon/mod/label/providers/prefetch-handler.ts b/src/addon/mod/label/providers/prefetch-handler.ts index 140c23538..e05f9ac0c 100644 --- a/src/addon/mod/label/providers/prefetch-handler.ts +++ b/src/addon/mod/label/providers/prefetch-handler.ts @@ -23,6 +23,7 @@ import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCourseResourcePrefetchHandlerBase } from '@core/course/classes/resource-prefetch-handler'; import { AddonModLabelProvider } from './label'; import { CoreFilterHelperProvider } from '@core/filter/providers/helper'; +import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; /** * Handler to prefetch labels. @@ -43,9 +44,11 @@ export class AddonModLabelPrefetchHandler extends CoreCourseResourcePrefetchHand sitesProvider: CoreSitesProvider, domUtils: CoreDomUtilsProvider, filterHelper: CoreFilterHelperProvider, + pluginFileDelegate: CorePluginFileDelegate, protected labelProvider: AddonModLabelProvider) { - super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper); + super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper, + pluginFileDelegate); } /** diff --git a/src/addon/mod/lesson/providers/prefetch-handler.ts b/src/addon/mod/lesson/providers/prefetch-handler.ts index 834845d47..46ef36db2 100644 --- a/src/addon/mod/lesson/providers/prefetch-handler.ts +++ b/src/addon/mod/lesson/providers/prefetch-handler.ts @@ -26,6 +26,7 @@ import { CoreCourseActivityPrefetchHandlerBase } from '@core/course/classes/acti import { AddonModLessonProvider } from './lesson'; import { AddonModLessonSyncProvider } from './lesson-sync'; import { CoreFilterHelperProvider } from '@core/filter/providers/helper'; +import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; /** * Handler to prefetch lessons. @@ -48,12 +49,14 @@ export class AddonModLessonPrefetchHandler extends CoreCourseActivityPrefetchHan sitesProvider: CoreSitesProvider, domUtils: CoreDomUtilsProvider, filterHelper: CoreFilterHelperProvider, + pluginFileDelegate: CorePluginFileDelegate, protected modalCtrl: ModalController, protected groupsProvider: CoreGroupsProvider, protected lessonProvider: AddonModLessonProvider, protected injector: Injector) { - super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper); + super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper, + pluginFileDelegate); } /** @@ -108,7 +111,9 @@ export class AddonModLessonPrefetchHandler extends CoreCourseActivityPrefetchHan let files = lesson.mediafiles || []; files = files.concat(this.getIntroFilesFromInstance(module, lesson)); - result = this.utils.sumFileSizes(files); + return this.pluginFileDelegate.getFilesSize(files); + }).then((res) => { + result = res; // Get the pages to calculate the size. return this.lessonProvider.getPages(lesson.id, password, false, false, siteId); @@ -414,7 +419,8 @@ export class AddonModLessonPrefetchHandler extends CoreCourseActivityPrefetchHan return; } answerPage.answerdata.answers.forEach((answer) => { - files.push(...this.domUtils.extractDownloadableFilesFromHtmlAsFakeFileObjects(answer[0])); + files.push(...this.filepoolProvider.extractDownloadableFilesFromHtmlAsFakeFileObjects( + answer[0])); }); }); diff --git a/src/addon/mod/lti/providers/prefetch-handler.ts b/src/addon/mod/lti/providers/prefetch-handler.ts index 14d3c2584..d2b02d014 100644 --- a/src/addon/mod/lti/providers/prefetch-handler.ts +++ b/src/addon/mod/lti/providers/prefetch-handler.ts @@ -23,6 +23,7 @@ import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCourseActivityPrefetchHandlerBase } from '@core/course/classes/activity-prefetch-handler'; import { AddonModLtiProvider } from './lti'; import { CoreFilterHelperProvider } from '@core/filter/providers/helper'; +import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; /** * Handler to prefetch LTIs. LTIs cannot be prefetched, but the handler will be used to invalidate some data on course PTR. @@ -41,9 +42,11 @@ export class AddonModLtiPrefetchHandler extends CoreCourseActivityPrefetchHandle sitesProvider: CoreSitesProvider, domUtils: CoreDomUtilsProvider, filterHelper: CoreFilterHelperProvider, + pluginFileDelegate: CorePluginFileDelegate, protected ltiProvider: AddonModLtiProvider) { - super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper); + super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper, + pluginFileDelegate); } /** diff --git a/src/addon/mod/page/providers/pluginfile-handler.ts b/src/addon/mod/page/providers/pluginfile-handler.ts index c17e6b626..1c93e3e21 100644 --- a/src/addon/mod/page/providers/pluginfile-handler.ts +++ b/src/addon/mod/page/providers/pluginfile-handler.ts @@ -47,4 +47,13 @@ export class AddonModPagePluginFileHandler implements CorePluginFileHandler { // Component + Filearea + Revision return '/mod_page/content/0/'; } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return Whether or not the handler is enabled on a site level. + */ + isEnabled(): boolean | Promise { + return true; + } } diff --git a/src/addon/mod/page/providers/prefetch-handler.ts b/src/addon/mod/page/providers/prefetch-handler.ts index 982b1d45b..5f72b882f 100644 --- a/src/addon/mod/page/providers/prefetch-handler.ts +++ b/src/addon/mod/page/providers/prefetch-handler.ts @@ -24,6 +24,7 @@ import { CoreCourseResourcePrefetchHandlerBase } from '@core/course/classes/reso import { AddonModPageProvider } from './page'; import { AddonModPageHelperProvider } from './helper'; import { CoreFilterHelperProvider } from '@core/filter/providers/helper'; +import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; /** * Handler to prefetch pages. @@ -43,10 +44,12 @@ export class AddonModPagePrefetchHandler extends CoreCourseResourcePrefetchHandl sitesProvider: CoreSitesProvider, domUtils: CoreDomUtilsProvider, filterHelper: CoreFilterHelperProvider, + pluginFileDelegate: CorePluginFileDelegate, protected pageProvider: AddonModPageProvider, protected pageHelper: AddonModPageHelperProvider) { - super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper); + super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper, + pluginFileDelegate); } /** diff --git a/src/addon/mod/quiz/providers/prefetch-handler.ts b/src/addon/mod/quiz/providers/prefetch-handler.ts index 1874ce516..0040dd8e8 100644 --- a/src/addon/mod/quiz/providers/prefetch-handler.ts +++ b/src/addon/mod/quiz/providers/prefetch-handler.ts @@ -29,6 +29,7 @@ import { AddonModQuizAccessRuleDelegate } from './access-rules-delegate'; import { AddonModQuizSyncProvider } from './quiz-sync'; import { CoreConstants } from '@core/constants'; import { CoreFilterHelperProvider } from '@core/filter/providers/helper'; +import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; /** * Handler to prefetch quizzes. @@ -50,6 +51,7 @@ export class AddonModQuizPrefetchHandler extends CoreCourseActivityPrefetchHandl sitesProvider: CoreSitesProvider, domUtils: CoreDomUtilsProvider, filterHelper: CoreFilterHelperProvider, + pluginFileDelegate: CorePluginFileDelegate, protected injector: Injector, protected quizProvider: AddonModQuizProvider, protected textUtils: CoreTextUtilsProvider, @@ -57,7 +59,8 @@ export class AddonModQuizPrefetchHandler extends CoreCourseActivityPrefetchHandl protected accessRuleDelegate: AddonModQuizAccessRuleDelegate, protected questionHelper: CoreQuestionHelperProvider) { - super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper); + super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper, + pluginFileDelegate); } /** @@ -123,7 +126,7 @@ export class AddonModQuizPrefetchHandler extends CoreCourseActivityPrefetchHandl files = files.concat(feedback.feedbackinlinefiles); } else if (feedback.feedbacktext && !getInlineFiles) { files = files.concat( - this.domUtils.extractDownloadableFilesFromHtmlAsFakeFileObjects(feedback.feedbacktext)); + this.filepoolProvider.extractDownloadableFilesFromHtmlAsFakeFileObjects(feedback.feedbacktext)); } })); } diff --git a/src/addon/mod/resource/providers/pluginfile-handler.ts b/src/addon/mod/resource/providers/pluginfile-handler.ts index b0b41368d..f9179a955 100644 --- a/src/addon/mod/resource/providers/pluginfile-handler.ts +++ b/src/addon/mod/resource/providers/pluginfile-handler.ts @@ -47,4 +47,13 @@ export class AddonModResourcePluginFileHandler implements CorePluginFileHandler // Component + Filearea + Revision return '/mod_resource/content/0/'; } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return Whether or not the handler is enabled on a site level. + */ + isEnabled(): boolean | Promise { + return true; + } } diff --git a/src/addon/mod/resource/providers/prefetch-handler.ts b/src/addon/mod/resource/providers/prefetch-handler.ts index 2f9bb6215..27c52989d 100644 --- a/src/addon/mod/resource/providers/prefetch-handler.ts +++ b/src/addon/mod/resource/providers/prefetch-handler.ts @@ -25,6 +25,7 @@ import { AddonModResourceProvider } from './resource'; import { AddonModResourceHelperProvider } from './helper'; import { CoreConstants } from '@core/constants'; import { CoreFilterHelperProvider } from '@core/filter/providers/helper'; +import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; /** * Handler to prefetch resources. @@ -43,10 +44,12 @@ export class AddonModResourcePrefetchHandler extends CoreCourseResourcePrefetchH sitesProvider: CoreSitesProvider, domUtils: CoreDomUtilsProvider, filterHelper: CoreFilterHelperProvider, + pluginFileDelegate: CorePluginFileDelegate, protected resourceProvider: AddonModResourceProvider, protected resourceHelper: AddonModResourceHelperProvider) { - super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper); + super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper, + pluginFileDelegate); } /** diff --git a/src/addon/mod/scorm/providers/pluginfile-handler.ts b/src/addon/mod/scorm/providers/pluginfile-handler.ts index ccb1a3a6b..6cd13e393 100644 --- a/src/addon/mod/scorm/providers/pluginfile-handler.ts +++ b/src/addon/mod/scorm/providers/pluginfile-handler.ts @@ -47,4 +47,13 @@ export class AddonModScormPluginFileHandler implements CorePluginFileHandler { // Component + Filearea + Revision return '/mod_scorm/content/0/'; } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return Whether or not the handler is enabled on a site level. + */ + isEnabled(): boolean | Promise { + return true; + } } diff --git a/src/addon/mod/scorm/providers/prefetch-handler.ts b/src/addon/mod/scorm/providers/prefetch-handler.ts index fd2d34b6e..0d3756312 100644 --- a/src/addon/mod/scorm/providers/prefetch-handler.ts +++ b/src/addon/mod/scorm/providers/prefetch-handler.ts @@ -26,6 +26,7 @@ import { CoreCourseActivityPrefetchHandlerBase } from '@core/course/classes/acti import { AddonModScormProvider } from './scorm'; import { AddonModScormSyncProvider } from './scorm-sync'; import { CoreFilterHelperProvider } from '@core/filter/providers/helper'; +import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; /** * Progress event used when downloading a SCORM. @@ -67,12 +68,14 @@ export class AddonModScormPrefetchHandler extends CoreCourseActivityPrefetchHand sitesProvider: CoreSitesProvider, domUtils: CoreDomUtilsProvider, filterHelper: CoreFilterHelperProvider, + pluginFileDelegate: CorePluginFileDelegate, protected fileProvider: CoreFileProvider, protected textUtils: CoreTextUtilsProvider, protected scormProvider: AddonModScormProvider, protected injector: Injector) { - super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper); + super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper, + pluginFileDelegate); } /** @@ -169,11 +172,6 @@ export class AddonModScormPrefetchHandler extends CoreCourseActivityPrefetchHand return this.filepoolProvider.downloadUrl(siteId, packageUrl, true, this.component, scorm.coursemodule, undefined, this.downloadProgress.bind(this, true, onProgress)); } - }).then(() => { - // Remove the destination folder to prevent having old unused files. - return this.fileProvider.removeDir(dirPath).catch(() => { - // Ignore errors, it might have failed because the folder doesn't exist. - }); }).then(() => { // Get the ZIP file path. return this.filepoolProvider.getFilePathByUrl(siteId, packageUrl); diff --git a/src/addon/mod/survey/providers/prefetch-handler.ts b/src/addon/mod/survey/providers/prefetch-handler.ts index faa894364..026d00c0e 100644 --- a/src/addon/mod/survey/providers/prefetch-handler.ts +++ b/src/addon/mod/survey/providers/prefetch-handler.ts @@ -25,6 +25,7 @@ import { AddonModSurveyProvider } from './survey'; import { AddonModSurveySyncProvider } from './sync'; import { AddonModSurveyHelperProvider } from './helper'; import { CoreFilterHelperProvider } from '@core/filter/providers/helper'; +import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; /** * Handler to prefetch surveys. @@ -46,11 +47,13 @@ export class AddonModSurveyPrefetchHandler extends CoreCourseActivityPrefetchHan sitesProvider: CoreSitesProvider, domUtils: CoreDomUtilsProvider, filterHelper: CoreFilterHelperProvider, + pluginFileDelegate: CorePluginFileDelegate, protected surveyProvider: AddonModSurveyProvider, protected surveyHelper: AddonModSurveyHelperProvider, protected injector: Injector) { - super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper); + super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper, + pluginFileDelegate); } /** diff --git a/src/addon/mod/url/providers/prefetch-handler.ts b/src/addon/mod/url/providers/prefetch-handler.ts index f904fc429..0063f8a25 100644 --- a/src/addon/mod/url/providers/prefetch-handler.ts +++ b/src/addon/mod/url/providers/prefetch-handler.ts @@ -23,6 +23,7 @@ import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCourseResourcePrefetchHandlerBase } from '@core/course/classes/resource-prefetch-handler'; import { AddonModUrlProvider } from './url'; import { CoreFilterHelperProvider } from '@core/filter/providers/helper'; +import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; /** * Handler to prefetch URLs. URLs cannot be prefetched, but the handler will be used to invalidate some data on course PTR. @@ -40,9 +41,11 @@ export class AddonModUrlPrefetchHandler extends CoreCourseResourcePrefetchHandle filepoolProvider: CoreFilepoolProvider, sitesProvider: CoreSitesProvider, domUtils: CoreDomUtilsProvider, - filterHelper: CoreFilterHelperProvider) { + filterHelper: CoreFilterHelperProvider, + pluginFileDelegate: CorePluginFileDelegate) { - super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper); + super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper, + pluginFileDelegate); } /** diff --git a/src/addon/mod/wiki/providers/prefetch-handler.ts b/src/addon/mod/wiki/providers/prefetch-handler.ts index 918ad0441..cfec49818 100644 --- a/src/addon/mod/wiki/providers/prefetch-handler.ts +++ b/src/addon/mod/wiki/providers/prefetch-handler.ts @@ -29,6 +29,7 @@ import { CoreUserProvider } from '@core/user/providers/user'; import { AddonModWikiProvider } from './wiki'; import { AddonModWikiSyncProvider } from './wiki-sync'; import { CoreFilterHelperProvider } from '@core/filter/providers/helper'; +import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; /** * Handler to prefetch wikis. @@ -48,6 +49,7 @@ export class AddonModWikiPrefetchHandler extends CoreCourseActivityPrefetchHandl sitesProvider: CoreSitesProvider, domUtils: CoreDomUtilsProvider, filterHelper: CoreFilterHelperProvider, + pluginFileDelegate: CorePluginFileDelegate, protected wikiProvider: AddonModWikiProvider, protected userProvider: CoreUserProvider, protected textUtils: CoreTextUtilsProvider, @@ -56,7 +58,8 @@ export class AddonModWikiPrefetchHandler extends CoreCourseActivityPrefetchHandl protected gradesHelper: CoreGradesHelperProvider, protected syncProvider: AddonModWikiSyncProvider) { - super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper); + super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper, + pluginFileDelegate); } /** @@ -96,7 +99,7 @@ export class AddonModWikiPrefetchHandler extends CoreCourseActivityPrefetchHandl siteId = this.sitesProvider.getCurrentSiteId(); promises.push(this.getFiles(module, courseId, single, siteId).then((files) => { - return this.utils.sumFileSizes(files); + return this.pluginFileDelegate.getFilesSize(files); })); promises.push(this.getAllPages(module, courseId, false, true, siteId).then((pages) => { diff --git a/src/addon/mod/workshop/providers/prefetch-handler.ts b/src/addon/mod/workshop/providers/prefetch-handler.ts index 0b7b47eee..cf7b99d42 100644 --- a/src/addon/mod/workshop/providers/prefetch-handler.ts +++ b/src/addon/mod/workshop/providers/prefetch-handler.ts @@ -27,6 +27,7 @@ import { AddonModWorkshopProvider } from './workshop'; import { AddonModWorkshopSyncProvider } from './sync'; import { AddonModWorkshopHelperProvider } from './helper'; import { CoreFilterHelperProvider } from '@core/filter/providers/helper'; +import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; /** * Handler to prefetch workshops. @@ -47,13 +48,15 @@ export class AddonModWorkshopPrefetchHandler extends CoreCourseActivityPrefetchH sitesProvider: CoreSitesProvider, domUtils: CoreDomUtilsProvider, filterHelper: CoreFilterHelperProvider, + pluginFileDelegate: CorePluginFileDelegate, private groupsProvider: CoreGroupsProvider, private userProvider: CoreUserProvider, private workshopProvider: AddonModWorkshopProvider, private workshopHelper: AddonModWorkshopHelperProvider, private syncProvider: AddonModWorkshopSyncProvider) { - super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper); + super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper, + pluginFileDelegate); } /** diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 6f9966fa8..f28974676 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -83,6 +83,7 @@ import { CoreBlockModule } from '@core/block/block.module'; import { CoreRatingModule } from '@core/rating/rating.module'; import { CoreTagModule } from '@core/tag/tag.module'; import { CoreFilterModule } from '@core/filter/filter.module'; +import { CoreH5PModule } from '@core/h5p/h5p.module'; // Addon modules. import { AddonBadgesModule } from '@addon/badges/badges.module'; @@ -230,6 +231,7 @@ export const WP_PROVIDER: any = null; CorePushNotificationsModule, CoreTagModule, CoreFilterModule, + CoreH5PModule, AddonBadgesModule, AddonBlogModule, AddonCalendarModule, diff --git a/src/assets/exttomime.json b/src/assets/exttomime.json index 445a11777..bab000445 100644 --- a/src/assets/exttomime.json +++ b/src/assets/exttomime.json @@ -359,6 +359,7 @@ "h261": {"type":"video/h261"}, "h263": {"type":"video/h263"}, "h264": {"type":"video/h264"}, +"h5p": {"type":"application/zip","icon":"archive","string":"archive","groups":["archive"]}, "hal": {"type":"application/vnd.hal+xml"}, "hbci": {"type":"application/vnd.hbci"}, "hdf": {"type":"application/x-hdf"}, diff --git a/src/assets/img/icons/h5p.svg b/src/assets/img/icons/h5p.svg new file mode 100644 index 000000000..7856f9efb --- /dev/null +++ b/src/assets/img/icons/h5p.svg @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/src/assets/lang/en.json b/src/assets/lang/en.json index 6879a9d11..5a3093332 100644 --- a/src/assets/lang/en.json +++ b/src/assets/lang/en.json @@ -1550,6 +1550,95 @@ "core.group": "Group", "core.groupsseparate": "Separate groups", "core.groupsvisible": "Visible groups", + "core.h5p.additionallicenseinfo": "Any additional information about the license", + "core.h5p.author": "Author", + "core.h5p.authorcomments": "Author comments", + "core.h5p.authorcommentsdescription": "Comments for the editor of the content. (This text will not be published as a part of the copyright info.)", + "core.h5p.authorname": "Author's name", + "core.h5p.authorrole": "Author's role", + "core.h5p.by": "by", + "core.h5p.cancellabel": "Cancel", + "core.h5p.ccattribution": "Attribution (CC BY)", + "core.h5p.ccattributionnc": "Attribution-NonCommercial (CC BY-NC)", + "core.h5p.ccattributionncnd": "Attribution-NonCommercial-NoDerivs (CC BY-NC-ND)", + "core.h5p.ccattributionncsa": "Attribution-NonCommercial-ShareAlike (CC BY-NC-SA)", + "core.h5p.ccattributionnd": "Attribution-NoDerivs (CC BY-ND)", + "core.h5p.ccattributionsa": "Attribution-ShareAlike (CC BY-SA)", + "core.h5p.ccpdd": "Public Domain Dedication (CC0)", + "core.h5p.changedby": "Changed by", + "core.h5p.changedescription": "Description of change", + "core.h5p.changelog": "Changelog", + "core.h5p.changeplaceholder": "Photo cropped, text changed, etc.", + "core.h5p.close": "Close", + "core.h5p.confirmdialogbody": "Please confirm that you wish to proceed. This action is not reversible.", + "core.h5p.confirmdialogheader": "Confirm action", + "core.h5p.confirmlabel": "Confirm", + "core.h5p.connectionLost": "Connection lost. Results will be stored and sent when you regain connection.", + "core.h5p.connectionReestablished": "Connection reestablished.", + "core.h5p.contentCopied": "Content is copied to the clipboard", + "core.h5p.contentchanged": "This content has changed since you last used it.", + "core.h5p.contenttype": "Content Type", + "core.h5p.copyright": "Rights of use", + "core.h5p.copyrightinfo": "Copyright information", + "core.h5p.copyrightstring": "Copyright", + "core.h5p.copyrighttitle": "View copyright information for this content.", + "core.h5p.creativecommons": "Creative Commons", + "core.h5p.date": "Date", + "core.h5p.disablefullscreen": "Disable fullscreen", + "core.h5p.download": "Download", + "core.h5p.downloadtitle": "Download this content as a H5P file.", + "core.h5p.editor": "Editor", + "core.h5p.embed": "Embed", + "core.h5p.embedtitle": "View the embed code for this content.", + "core.h5p.fullscreen": "Fullscreen", + "core.h5p.gpl": "General Public License v3", + "core.h5p.h5ptitle": "Visit H5P.org to check out more cool content.", + "core.h5p.hideadvanced": "Hide advanced", + "core.h5p.license": "License", + "core.h5p.licenseCC010": "CC0 1.0 Universal (CC0 1.0) Public Domain Dedication", + "core.h5p.licenseCC010U": "CC0 1.0 Universal", + "core.h5p.licenseCC10": "1.0 Generic", + "core.h5p.licenseCC20": "2.0 Generic", + "core.h5p.licenseCC25": "2.5 Generic", + "core.h5p.licenseCC30": "3.0 Unported", + "core.h5p.licenseCC40": "4.0 International", + "core.h5p.licenseGPL": "General Public License", + "core.h5p.licenseV1": "Version 1", + "core.h5p.licenseV2": "Version 2", + "core.h5p.licenseV3": "Version 3", + "core.h5p.licensee": "Licensee", + "core.h5p.licenseextras": "License Extras", + "core.h5p.licenseversion": "License version", + "core.h5p.nocopyright": "No copyright information available for this content.", + "core.h5p.offlineDialogBody": "We were unable to send information about your completion of this task. Please check your internet connection.", + "core.h5p.offlineDialogHeader": "Your connection to the server was lost", + "core.h5p.offlineDialogRetryButtonLabel": "Retry now", + "core.h5p.offlineDialogRetryMessage": "Retrying in :num....", + "core.h5p.offlineSuccessfulSubmit": "Successfully submitted results.", + "core.h5p.originator": "Originator", + "core.h5p.pd": "Public Domain", + "core.h5p.pddl": "Public Domain Dedication and Licence", + "core.h5p.pdm": "Public Domain Mark (PDM)", + "core.h5p.play": "Play H5P", + "core.h5p.resizescript": "Include this script on your website if you want dynamic sizing of the embedded content:", + "core.h5p.resubmitScores": "Attempting to submit stored results.", + "core.h5p.reuse": "Reuse", + "core.h5p.reuseContent": "Reuse Content", + "core.h5p.reuseDescription": "Reuse this content.", + "core.h5p.showadvanced": "Show advanced", + "core.h5p.showless": "Show less", + "core.h5p.showmore": "Show more", + "core.h5p.size": "Size", + "core.h5p.source": "Source", + "core.h5p.startingover": "You'll be starting over.", + "core.h5p.sublevel": "Sublevel", + "core.h5p.thumbnail": "Thumbnail", + "core.h5p.title": "Title", + "core.h5p.undisclosed": "Undisclosed", + "core.h5p.year": "Year", + "core.h5p.years": "Year(s)", + "core.h5p.yearsfrom": "Years (from)", + "core.h5p.yearsto": "Years (to)", "core.hasdatatosync": "This {{$a}} has offline data to be synchronised.", "core.help": "Help", "core.hide": "Hide", diff --git a/src/classes/delegate.ts b/src/classes/delegate.ts index db31ff907..e342c1e62 100644 --- a/src/classes/delegate.ts +++ b/src/classes/delegate.ts @@ -237,14 +237,16 @@ export class CoreDelegate { * @return True when registered, false if already registered. */ registerHandler(handler: CoreDelegateHandler): boolean { - if (typeof this.handlers[handler[this.handlerNameProperty]] !== 'undefined') { + const key = handler[this.handlerNameProperty] || handler.name; + + if (typeof this.handlers[key] !== 'undefined') { this.logger.log(`Handler '${handler[this.handlerNameProperty]}' already registered`); return false; } this.logger.log(`Registered handler '${handler[this.handlerNameProperty]}'`); - this.handlers[handler[this.handlerNameProperty]] = handler; + this.handlers[key] = handler; return true; } @@ -282,10 +284,12 @@ export class CoreDelegate { }).then((enabled: boolean) => { // Check that site hasn't changed since the check started. if (this.sitesProvider.getCurrentSiteId() === siteId) { + const key = handler[this.handlerNameProperty] || handler.name; + if (enabled) { - this.enabledHandlers[handler[this.handlerNameProperty]] = handler; + this.enabledHandlers[key] = handler; } else { - delete this.enabledHandlers[handler[this.handlerNameProperty]]; + delete this.enabledHandlers[key]; } } }).finally(() => { diff --git a/src/components/file/file.ts b/src/components/file/file.ts index a1c97cf3e..57c509107 100644 --- a/src/components/file/file.ts +++ b/src/components/file/file.ts @@ -23,6 +23,7 @@ import { CoreMimetypeUtilsProvider } from '@providers/utils/mimetype'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreConstants } from '@core/constants'; +import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; /** * Component to handle a remote file. Shows the file name, icon (depending on mimetype) and a button @@ -56,10 +57,16 @@ export class CoreFileComponent implements OnInit, OnDestroy { protected timemodified: number; protected observer; - constructor(private sitesProvider: CoreSitesProvider, private utils: CoreUtilsProvider, private domUtils: CoreDomUtilsProvider, - private filepoolProvider: CoreFilepoolProvider, private appProvider: CoreAppProvider, - private fileHelper: CoreFileHelperProvider, private mimeUtils: CoreMimetypeUtilsProvider, - private eventsProvider: CoreEventsProvider, private textUtils: CoreTextUtilsProvider) { + constructor(private sitesProvider: CoreSitesProvider, + private utils: CoreUtilsProvider, + private domUtils: CoreDomUtilsProvider, + private filepoolProvider: CoreFilepoolProvider, + private appProvider: CoreAppProvider, + private fileHelper: CoreFileHelperProvider, + private mimeUtils: CoreMimetypeUtilsProvider, + private eventsProvider: CoreEventsProvider, + private textUtils: CoreTextUtilsProvider, + private pluginFileDelegate: CorePluginFileDelegate) { this.onDelete = new EventEmitter(); } @@ -141,8 +148,6 @@ export class CoreFileComponent implements OnInit, OnDestroy { e && e.preventDefault(); e && e.stopPropagation(); - let promise; - if (this.isDownloading && !openAfterDownload) { return; } @@ -164,7 +169,7 @@ export class CoreFileComponent implements OnInit, OnDestroy { } if (!this.appProvider.isOnline() && (!openAfterDownload || (openAfterDownload && - !(this.state === CoreConstants.DOWNLOADED || this.state === CoreConstants.OUTDATED)))) { + !this.fileHelper.isStateDownloaded(this.state)))) { this.domUtils.showErrorModal('core.networkerrormsg', true); return; @@ -177,20 +182,26 @@ export class CoreFileComponent implements OnInit, OnDestroy { }); } else { // File doesn't need to be opened (it's a prefetch). Show confirm modal if file size is defined and it's big. - promise = this.fileSize ? this.domUtils.confirmDownloadSize({ size: this.fileSize, total: true }) : Promise.resolve(); - promise.then(() => { - // User confirmed, add the file to queue. - return this.filepoolProvider.invalidateFileByUrl(this.siteId, this.fileUrl).finally(() => { - this.isDownloading = true; + this.pluginFileDelegate.getFileSize({fileurl: this.fileUrl, filesize: this.fileSize}, this.siteId).then((size) => { - this.filepoolProvider.addToQueueByUrl(this.siteId, this.fileUrl, this.component, - this.componentId, this.timemodified, undefined, undefined, 0, this.file).catch((error) => { - this.domUtils.showErrorModalDefault(error, 'core.errordownloading', true); - this.calculateState(); - }); + const promise = size ? this.domUtils.confirmDownloadSize({ size: size, total: true }) : Promise.resolve(); + + return promise.then(() => { + // User confirmed, add the file to queue. + return this.filepoolProvider.invalidateFileByUrl(this.siteId, this.fileUrl).finally(() => { + this.isDownloading = true; + + this.filepoolProvider.addToQueueByUrl(this.siteId, this.fileUrl, this.component, + this.componentId, this.timemodified, undefined, undefined, 0, this.file).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.errordownloading', true); + this.calculateState(); + }); + }); + }).catch(() => { + // User cancelled. }); - }).catch(() => { - // Ignore error. + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.errordownloading', true); }); } } diff --git a/src/components/iframe/core-iframe.html b/src/components/iframe/core-iframe.html index a94b5d110..7bdeac425 100644 --- a/src/components/iframe/core-iframe.html +++ b/src/components/iframe/core-iframe.html @@ -1,4 +1,4 @@ -
+
diff --git a/src/components/iframe/iframe.scss b/src/components/iframe/iframe.scss index 0d0e5ca92..782a554cb 100644 --- a/src/components/iframe/iframe.scss +++ b/src/components/iframe/iframe.scss @@ -1,7 +1,4 @@ ion-app.app-root core-iframe { - > div { - height: 100%; - } iframe { border: 0; display: block; diff --git a/src/components/site-picker/site-picker.ts b/src/components/site-picker/site-picker.ts index dca537560..60bfd17e5 100644 --- a/src/components/site-picker/site-picker.ts +++ b/src/components/site-picker/site-picker.ts @@ -51,7 +51,7 @@ export class CoreSitePickerComponent implements OnInit { sites.forEach((site: any) => { // Format the site name. promises.push(this.filterProvider.formatText(site.siteName, {clean: true, singleLine: true, filter: false}, [], - site.getId()).catch(() => { + site.id).catch(() => { return site.siteName; }).then((siteName) => { site.fullNameAndSiteName = this.translate.instant('core.fullnameandsitename', diff --git a/src/core/compile/providers/compile.ts b/src/core/compile/providers/compile.ts index bbfc35f48..8f0c7e1b6 100644 --- a/src/core/compile/providers/compile.ts +++ b/src/core/compile/providers/compile.ts @@ -30,6 +30,7 @@ import { CORE_COURSES_PROVIDERS } from '@core/courses/courses.module'; import { CORE_FILEUPLOADER_PROVIDERS } from '@core/fileuploader/fileuploader.module'; import { CORE_FILTER_PROVIDERS } from '@core/filter/filter.module'; import { CORE_GRADES_PROVIDERS } from '@core/grades/grades.module'; +import { CORE_H5P_PROVIDERS } from '@core/h5p/h5p.module'; import { CORE_LOGIN_PROVIDERS } from '@core/login/login.module'; import { CORE_MAINMENU_PROVIDERS } from '@core/mainmenu/mainmenu.module'; import { CORE_QUESTION_PROVIDERS } from '@core/question/question.module'; @@ -236,7 +237,7 @@ export class CoreCompileProvider { .concat(ADDON_MOD_SURVEY_PROVIDERS).concat(ADDON_MOD_URL_PROVIDERS).concat(ADDON_MOD_WIKI_PROVIDERS) .concat(ADDON_MOD_WORKSHOP_PROVIDERS).concat(ADDON_NOTES_PROVIDERS).concat(ADDON_NOTIFICATIONS_PROVIDERS) .concat(CORE_PUSHNOTIFICATIONS_PROVIDERS).concat(ADDON_REMOTETHEMES_PROVIDERS).concat(CORE_BLOCK_PROVIDERS) - .concat(CORE_FILTER_PROVIDERS); + .concat(CORE_FILTER_PROVIDERS).concat(CORE_H5P_PROVIDERS); // We cannot inject anything to this constructor. Use the Injector to inject all the providers into the instance. for (const i in providers) { diff --git a/src/core/course/classes/module-prefetch-handler.ts b/src/core/course/classes/module-prefetch-handler.ts index 2e344104a..815cbf5c4 100644 --- a/src/core/course/classes/module-prefetch-handler.ts +++ b/src/core/course/classes/module-prefetch-handler.ts @@ -21,6 +21,7 @@ import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreCourseProvider } from '../providers/course'; import { CoreCourseModulePrefetchHandler } from '../providers/module-prefetch-delegate'; import { CoreFilterHelperProvider } from '@core/filter/providers/helper'; +import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; /** * Base prefetch handler to be registered in CoreCourseModulePrefetchDelegate. Prefetch handlers should inherit either @@ -67,7 +68,8 @@ export class CoreCourseModulePrefetchHandlerBase implements CoreCourseModulePref protected filepoolProvider: CoreFilepoolProvider, protected sitesProvider: CoreSitesProvider, protected domUtils: CoreDomUtilsProvider, - protected filterHelper: CoreFilterHelperProvider) { } + protected filterHelper: CoreFilterHelperProvider, + protected pluginFileDelegate: CorePluginFileDelegate) { } /** * Add an ongoing download to the downloadPromises list. When the promise finishes it will be removed. @@ -137,7 +139,7 @@ export class CoreCourseModulePrefetchHandlerBase implements CoreCourseModulePref */ getDownloadSize(module: any, courseId: number, single?: boolean): Promise<{ size: number, total: boolean }> { return this.getFiles(module, courseId).then((files) => { - return this.utils.sumFileSizes(files); + return this.pluginFileDelegate.getFilesSize(files); }).catch(() => { return { size: -1, total: false }; }); @@ -193,12 +195,12 @@ export class CoreCourseModulePrefetchHandlerBase implements CoreCourseModulePref if (typeof instance.introfiles != 'undefined') { return instance.introfiles; } else if (instance.intro) { - return this.domUtils.extractDownloadableFilesFromHtmlAsFakeFileObjects(instance.intro); + return this.filepoolProvider.extractDownloadableFilesFromHtmlAsFakeFileObjects(instance.intro); } } if (module.description) { - return this.domUtils.extractDownloadableFilesFromHtmlAsFakeFileObjects(module.description); + return this.filepoolProvider.extractDownloadableFilesFromHtmlAsFakeFileObjects(module.description); } return []; diff --git a/src/core/course/providers/helper.ts b/src/core/course/providers/helper.ts index 82b9d03d6..0501ba682 100644 --- a/src/core/course/providers/helper.ts +++ b/src/core/course/providers/helper.ts @@ -438,7 +438,7 @@ export class CoreCourseHelperProvider { sizePromise = this.prefetchDelegate.getDownloadSize(section.modules, courseId); // Check if the section has embedded files in the description. - haveEmbeddedFiles = this.domUtils.extractDownloadableFilesFromHtml(section.summary).length > 0; + haveEmbeddedFiles = this.filepoolProvider.extractDownloadableFilesFromHtml(section.summary).length > 0; } else { const promises = [], results = { @@ -454,7 +454,7 @@ export class CoreCourseHelperProvider { })); // Check if the section has embedded files in the description. - if (!haveEmbeddedFiles && this.domUtils.extractDownloadableFilesFromHtml(s.summary).length > 0) { + if (!haveEmbeddedFiles && this.filepoolProvider.extractDownloadableFilesFromHtml(s.summary).length > 0) { haveEmbeddedFiles = true; } } @@ -1089,7 +1089,7 @@ export class CoreCourseHelperProvider { // Get the time it was downloaded (if it was downloaded). promises.push(this.filepoolProvider.getPackageData(siteId, component, module.id).then((data) => { - if (data && data.downloadTime && (data.status == CoreConstants.OUTDATED || data.status == CoreConstants.DOWNLOADED)) { + if (data && data.downloadTime && this.fileHelper.isStateDownloaded(data.status)) { const now = this.timeUtils.timestamp(); moduleInfo.downloadTime = data.downloadTime; if (now - data.downloadTime < 7 * 86400) { @@ -1449,7 +1449,7 @@ export class CoreCourseHelperProvider { })); // Download the files in the section description. - const introFiles = this.domUtils.extractDownloadableFilesFromHtmlAsFakeFileObjects(section.summary), + const introFiles = this.filepoolProvider.extractDownloadableFilesFromHtmlAsFakeFileObjects(section.summary), siteId = this.sitesProvider.getCurrentSiteId(); promises.push(this.filepoolProvider.addFilesToQueue(siteId, introFiles, CoreCourseProvider.COMPONENT, courseId) diff --git a/src/core/course/providers/module-prefetch-delegate.ts b/src/core/course/providers/module-prefetch-delegate.ts index 58cda1d91..6e05b283b 100644 --- a/src/core/course/providers/module-prefetch-delegate.ts +++ b/src/core/course/providers/module-prefetch-delegate.ts @@ -27,6 +27,7 @@ import { CoreConstants } from '../../constants'; import { Md5 } from 'ts-md5/dist/md5'; import { Subject, BehaviorSubject, Subscription } from 'rxjs'; import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; +import { CoreFileHelperProvider } from '@providers/file-helper'; /** * Progress of downloading a list of modules. @@ -258,10 +259,15 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { } } = {}; - constructor(loggerProvider: CoreLoggerProvider, protected sitesProvider: CoreSitesProvider, private utils: CoreUtilsProvider, - private courseProvider: CoreCourseProvider, private filepoolProvider: CoreFilepoolProvider, - private timeUtils: CoreTimeUtilsProvider, private fileProvider: CoreFileProvider, - protected eventsProvider: CoreEventsProvider) { + constructor(loggerProvider: CoreLoggerProvider, + protected sitesProvider: CoreSitesProvider, + protected utils: CoreUtilsProvider, + protected courseProvider: CoreCourseProvider, + protected filepoolProvider: CoreFilepoolProvider, + protected timeUtils: CoreTimeUtilsProvider, + protected fileProvider: CoreFileProvider, + protected eventsProvider: CoreEventsProvider, + protected fileHelper: CoreFileHelperProvider) { super('CoreCourseModulePrefetchDelegate', loggerProvider, sitesProvider, eventsProvider); this.sitesProvider.registerSiteSchema(this.siteSchema); @@ -881,7 +887,7 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { const packageId = this.filepoolProvider.getPackageId(handler.component, module.id), status = this.statusCache.getValue(packageId, 'status'); - if (typeof status != 'undefined' && status != CoreConstants.DOWNLOADED && status != CoreConstants.OUTDATED) { + if (typeof status != 'undefined' && !this.fileHelper.isStateDownloaded(status)) { // Module isn't downloaded, just return the status. return Promise.resolve({ status: status @@ -927,7 +933,7 @@ export class CoreCourseModulePrefetchDelegate extends CoreDelegate { return this.sitesProvider.getSite(siteId).then((site) => { // Get the status and download time of the module. return this.getModuleStatusAndDownloadTime(module, courseId).then((data) => { - if (data.status != CoreConstants.DOWNLOADED && data.status != CoreConstants.OUTDATED) { + if (!this.fileHelper.isStateDownloaded(data.status)) { // Not downloaded, no updates. return {}; } diff --git a/src/core/filter/providers/default-filter.ts b/src/core/filter/providers/default-filter.ts index bb9ff9d32..f5f307500 100644 --- a/src/core/filter/providers/default-filter.ts +++ b/src/core/filter/providers/default-filter.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Injectable } from '@angular/core'; +import { Injectable, ViewContainerRef } from '@angular/core'; import { CoreFilterHandler } from './delegate'; import { CoreFilterFilter, CoreFilterFormatTextOptions } from './filter'; import { CoreSite } from '@classes/site'; @@ -50,10 +50,14 @@ export class CoreFilterDefaultHandler implements CoreFilterHandler { * @param container The HTML container to handle. * @param filter The filter. * @param options Options passed to the filters. + * @param viewContainerRef The ViewContainerRef where the container is. + * @param component Component. + * @param componentId Component ID. * @param siteId Site ID. If not defined, current site. * @return If async, promise resolved when done. */ - handleHtml(container: HTMLElement, filter: CoreFilterFilter, options: CoreFilterFormatTextOptions, siteId?: string) + handleHtml(container: HTMLElement, filter: CoreFilterFilter, options: CoreFilterFormatTextOptions, + viewContainerRef: ViewContainerRef, component?: string, componentId?: string | number, siteId?: string) : void | Promise { // To be overridden. } diff --git a/src/core/filter/providers/delegate.ts b/src/core/filter/providers/delegate.ts index ef1e5852a..642a454ca 100644 --- a/src/core/filter/providers/delegate.ts +++ b/src/core/filter/providers/delegate.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Injectable } from '@angular/core'; +import { Injectable, ViewContainerRef } from '@angular/core'; import { CoreEventsProvider } from '@providers/events'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreSitesProvider } from '@providers/sites'; @@ -48,10 +48,14 @@ export interface CoreFilterHandler extends CoreDelegateHandler { * @param container The HTML container to handle. * @param filter The filter. * @param options Options passed to the filters. + * @param viewContainerRef The ViewContainerRef where the container is. + * @param component Component. + * @param componentId Component ID. * @param siteId Site ID. If not defined, current site. * @return If async, promise resolved when done. */ - handleHtml?(container: HTMLElement, filter: CoreFilterFilter, options: CoreFilterFormatTextOptions, siteId?: string) + handleHtml?(container: HTMLElement, filter: CoreFilterFilter, options: CoreFilterFormatTextOptions, + viewContainerRef: ViewContainerRef, component?: string, componentId?: string | number, siteId?: string) : void | Promise; /** @@ -156,13 +160,16 @@ export class CoreFilterDelegate extends CoreDelegate { * * @param container The HTML container to handle. * @param filters Filters to apply. + * @param viewContainerRef The ViewContainerRef where the container is. * @param options Options passed to the filters. * @param skipFilters Names of filters that shouldn't be applied. + * @param component Component. + * @param componentId Component ID. * @param siteId Site ID. If not defined, current site. * @return Promise resolved when done. */ - handleHtml(container: HTMLElement, filters: CoreFilterFilter[], options?: any, skipFilters?: string[], siteId?: string) - : Promise { + handleHtml(container: HTMLElement, filters: CoreFilterFilter[], viewContainerRef?: ViewContainerRef, options?: any, + skipFilters?: string[], component?: string, componentId?: string | number, siteId?: string): Promise { // Wait for filters to be initialized. return this.handlersInitPromise.then(() => { @@ -181,8 +188,9 @@ export class CoreFilterDelegate extends CoreDelegate { } promise = promise.then(() => { + return Promise.resolve(this.executeFunctionOnEnabled(filter.filter, 'handleHtml', - [container, filter, options, siteId])).catch((error) => { + [container, filter, options, viewContainerRef, component, componentId, siteId])).catch((error) => { this.logger.error('Error handling HTML' + filter.filter, error); }); }); diff --git a/src/core/filter/providers/filter.ts b/src/core/filter/providers/filter.ts index 42802335f..a404d5616 100644 --- a/src/core/filter/providers/filter.ts +++ b/src/core/filter/providers/filter.ts @@ -402,7 +402,7 @@ export type CoreFilterFilter = { */ export type CoreFilterGetAvailableInContextResult = { filters: CoreFilterFilter[]; // Available filters. - warning: CoreWSExternalWarning[]; // List of warnings. + warnings: CoreWSExternalWarning[]; // List of warnings. }; /** diff --git a/src/core/h5p/assets/fonts/h5p-core-23.eot b/src/core/h5p/assets/fonts/h5p-core-23.eot new file mode 100644 index 000000000..f86828cff Binary files /dev/null and b/src/core/h5p/assets/fonts/h5p-core-23.eot differ diff --git a/src/core/h5p/assets/fonts/h5p-core-23.svg b/src/core/h5p/assets/fonts/h5p-core-23.svg new file mode 100644 index 000000000..dd65f1809 --- /dev/null +++ b/src/core/h5p/assets/fonts/h5p-core-23.svg @@ -0,0 +1,62 @@ + + + + + + +{ + "fontFamily": "h5p-core-21", + "description": "Font generated by IcoMoon.", + "majorVersion": 1, + "minorVersion": 1, + "version": "Version 1.1", + "fontId": "h5p-core-21", + "psName": "h5p-core-21", + "subFamily": "Regular", + "fullName": "h5p-core-21" +} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/core/h5p/assets/fonts/h5p-core-23.ttf b/src/core/h5p/assets/fonts/h5p-core-23.ttf new file mode 100644 index 000000000..3a3348adf Binary files /dev/null and b/src/core/h5p/assets/fonts/h5p-core-23.ttf differ diff --git a/src/core/h5p/assets/fonts/h5p-core-23.woff b/src/core/h5p/assets/fonts/h5p-core-23.woff new file mode 100644 index 000000000..ff03f4248 Binary files /dev/null and b/src/core/h5p/assets/fonts/h5p-core-23.woff differ diff --git a/src/core/h5p/assets/js/h5p-action-bar.js b/src/core/h5p/assets/js/h5p-action-bar.js new file mode 100644 index 000000000..608a848b3 --- /dev/null +++ b/src/core/h5p/assets/js/h5p-action-bar.js @@ -0,0 +1,100 @@ +/** + * @class + * @augments H5P.EventDispatcher + * @param {Object} displayOptions + * @param {boolean} displayOptions.export Triggers the display of the 'Download' button + * @param {boolean} displayOptions.copyright Triggers the display of the 'Copyright' button + * @param {boolean} displayOptions.embed Triggers the display of the 'Embed' button + * @param {boolean} displayOptions.icon Triggers the display of the 'H5P icon' link + */ +H5P.ActionBar = (function ($, EventDispatcher) { + "use strict"; + + function ActionBar(displayOptions) { + EventDispatcher.call(this); + + /** @alias H5P.ActionBar# */ + var self = this; + + var hasActions = false; + + // Create action bar + var $actions = H5P.jQuery('
    '); + + /** + * Helper for creating action bar buttons. + * + * @private + * @param {string} type + * @param {string} customClass Instead of type class + */ + var addActionButton = function (type, customClass) { + /** + * Handles selection of action + */ + var handler = function () { + self.trigger(type); + }; + H5P.jQuery('
  • ', { + 'class': 'h5p-button h5p-noselect h5p-' + (customClass ? customClass : type), + role: 'button', + tabindex: 0, + title: H5P.t(type + 'Description'), + html: H5P.t(type), + on: { + click: handler, + keypress: function (e) { + if (e.which === 32) { + handler(); + e.preventDefault(); // (since return false will block other inputs) + } + } + }, + appendTo: $actions + }); + + hasActions = true; + }; + + // Register action bar buttons + if (displayOptions.export || displayOptions.copy) { + // Add export button + addActionButton('reuse', 'export'); + } + if (displayOptions.copyright) { + addActionButton('copyrights'); + } + if (displayOptions.embed) { + addActionButton('embed'); + } + if (displayOptions.icon) { + // Add about H5P button icon + H5P.jQuery('
  • ').appendTo($actions); + hasActions = true; + } + + /** + * Returns a reference to the dom element + * + * @return {H5P.jQuery} + */ + self.getDOMElement = function () { + return $actions; + }; + + /** + * Does the actionbar contain actions? + * + * @return {Boolean} + */ + self.hasActions = function () { + return hasActions; + }; + } + + ActionBar.prototype = Object.create(EventDispatcher.prototype); + ActionBar.prototype.constructor = ActionBar; + + return ActionBar; + +})(H5P.jQuery, H5P.EventDispatcher); diff --git a/src/core/h5p/assets/js/h5p-confirmation-dialog.js b/src/core/h5p/assets/js/h5p-confirmation-dialog.js new file mode 100644 index 000000000..cd3536e7a --- /dev/null +++ b/src/core/h5p/assets/js/h5p-confirmation-dialog.js @@ -0,0 +1,410 @@ +/*global H5P*/ +H5P.ConfirmationDialog = (function (EventDispatcher) { + "use strict"; + + /** + * Create a confirmation dialog + * + * @param [options] Options for confirmation dialog + * @param [options.instance] Instance that uses confirmation dialog + * @param [options.headerText] Header text + * @param [options.dialogText] Dialog text + * @param [options.cancelText] Cancel dialog button text + * @param [options.confirmText] Confirm dialog button text + * @param [options.hideCancel] Hide cancel button + * @param [options.hideExit] Hide exit button + * @param [options.skipRestoreFocus] Skip restoring focus when hiding the dialog + * @param [options.classes] Extra classes for popup + * @constructor + */ + function ConfirmationDialog(options) { + EventDispatcher.call(this); + var self = this; + + // Make sure confirmation dialogs have unique id + H5P.ConfirmationDialog.uniqueId += 1; + var uniqueId = H5P.ConfirmationDialog.uniqueId; + + // Default options + options = options || {}; + options.headerText = options.headerText || H5P.t('confirmDialogHeader'); + options.dialogText = options.dialogText || H5P.t('confirmDialogBody'); + options.cancelText = options.cancelText || H5P.t('cancelLabel'); + options.confirmText = options.confirmText || H5P.t('confirmLabel'); + + /** + * Handle confirming event + * @param {Event} e + */ + function dialogConfirmed(e) { + self.hide(); + self.trigger('confirmed'); + e.preventDefault(); + } + + /** + * Handle dialog canceled + * @param {Event} e + */ + function dialogCanceled(e) { + self.hide(); + self.trigger('canceled'); + e.preventDefault(); + } + + /** + * Flow focus to element + * @param {HTMLElement} element Next element to be focused + * @param {Event} e Original tab event + */ + function flowTo(element, e) { + element.focus(); + e.preventDefault(); + } + + // Offset of exit button + var exitButtonOffset = 2 * 16; + var shadowOffset = 8; + + // Determine if we are too large for our container and must resize + var resizeIFrame = false; + + // Create background + var popupBackground = document.createElement('div'); + popupBackground.classList + .add('h5p-confirmation-dialog-background', 'hidden', 'hiding'); + + // Create outer popup + var popup = document.createElement('div'); + popup.classList.add('h5p-confirmation-dialog-popup', 'hidden'); + if (options.classes) { + options.classes.forEach(function (popupClass) { + popup.classList.add(popupClass); + }); + } + + popup.setAttribute('role', 'dialog'); + popup.setAttribute('aria-labelledby', 'h5p-confirmation-dialog-dialog-text-' + uniqueId); + popupBackground.appendChild(popup); + popup.addEventListener('keydown', function (e) { + if (e.which === 27) {// Esc key + // Exit dialog + dialogCanceled(e); + } + }); + + // Popup header + var header = document.createElement('div'); + header.classList.add('h5p-confirmation-dialog-header'); + popup.appendChild(header); + + // Header text + var headerText = document.createElement('div'); + headerText.classList.add('h5p-confirmation-dialog-header-text'); + headerText.innerHTML = options.headerText; + header.appendChild(headerText); + + // Popup body + var body = document.createElement('div'); + body.classList.add('h5p-confirmation-dialog-body'); + popup.appendChild(body); + + // Popup text + var text = document.createElement('div'); + text.classList.add('h5p-confirmation-dialog-text'); + text.innerHTML = options.dialogText; + text.id = 'h5p-confirmation-dialog-dialog-text-' + uniqueId; + body.appendChild(text); + + // Popup buttons + var buttons = document.createElement('div'); + buttons.classList.add('h5p-confirmation-dialog-buttons'); + body.appendChild(buttons); + + // Cancel button + var cancelButton = document.createElement('button'); + cancelButton.classList.add('h5p-core-cancel-button'); + cancelButton.textContent = options.cancelText; + + // Confirm button + var confirmButton = document.createElement('button'); + confirmButton.classList.add('h5p-core-button'); + confirmButton.classList.add('h5p-confirmation-dialog-confirm-button'); + confirmButton.textContent = options.confirmText; + + // Exit button + var exitButton = document.createElement('button'); + exitButton.classList.add('h5p-confirmation-dialog-exit'); + exitButton.setAttribute('aria-hidden', 'true'); + exitButton.tabIndex = -1; + exitButton.title = options.cancelText; + + // Cancel handler + cancelButton.addEventListener('click', dialogCanceled); + cancelButton.addEventListener('keydown', function (e) { + if (e.which === 32) { // Space + dialogCanceled(e); + } + else if (e.which === 9 && e.shiftKey) { // Shift-tab + flowTo(confirmButton, e); + } + }); + + if (!options.hideCancel) { + buttons.appendChild(cancelButton); + } + else { + // Center buttons + buttons.classList.add('center'); + } + + // Confirm handler + confirmButton.addEventListener('click', dialogConfirmed); + confirmButton.addEventListener('keydown', function (e) { + if (e.which === 32) { // Space + dialogConfirmed(e); + } + else if (e.which === 9 && !e.shiftKey) { // Tab + const nextButton = !options.hideCancel ? cancelButton : confirmButton; + flowTo(nextButton, e); + } + }); + buttons.appendChild(confirmButton); + + // Exit handler + exitButton.addEventListener('click', dialogCanceled); + exitButton.addEventListener('keydown', function (e) { + if (e.which === 32) { // Space + dialogCanceled(e); + } + }); + if (!options.hideExit) { + popup.appendChild(exitButton); + } + + // Wrapper element + var wrapperElement; + + // Focus capturing + var focusPredator; + + // Maintains hidden state of elements + var wrapperSiblingsHidden = []; + var popupSiblingsHidden = []; + + // Element with focus before dialog + var previouslyFocused; + + /** + * Set parent of confirmation dialog + * @param {HTMLElement} wrapper + * @returns {H5P.ConfirmationDialog} + */ + this.appendTo = function (wrapper) { + wrapperElement = wrapper; + return this; + }; + + /** + * Capture the focus element, send it to confirmation button + * @param {Event} e Original focus event + */ + var captureFocus = function (e) { + if (!popupBackground.contains(e.target)) { + e.preventDefault(); + confirmButton.focus(); + } + }; + + /** + * Hide siblings of element from assistive technology + * + * @param {HTMLElement} element + * @returns {Array} The previous hidden state of all siblings + */ + var hideSiblings = function (element) { + var hiddenSiblings = []; + var siblings = element.parentNode.children; + var i; + for (i = 0; i < siblings.length; i += 1) { + // Preserve hidden state + hiddenSiblings[i] = siblings[i].getAttribute('aria-hidden') ? + true : false; + + if (siblings[i] !== element) { + siblings[i].setAttribute('aria-hidden', true); + } + } + return hiddenSiblings; + }; + + /** + * Restores assistive technology state of element's siblings + * + * @param {HTMLElement} element + * @param {Array} hiddenSiblings Hidden state of all siblings + */ + var restoreSiblings = function (element, hiddenSiblings) { + var siblings = element.parentNode.children; + var i; + for (i = 0; i < siblings.length; i += 1) { + if (siblings[i] !== element && !hiddenSiblings[i]) { + siblings[i].removeAttribute('aria-hidden'); + } + } + }; + + /** + * Start capturing focus of parent and send it to dialog + */ + var startCapturingFocus = function () { + focusPredator = wrapperElement.parentNode || wrapperElement; + focusPredator.addEventListener('focus', captureFocus, true); + }; + + /** + * Clean up event listener for capturing focus + */ + var stopCapturingFocus = function () { + focusPredator.removeAttribute('aria-hidden'); + focusPredator.removeEventListener('focus', captureFocus, true); + }; + + /** + * Hide siblings in underlay from assistive technologies + */ + var disableUnderlay = function () { + wrapperSiblingsHidden = hideSiblings(wrapperElement); + popupSiblingsHidden = hideSiblings(popupBackground); + }; + + /** + * Restore state of underlay for assistive technologies + */ + var restoreUnderlay = function () { + restoreSiblings(wrapperElement, wrapperSiblingsHidden); + restoreSiblings(popupBackground, popupSiblingsHidden); + }; + + /** + * Fit popup to container. Makes sure it doesn't overflow. + * @params {number} [offsetTop] Offset of popup + */ + var fitToContainer = function (offsetTop) { + var popupOffsetTop = parseInt(popup.style.top, 10); + if (offsetTop !== undefined) { + popupOffsetTop = offsetTop; + } + + if (!popupOffsetTop) { + popupOffsetTop = 0; + } + + // Overflows height + if (popupOffsetTop + popup.offsetHeight > wrapperElement.offsetHeight) { + popupOffsetTop = wrapperElement.offsetHeight - popup.offsetHeight - shadowOffset; + } + + if (popupOffsetTop - exitButtonOffset <= 0) { + popupOffsetTop = exitButtonOffset + shadowOffset; + + // We are too big and must resize + resizeIFrame = true; + } + popup.style.top = popupOffsetTop + 'px'; + }; + + /** + * Show confirmation dialog + * @params {number} offsetTop Offset top + * @returns {H5P.ConfirmationDialog} + */ + this.show = function (offsetTop) { + // Capture focused item + previouslyFocused = document.activeElement; + wrapperElement.appendChild(popupBackground); + startCapturingFocus(); + disableUnderlay(); + popupBackground.classList.remove('hidden'); + fitToContainer(offsetTop); + setTimeout(function () { + popup.classList.remove('hidden'); + popupBackground.classList.remove('hiding'); + + setTimeout(function () { + // Focus confirm button + confirmButton.focus(); + + // Resize iFrame if necessary + if (resizeIFrame && options.instance) { + var minHeight = parseInt(popup.offsetHeight, 10) + + exitButtonOffset + (2 * shadowOffset); + self.setViewPortMinimumHeight(minHeight); + options.instance.trigger('resize'); + resizeIFrame = false; + } + }, 100); + }, 0); + + return this; + }; + + /** + * Hide confirmation dialog + * @returns {H5P.ConfirmationDialog} + */ + this.hide = function () { + popupBackground.classList.add('hiding'); + popup.classList.add('hidden'); + + // Restore focus + stopCapturingFocus(); + if (!options.skipRestoreFocus) { + previouslyFocused.focus(); + } + restoreUnderlay(); + setTimeout(function () { + popupBackground.classList.add('hidden'); + wrapperElement.removeChild(popupBackground); + self.setViewPortMinimumHeight(null); + }, 100); + + return this; + }; + + /** + * Retrieve element + * + * @return {HTMLElement} + */ + this.getElement = function () { + return popup; + }; + + /** + * Get previously focused element + * @return {HTMLElement} + */ + this.getPreviouslyFocused = function () { + return previouslyFocused; + }; + + /** + * Sets the minimum height of the view port + * + * @param {number|null} minHeight + */ + this.setViewPortMinimumHeight = function (minHeight) { + var container = document.querySelector('.h5p-container') || document.body; + container.style.minHeight = (typeof minHeight === 'number') ? (minHeight + 'px') : minHeight; + }; + } + + ConfirmationDialog.prototype = Object.create(EventDispatcher.prototype); + ConfirmationDialog.prototype.constructor = ConfirmationDialog; + + return ConfirmationDialog; + +}(H5P.EventDispatcher)); + +H5P.ConfirmationDialog.uniqueId = -1; diff --git a/src/core/h5p/assets/js/h5p-content-type.js b/src/core/h5p/assets/js/h5p-content-type.js new file mode 100644 index 000000000..47c4d21bf --- /dev/null +++ b/src/core/h5p/assets/js/h5p-content-type.js @@ -0,0 +1,41 @@ +/** + * H5P.ContentType is a base class for all content types. Used by newRunnable() + * + * Functions here may be overridable by the libraries. In special cases, + * it is also possible to override H5P.ContentType on a global level. + * + * NOTE that this doesn't actually 'extend' the event dispatcher but instead + * it creates a single instance which all content types shares as their base + * prototype. (in some cases this may be the root of strange event behavior) + * + * @class + * @augments H5P.EventDispatcher + */ +H5P.ContentType = function (isRootLibrary) { + + function ContentType() {} + + // Inherit from EventDispatcher. + ContentType.prototype = new H5P.EventDispatcher(); + + /** + * Is library standalone or not? Not beeing standalone, means it is + * included in another library + * + * @return {Boolean} + */ + ContentType.prototype.isRoot = function () { + return isRootLibrary; + }; + + /** + * Returns the file path of a file in the current library + * @param {string} filePath The path to the file relative to the library folder + * @return {string} The full path to the file + */ + ContentType.prototype.getLibraryFilePath = function (filePath) { + return H5P.getLibraryPath(this.libraryInfo.versionedNameNoSpaces) + '/' + filePath; + }; + + return ContentType; +}; diff --git a/src/core/h5p/assets/js/h5p-content-upgrade-process.js b/src/core/h5p/assets/js/h5p-content-upgrade-process.js new file mode 100644 index 000000000..fbaa4f2bf --- /dev/null +++ b/src/core/h5p/assets/js/h5p-content-upgrade-process.js @@ -0,0 +1,313 @@ +/*jshint -W083 */ +var H5PUpgrades = H5PUpgrades || {}; + +H5P.ContentUpgradeProcess = (function (Version) { + + /** + * @class + * @namespace H5P + */ + function ContentUpgradeProcess(name, oldVersion, newVersion, params, id, loadLibrary, done) { + var self = this; + + // Make params possible to work with + try { + params = JSON.parse(params); + if (!(params instanceof Object)) { + throw true; + } + } + catch (event) { + return done({ + type: 'errorParamsBroken', + id: id + }); + } + + self.loadLibrary = loadLibrary; + self.upgrade(name, oldVersion, newVersion, params.params, params.metadata, function (err, upgradedParams, upgradedMetadata) { + if (err) { + err.id = id; + return done(err); + } + + done(null, JSON.stringify({params: upgradedParams, metadata: upgradedMetadata})); + }); + } + + /** + * Run content upgrade. + * + * @public + * @param {string} name + * @param {Version} oldVersion + * @param {Version} newVersion + * @param {Object} params + * @param {Object} metadata + * @param {Function} done + */ + ContentUpgradeProcess.prototype.upgrade = function (name, oldVersion, newVersion, params, metadata, done) { + var self = this; + + // Load library details and upgrade routines + self.loadLibrary(name, newVersion, function (err, library) { + if (err) { + return done(err); + } + if (library.semantics === null) { + return done({ + type: 'libraryMissing', + library: library.name + ' ' + library.version.major + '.' + library.version.minor + }); + } + + // Run upgrade routines on params + self.processParams(library, oldVersion, newVersion, params, metadata, function (err, params, metadata) { + if (err) { + return done(err); + } + + // Check if any of the sub-libraries need upgrading + asyncSerial(library.semantics, function (index, field, next) { + self.processField(field, params[field.name], function (err, upgradedParams) { + if (upgradedParams) { + params[field.name] = upgradedParams; + } + next(err); + }); + }, function (err) { + done(err, params, metadata); + }); + }); + }); + }; + + /** + * Run upgrade hooks on params. + * + * @public + * @param {Object} library + * @param {Version} oldVersion + * @param {Version} newVersion + * @param {Object} params + * @param {Function} next + */ + ContentUpgradeProcess.prototype.processParams = function (library, oldVersion, newVersion, params, metadata, next) { + if (H5PUpgrades[library.name] === undefined) { + if (library.upgradesScript) { + // Upgrades script should be loaded so the upgrades should be here. + return next({ + type: 'scriptMissing', + library: library.name + ' ' + newVersion + }); + } + + // No upgrades script. Move on + return next(null, params, metadata); + } + + // Run upgrade hooks. Start by going through major versions + asyncSerial(H5PUpgrades[library.name], function (major, minors, nextMajor) { + if (major < oldVersion.major || major > newVersion.major) { + // Older than the current version or newer than the selected + nextMajor(); + } + else { + // Go through the minor versions for this major version + asyncSerial(minors, function (minor, upgrade, nextMinor) { + minor =+ minor; + if (minor <= oldVersion.minor || minor > newVersion.minor) { + // Older than or equal to the current version or newer than the selected + nextMinor(); + } + else { + // We found an upgrade hook, run it + var unnecessaryWrapper = (upgrade.contentUpgrade !== undefined ? upgrade.contentUpgrade : upgrade); + + try { + unnecessaryWrapper(params, function (err, upgradedParams, upgradedExtras) { + params = upgradedParams; + if (upgradedExtras && upgradedExtras.metadata) { // Optional + metadata = upgradedExtras.metadata; + } + nextMinor(err); + }, {metadata: metadata}); + } + catch (err) { + if (console && console.error) { + console.error("Error", err.stack); + console.error("Error", err.name); + console.error("Error", err.message); + } + next(err); + } + } + }, nextMajor); + } + }, function (err) { + next(err, params, metadata); + }); + }; + + /** + * Process parameter fields to find and upgrade sub-libraries. + * + * @public + * @param {Object} field + * @param {Object} params + * @param {Function} done + */ + ContentUpgradeProcess.prototype.processField = function (field, params, done) { + var self = this; + + if (params === undefined) { + return done(); + } + + switch (field.type) { + case 'library': + if (params.library === undefined || params.params === undefined) { + return done(); + } + + // Look for available upgrades + var usedLib = params.library.split(' ', 2); + for (var i = 0; i < field.options.length; i++) { + var availableLib = (typeof field.options[i] === 'string') ? field.options[i].split(' ', 2) : field.options[i].name.split(' ', 2); + if (availableLib[0] === usedLib[0]) { + if (availableLib[1] === usedLib[1]) { + return done(); // Same version + } + + // We have different versions + var usedVer = new Version(usedLib[1]); + var availableVer = new Version(availableLib[1]); + if (usedVer.major > availableVer.major || (usedVer.major === availableVer.major && usedVer.minor >= availableVer.minor)) { + return done({ + type: 'errorTooHighVersion', + used: usedLib[0] + ' ' + usedVer, + supported: availableLib[0] + ' ' + availableVer + }); // Larger or same version that's available + } + + // A newer version is available, upgrade params + return self.upgrade(availableLib[0], usedVer, availableVer, params.params, params.metadata, function (err, upgradedParams, upgradedMetadata) { + if (!err) { + params.library = availableLib[0] + ' ' + availableVer.major + '.' + availableVer.minor; + params.params = upgradedParams; + if (upgradedMetadata) { + params.metadata = upgradedMetadata; + } + } + done(err, params); + }); + } + } + + // Content type was not supporte by the higher version + done({ + type: 'errorNotSupported', + used: usedLib[0] + ' ' + usedVer + }); + break; + + case 'group': + if (field.fields.length === 1 && field.isSubContent !== true) { + // Single field to process, wrapper will be skipped + self.processField(field.fields[0], params, function (err, upgradedParams) { + if (upgradedParams) { + params = upgradedParams; + } + done(err, params); + }); + } + else { + // Go through all fields in the group + asyncSerial(field.fields, function (index, subField, next) { + var paramsToProcess = params ? params[subField.name] : null; + self.processField(subField, paramsToProcess, function (err, upgradedParams) { + if (upgradedParams) { + params[subField.name] = upgradedParams; + } + next(err); + }); + + }, function (err) { + done(err, params); + }); + } + break; + + case 'list': + // Go trough all params in the list + asyncSerial(params, function (index, subParams, next) { + self.processField(field.field, subParams, function (err, upgradedParams) { + if (upgradedParams) { + params[index] = upgradedParams; + } + next(err); + }); + }, function (err) { + done(err, params); + }); + break; + + default: + done(); + } + }; + + /** + * Helps process each property on the given object asynchronously in serial order. + * + * @private + * @param {Object} obj + * @param {Function} process + * @param {Function} finished + */ + var asyncSerial = function (obj, process, finished) { + var id, isArray = obj instanceof Array; + + // Keep track of each property that belongs to this object. + if (!isArray) { + var ids = []; + for (id in obj) { + if (obj.hasOwnProperty(id)) { + ids.push(id); + } + } + } + + var i = -1; // Keeps track of the current property + + /** + * Private. Process the next property + */ + var next = function () { + id = isArray ? i : ids[i]; + process(id, obj[id], check); + }; + + /** + * Private. Check if we're done or have an error. + * + * @param {String} err + */ + var check = function (err) { + // We need to use a real async function in order for the stack to clear. + setTimeout(function () { + i++; + if (i === (isArray ? obj.length : ids.length) || (err !== undefined && err !== null)) { + finished(err); + } + else { + next(); + } + }, 0); + }; + + check(); // Start + }; + + return ContentUpgradeProcess; +})(H5P.Version); diff --git a/src/core/h5p/assets/js/h5p-content-upgrade-worker.js b/src/core/h5p/assets/js/h5p-content-upgrade-worker.js new file mode 100644 index 000000000..3507a358a --- /dev/null +++ b/src/core/h5p/assets/js/h5p-content-upgrade-worker.js @@ -0,0 +1,63 @@ +/* global importScripts */ +var H5P = H5P || {}; +importScripts('h5p-version.js', 'h5p-content-upgrade-process.js'); + +var libraryLoadedCallback; + +/** + * Register message handlers + */ +var messageHandlers = { + newJob: function (job) { + // Start new job + new H5P.ContentUpgradeProcess(job.name, new H5P.Version(job.oldVersion), new H5P.Version(job.newVersion), job.params, job.id, function loadLibrary(name, version, next) { + // TODO: Cache? + postMessage({ + action: 'loadLibrary', + name: name, + version: version.toString() + }); + libraryLoadedCallback = next; + }, function done(err, result) { + if (err) { + // Return error + postMessage({ + action: 'error', + id: job.id, + err: err.message ? err.message : err + }); + + return; + } + + // Return upgraded content + postMessage({ + action: 'done', + id: job.id, + params: result + }); + }); + }, + libraryLoaded: function (data) { + var library = data.library; + if (library.upgradesScript) { + try { + importScripts(library.upgradesScript); + } + catch (err) { + libraryLoadedCallback(err); + return; + } + } + libraryLoadedCallback(null, data.library); + } +}; + +/** + * Handle messages from our master + */ +onmessage = function (event) { + if (event.data.action !== undefined && messageHandlers[event.data.action]) { + messageHandlers[event.data.action].call(this, event.data); + } +}; diff --git a/src/core/h5p/assets/js/h5p-content-upgrade.js b/src/core/h5p/assets/js/h5p-content-upgrade.js new file mode 100644 index 000000000..9dc066c5c --- /dev/null +++ b/src/core/h5p/assets/js/h5p-content-upgrade.js @@ -0,0 +1,445 @@ +/* global H5PAdminIntegration H5PUtils */ + +(function ($, Version) { + var info, $log, $container, librariesCache = {}, scriptsCache = {}; + + // Initialize + $(document).ready(function () { + // Get library info + info = H5PAdminIntegration.libraryInfo; + + // Get and reset container + const $wrapper = $('#h5p-admin-container').html(''); + $log = $('
      ').appendTo($wrapper); + $container = $('

      ' + info.message + '

      ').appendTo($wrapper); + + // Make it possible to select version + var $version = $(getVersionSelect(info.versions)).appendTo($container); + + // Add "go" button + $(''); + H5PLibraryDetails.$next = $(''); + + H5PLibraryDetails.$previous.on('click', function () { + if (H5PLibraryDetails.$previous.hasClass('disabled')) { + return; + } + + H5PLibraryDetails.currentPage--; + H5PLibraryDetails.updatePager(); + H5PLibraryDetails.createContentTable(); + }); + + H5PLibraryDetails.$next.on('click', function () { + if (H5PLibraryDetails.$next.hasClass('disabled')) { + return; + } + + H5PLibraryDetails.currentPage++; + H5PLibraryDetails.updatePager(); + H5PLibraryDetails.createContentTable(); + }); + + // This is the Page x of y widget: + H5PLibraryDetails.$pagerInfo = $(''); + + H5PLibraryDetails.$pager = $('
      ').append(H5PLibraryDetails.$previous, H5PLibraryDetails.$pagerInfo, H5PLibraryDetails.$next); + H5PLibraryDetails.$content.append(H5PLibraryDetails.$pager); + + H5PLibraryDetails.$pagerInfo.on('click', function () { + var width = H5PLibraryDetails.$pagerInfo.innerWidth(); + H5PLibraryDetails.$pagerInfo.hide(); + + // User has updated the pageNumber + var pageNumerUpdated = function () { + var newPageNum = $gotoInput.val()-1; + var intRegex = /^\d+$/; + + $goto.remove(); + H5PLibraryDetails.$pagerInfo.css({display: 'inline-block'}); + + // Check if input value is valid, and that it has actually changed + if (!(intRegex.test(newPageNum) && newPageNum >= 0 && newPageNum < H5PLibraryDetails.getNumPages() && newPageNum != H5PLibraryDetails.currentPage)) { + return; + } + + H5PLibraryDetails.currentPage = newPageNum; + H5PLibraryDetails.updatePager(); + H5PLibraryDetails.createContentTable(); + }; + + // We create an input box where the user may type in the page number + // he wants to be displayed. + // Reson for doing this is when user has ten-thousands of elements in list, + // this is the easiest way of getting to a specified page + var $gotoInput = $('', { + type: 'number', + min : 1, + max: H5PLibraryDetails.getNumPages(), + on: { + // Listen to blur, and the enter-key: + 'blur': pageNumerUpdated, + 'keyup': function (event) { + if (event.keyCode === 13) { + pageNumerUpdated(); + } + } + } + }).css({width: width}); + var $goto = $('', { + 'class': 'h5p-pager-goto' + }).css({width: width}).append($gotoInput).insertAfter(H5PLibraryDetails.$pagerInfo); + + $gotoInput.focus(); + }); + + H5PLibraryDetails.updatePager(); + }; + + /** + * Calculates number of pages + */ + H5PLibraryDetails.getNumPages = function () { + return Math.ceil(H5PLibraryDetails.currentContent.length / H5PLibraryDetails.PAGER_SIZE); + }; + + /** + * Update the pager text, and enables/disables the next and previous buttons as needed + */ + H5PLibraryDetails.updatePager = function () { + H5PLibraryDetails.$pagerInfo.css({display: 'inline-block'}); + + if (H5PLibraryDetails.getNumPages() > 0) { + var message = H5PUtils.translateReplace(H5PLibraryDetails.library.translations.pageXOfY, { + '$x': (H5PLibraryDetails.currentPage+1), + '$y': H5PLibraryDetails.getNumPages() + }); + H5PLibraryDetails.$pagerInfo.html(message); + } + else { + H5PLibraryDetails.$pagerInfo.html(''); + } + + H5PLibraryDetails.$previous.toggleClass('disabled', H5PLibraryDetails.currentPage <= 0); + H5PLibraryDetails.$next.toggleClass('disabled', H5PLibraryDetails.currentContent.length < (H5PLibraryDetails.currentPage+1)*H5PLibraryDetails.PAGER_SIZE); + }; + + /** + * Creates the search element + */ + H5PLibraryDetails.createSearchElement = function () { + + H5PLibraryDetails.$search = $(''); + + var performSeach = function () { + var searchString = $('.h5p-content-search > input').val(); + + // If search string same as previous, just do nothing + if (H5PLibraryDetails.currentFilter === searchString) { + return; + } + + if (searchString.trim().length === 0) { + // If empty search, use the complete list + H5PLibraryDetails.currentContent = H5PLibraryDetails.library.content; + } + else if (H5PLibraryDetails.filterCache[searchString]) { + // If search is cached, no need to filter + H5PLibraryDetails.currentContent = H5PLibraryDetails.filterCache[searchString]; + } + else { + var listToFilter = H5PLibraryDetails.library.content; + + // Check if we can filter the already filtered results (for performance) + if (searchString.length > 1 && H5PLibraryDetails.currentFilter === searchString.substr(0, H5PLibraryDetails.currentFilter.length)) { + listToFilter = H5PLibraryDetails.currentContent; + } + H5PLibraryDetails.currentContent = $.grep(listToFilter, function (content) { + return content.title && content.title.match(new RegExp(searchString, 'i')); + }); + } + + H5PLibraryDetails.currentFilter = searchString; + // Cache the current result + H5PLibraryDetails.filterCache[searchString] = H5PLibraryDetails.currentContent; + H5PLibraryDetails.currentPage = 0; + H5PLibraryDetails.createContentTable(); + + // Display search results: + if (H5PLibraryDetails.$searchResults) { + H5PLibraryDetails.$searchResults.remove(); + } + if (searchString.trim().length > 0) { + H5PLibraryDetails.$searchResults = $('' + H5PLibraryDetails.currentContent.length + ' hits on ' + H5PLibraryDetails.currentFilter + ''); + H5PLibraryDetails.$search.append(H5PLibraryDetails.$searchResults); + } + H5PLibraryDetails.updatePager(); + }; + + var inputTimer; + $('input', H5PLibraryDetails.$search).on('change keypress paste input', function () { + // Here we start the filtering + // We wait at least 500 ms after last input to perform search + if (inputTimer) { + clearTimeout(inputTimer); + } + + inputTimer = setTimeout( function () { + performSeach(); + }, 500); + }); + + H5PLibraryDetails.$content.append(H5PLibraryDetails.$search); + }; + + /** + * Creates the page size selector + */ + H5PLibraryDetails.createPageSizeSelector = function () { + H5PLibraryDetails.$search.append('
      ' + H5PLibraryDetails.library.translations.pageSizeSelectorLabel + ':102050100200
      '); + + // Listen to clicks on the page size selector: + $('.h5p-admin-pager-size-selector > span', H5PLibraryDetails.$search).on('click', function () { + H5PLibraryDetails.PAGER_SIZE = $(this).data('page-size'); + $('.h5p-admin-pager-size-selector > span', H5PLibraryDetails.$search).removeClass('selected'); + $(this).addClass('selected'); + H5PLibraryDetails.currentPage = 0; + H5PLibraryDetails.createContentTable(); + H5PLibraryDetails.updatePager(); + }); + }; + + // Initialize me: + $(document).ready(function () { + if (!H5PLibraryDetails.initialized) { + H5PLibraryDetails.initialized = true; + H5PLibraryDetails.init(); + } + }); + +})(H5P.jQuery); diff --git a/src/core/h5p/assets/js/h5p-library-list.js b/src/core/h5p/assets/js/h5p-library-list.js new file mode 100644 index 000000000..344b73672 --- /dev/null +++ b/src/core/h5p/assets/js/h5p-library-list.js @@ -0,0 +1,140 @@ +/* global H5PAdminIntegration H5PUtils */ +var H5PLibraryList = H5PLibraryList || {}; + +(function ($) { + + /** + * Initializing + */ + H5PLibraryList.init = function () { + var $adminContainer = H5P.jQuery(H5PAdminIntegration.containerSelector).html(''); + + var libraryList = H5PAdminIntegration.libraryList; + if (libraryList.notCached) { + $adminContainer.append(H5PUtils.getRebuildCache(libraryList.notCached)); + } + + // Create library list + $adminContainer.append(H5PLibraryList.createLibraryList(H5PAdminIntegration.libraryList)); + }; + + /** + * Create the library list + * + * @param {object} libraries List of libraries and headers + */ + H5PLibraryList.createLibraryList = function (libraries) { + var t = H5PAdminIntegration.l10n; + if (libraries.listData === undefined || libraries.listData.length === 0) { + return $('
      ' + t.NA + '
      '); + } + + // Create table + var $table = H5PUtils.createTable(libraries.listHeaders); + $table.addClass('libraries'); + + // Add libraries + $.each (libraries.listData, function (index, library) { + var $libraryRow = H5PUtils.createTableRow([ + library.title, + '', + { + text: library.numContent, + class: 'h5p-admin-center' + }, + { + text: library.numContentDependencies, + class: 'h5p-admin-center' + }, + { + text: library.numLibraryDependencies, + class: 'h5p-admin-center' + }, + '
      ' + + '' + + (library.detailsUrl ? '' : '') + + (library.deleteUrl ? '' : '') + + '
      ' + ]); + + H5PLibraryList.addRestricted($('.h5p-admin-restricted', $libraryRow), library.restrictedUrl, library.restricted); + + var hasContent = !(library.numContent === '' || library.numContent === 0); + if (library.upgradeUrl === null) { + $('.h5p-admin-upgrade-library', $libraryRow).remove(); + } + else if (library.upgradeUrl === false || !hasContent) { + $('.h5p-admin-upgrade-library', $libraryRow).attr('disabled', true); + } + else { + $('.h5p-admin-upgrade-library', $libraryRow).attr('title', t.upgradeLibrary).click(function () { + window.location.href = library.upgradeUrl; + }); + } + + // Open details view when clicked + $('.h5p-admin-view-library', $libraryRow).on('click', function () { + window.location.href = library.detailsUrl; + }); + + var $deleteButton = $('.h5p-admin-delete-library', $libraryRow); + if (libraries.notCached !== undefined || + hasContent || + (library.numContentDependencies !== '' && + library.numContentDependencies !== 0) || + (library.numLibraryDependencies !== '' && + library.numLibraryDependencies !== 0)) { + // Disabled delete if content. + $deleteButton.attr('disabled', true); + } + else { + // Go to delete page om click. + $deleteButton.attr('title', t.deleteLibrary).on('click', function () { + window.location.href = library.deleteUrl; + }); + } + + $table.append($libraryRow); + }); + + return $table; + }; + + H5PLibraryList.addRestricted = function ($checkbox, url, selected) { + if (selected === null) { + $checkbox.remove(); + } + else { + $checkbox.change(function () { + $checkbox.attr('disabled', true); + + $.ajax({ + dataType: 'json', + url: url, + cache: false + }).fail(function () { + $checkbox.attr('disabled', false); + + // Reset + $checkbox.attr('checked', !$checkbox.is(':checked')); + }).done(function (result) { + url = result.url; + $checkbox.attr('disabled', false); + }); + }); + + if (selected) { + $checkbox.attr('checked', true); + } + } + }; + + // Initialize me: + $(document).ready(function () { + if (!H5PLibraryList.initialized) { + H5PLibraryList.initialized = true; + H5PLibraryList.init(); + } + }); + +})(H5P.jQuery); diff --git a/src/core/h5p/assets/js/h5p-resizer.js b/src/core/h5p/assets/js/h5p-resizer.js new file mode 100644 index 000000000..ed78724ec --- /dev/null +++ b/src/core/h5p/assets/js/h5p-resizer.js @@ -0,0 +1,131 @@ +// H5P iframe Resizer +(function () { + if (!window.postMessage || !window.addEventListener || window.h5pResizerInitialized) { + return; // Not supported + } + window.h5pResizerInitialized = true; + + // Map actions to handlers + var actionHandlers = {}; + + /** + * Prepare iframe resize. + * + * @private + * @param {Object} iframe Element + * @param {Object} data Payload + * @param {Function} respond Send a response to the iframe + */ + actionHandlers.hello = function (iframe, data, respond) { + // Make iframe responsive + iframe.style.width = '100%'; + + // Bugfix for Chrome: Force update of iframe width. If this is not done the + // document size may not be updated before the content resizes. + iframe.getBoundingClientRect(); + + // Tell iframe that it needs to resize when our window resizes + var resize = function () { + if (iframe.contentWindow) { + // Limit resize calls to avoid flickering + respond('resize'); + } + else { + // Frame is gone, unregister. + window.removeEventListener('resize', resize); + } + }; + window.addEventListener('resize', resize, false); + + // Respond to let the iframe know we can resize it + respond('hello'); + }; + + /** + * Prepare iframe resize. + * + * @private + * @param {Object} iframe Element + * @param {Object} data Payload + * @param {Function} respond Send a response to the iframe + */ + actionHandlers.prepareResize = function (iframe, data, respond) { + // Do not resize unless page and scrolling differs + if (iframe.clientHeight !== data.scrollHeight || + data.scrollHeight !== data.clientHeight) { + + // Reset iframe height, in case content has shrinked. + iframe.style.height = data.clientHeight + 'px'; + respond('resizePrepared'); + } + }; + + /** + * Resize parent and iframe to desired height. + * + * @private + * @param {Object} iframe Element + * @param {Object} data Payload + * @param {Function} respond Send a response to the iframe + */ + actionHandlers.resize = function (iframe, data) { + // Resize iframe so all content is visible. Use scrollHeight to make sure we get everything + iframe.style.height = data.scrollHeight + 'px'; + }; + + /** + * Keyup event handler. Exits full screen on escape. + * + * @param {Event} event + */ + var escape = function (event) { + if (event.keyCode === 27) { + exitFullScreen(); + } + }; + + // Listen for messages from iframes + window.addEventListener('message', function receiveMessage(event) { + if (event.data.context !== 'h5p') { + return; // Only handle h5p requests. + } + + // Find out who sent the message + var iframe, iframes = document.getElementsByTagName('iframe'); + for (var i = 0; i < iframes.length; i++) { + if (iframes[i].contentWindow === event.source) { + iframe = iframes[i]; + break; + } + } + + if (!iframe) { + return; // Cannot find sender + } + + // Find action handler handler + if (actionHandlers[event.data.action]) { + actionHandlers[event.data.action](iframe, event.data, function respond(action, data) { + if (data === undefined) { + data = {}; + } + data.action = action; + data.context = 'h5p'; + event.source.postMessage(data, event.origin); + }); + } + }, false); + + // Let h5p iframes know we're ready! + var iframes = document.getElementsByTagName('iframe'); + var ready = { + context: 'h5p', + action: 'ready' + }; + for (var i = 0; i < iframes.length; i++) { + if (iframes[i].src.indexOf('h5p') !== -1) { + iframes[i].contentWindow.postMessage(ready, '*'); + } + } + +})(); diff --git a/src/core/h5p/assets/js/h5p-utils.js b/src/core/h5p/assets/js/h5p-utils.js new file mode 100644 index 000000000..b5aa3334e --- /dev/null +++ b/src/core/h5p/assets/js/h5p-utils.js @@ -0,0 +1,506 @@ +/* global H5PAdminIntegration*/ +var H5PUtils = H5PUtils || {}; + +(function ($) { + /** + * Generic function for creating a table including the headers + * + * @param {array} headers List of headers + */ + H5PUtils.createTable = function (headers) { + var $table = $('
      '); + + if (headers) { + var $thead = $(''); + var $tr = $(''); + + $.each(headers, function (index, value) { + if (!(value instanceof Object)) { + value = { + html: value + }; + } + + $('', value).appendTo($tr); + }); + + $table.append($thead.append($tr)); + } + + return $table; + }; + + /** + * Generic function for creating a table row + * + * @param {array} rows Value list. Object name is used as class name in + */ + H5PUtils.createTableRow = function (rows) { + var $tr = $(''); + + $.each(rows, function (index, value) { + if (!(value instanceof Object)) { + value = { + html: value + }; + } + + $('', value).appendTo($tr); + }); + + return $tr; + }; + + /** + * Generic function for creating a field containing label and value + * + * @param {string} label The label displayed in front of the value + * @param {string} value The value + */ + H5PUtils.createLabeledField = function (label, value) { + var $field = $('
      '); + + $field.append('
      ' + label + '
      '); + $field.append('
      ' + value + '
      '); + + return $field; + }; + + /** + * Replaces placeholder fields in translation strings + * + * @param {string} template The translation template string in the following format: "$name is a $sex" + * @param {array} replacors An js object with key and values. Eg: {'$name': 'Frode', '$sex': 'male'} + */ + H5PUtils.translateReplace = function (template, replacors) { + $.each(replacors, function (key, value) { + template = template.replace(new RegExp('\\'+key, 'g'), value); + }); + return template; + }; + + /** + * Get throbber with given text. + * + * @param {String} text + * @returns {$} + */ + H5PUtils.throbber = function (text) { + return $('
      ', { + class: 'h5p-throbber', + text: text + }); + }; + + /** + * Makes it possbile to rebuild all content caches from admin UI. + * @param {Object} notCached + * @returns {$} + */ + H5PUtils.getRebuildCache = function (notCached) { + var $container = $('

      ' + notCached.message + '

      ' + notCached.progress + '

      '); + var $button = $('').appendTo($container).click(function () { + var $spinner = $('
      ', {class: 'h5p-spinner'}).replaceAll($button); + var parts = ['|', '/', '-', '\\']; + var current = 0; + var spinning = setInterval(function () { + $spinner.text(parts[current]); + current++; + if (current === parts.length) current = 0; + }, 100); + + var $counter = $container.find('.progress'); + var build = function () { + $.post(notCached.url, function (left) { + if (left === '0') { + clearInterval(spinning); + $container.remove(); + location.reload(); + } + else { + var counter = $counter.text().split(' '); + counter[0] = left; + $counter.text(counter.join(' ')); + build(); + } + }); + }; + build(); + }); + + return $container; + }; + + /** + * Generic table class with useful helpers. + * + * @class + * @param {Object} classes + * Custom html classes to use on elements. + * e.g. {tableClass: 'fixed'}. + */ + H5PUtils.Table = function (classes) { + var numCols; + var sortByCol; + var $sortCol; + var sortCol; + var sortDir; + + // Create basic table + var tableOptions = {}; + if (classes.table !== undefined) { + tableOptions['class'] = classes.table; + } + var $table = $('', tableOptions); + var $thead = $('').appendTo($table); + var $tfoot = $('').appendTo($table); + var $tbody = $('').appendTo($table); + + /** + * Add columns to given table row. + * + * @private + * @param {jQuery} $tr Table row + * @param {(String|Object)} col Column properties + * @param {Number} id Used to seperate the columns + */ + var addCol = function ($tr, col, id) { + var options = { + on: {} + }; + + if (!(col instanceof Object)) { + options.text = col; + } + else { + if (col.text !== undefined) { + options.text = col.text; + } + if (col.class !== undefined) { + options.class = col.class; + } + + if (sortByCol !== undefined && col.sortable === true) { + // Make sortable + options.role = 'button'; + options.tabIndex = 0; + + // This is the first sortable column, use as default sort + if (sortCol === undefined) { + sortCol = id; + sortDir = 0; + } + + // This is the sort column + if (sortCol === id) { + options['class'] = 'h5p-sort'; + if (sortDir === 1) { + options['class'] += ' h5p-reverse'; + } + } + + options.on.click = function () { + sort($th, id); + }; + options.on.keypress = function (event) { + if ((event.charCode || event.keyCode) === 32) { // Space + sort($th, id); + } + }; + } + } + + // Append + var $th = $(''); + var $tr = $('').appendTo($newThead); + for (var i = 0; i < cols.length; i++) { + addCol($tr, cols[i], i); + } + + // Update DOM + $thead.replaceWith($newThead); + $thead = $newThead; + }; + + /** + * Set table rows. + * + * @public + * @param {Array} rows Table rows with cols: [[1,'hello',3],[2,'asd',6]] + */ + this.setRows = function (rows) { + var $newTbody = $(''); + + for (var i = 0; i < rows.length; i++) { + var $tr = $('').appendTo($newTbody); + + for (var j = 0; j < rows[i].length; j++) { + $(''); + var $tr = $('').appendTo($newTbody); + $(''); + var $tr = $('').appendTo($newTfoot); + $('\s*$/g,At={option:[1,""],legend:[1,"
      ","
      "],area:[1,"",""],param:[1,"",""],thead:[1,"
      ', options).appendTo($tr); + if (sortCol === id) { + $sortCol = $th; // Default sort column + } + }; + + /** + * Updates the UI when a column header has been clicked. + * Triggers sorting callback. + * + * @private + * @param {jQuery} $th Table header + * @param {Number} id Used to seperate the columns + */ + var sort = function ($th, id) { + if (id === sortCol) { + // Change sorting direction + if (sortDir === 0) { + sortDir = 1; + $th.addClass('h5p-reverse'); + } + else { + sortDir = 0; + $th.removeClass('h5p-reverse'); + } + } + else { + // Change sorting column + $sortCol.removeClass('h5p-sort').removeClass('h5p-reverse'); + $sortCol = $th.addClass('h5p-sort'); + sortCol = id; + sortDir = 0; + } + + sortByCol({ + by: sortCol, + dir: sortDir + }); + }; + + /** + * Set table headers. + * + * @public + * @param {Array} cols + * Table header data. Can be strings or objects with options like + * "text" and "sortable". E.g. + * [{text: 'Col 1', sortable: true}, 'Col 2', 'Col 3'] + * @param {Function} sort Callback which is runned when sorting changes + * @param {Object} [order] + */ + this.setHeaders = function (cols, sort, order) { + numCols = cols.length; + sortByCol = sort; + + if (order) { + sortCol = order.by; + sortDir = order.dir; + } + + // Create new head + var $newThead = $('
      ', { + html: rows[i][j] + }).appendTo($tr); + } + } + + $tbody.replaceWith($newTbody); + $tbody = $newTbody; + + return $tbody; + }; + + /** + * Set custom table body content. This can be a message or a throbber. + * Will cover all table columns. + * + * @public + * @param {jQuery} $content Custom content + */ + this.setBody = function ($content) { + var $newTbody = $('
      ', { + colspan: numCols + }).append($content).appendTo($tr); + $tbody.replaceWith($newTbody); + $tbody = $newTbody; + }; + + /** + * Set custom table foot content. This can be a pagination widget. + * Will cover all table columns. + * + * @public + * @param {jQuery} $content Custom content + */ + this.setFoot = function ($content) { + var $newTfoot = $('
      ', { + colspan: numCols + }).append($content).appendTo($tr); + $tfoot.replaceWith($newTfoot); + }; + + + /** + * Appends the table to the given container. + * + * @public + * @param {jQuery} $container + */ + this.appendTo = function ($container) { + $table.appendTo($container); + }; + }; + + /** + * Generic pagination class. Creates a useful pagination widget. + * + * @class + * @param {Number} num Total number of items to pagiate. + * @param {Number} limit Number of items to dispaly per page. + * @param {Function} goneTo + * Callback which is fired when the user wants to go to another page. + * @param {Object} l10n + * Localization / translations. e.g. + * { + * currentPage: 'Page $current of $total', + * nextPage: 'Next page', + * previousPage: 'Previous page' + * } + */ + H5PUtils.Pagination = function (num, limit, goneTo, l10n) { + var current = 0; + var pages = Math.ceil(num / limit); + + // Create components + + // Previous button + var $left = $(''; + } + if (contentData.displayOptions.export && contentData.displayOptions.copy) { + html += '
      or
      '; + } + if (contentData.displayOptions.copy) { + html += ''; + } + + const dialog = new H5P.Dialog('reuse', H5P.t('reuseContent'), html, $element); + + // Selecting embed code when dialog is opened + H5P.jQuery(dialog).on('dialog-opened', function (e, $dialog) { + H5P.jQuery('More Info').click(function (e) { + e.stopPropagation(); + }).appendTo($dialog.find('h2')); + $dialog.find('.h5p-download-button').click(function () { + window.location.href = contentData.exportUrl; + instance.triggerXAPI('downloaded'); + dialog.close(); + }); + $dialog.find('.h5p-copy-button').click(function () { + const item = new H5P.ClipboardItem(library); + item.contentId = contentId; + H5P.setClipboard(item); + instance.triggerXAPI('copied'); + dialog.close(); + H5P.attachToastTo( + H5P.jQuery('.h5p-content:first')[0], + H5P.t('contentCopied'), + { + position: { + horizontal: 'centered', + vertical: 'centered', + noOverflowX: true + } + } + ); + }); + H5P.trigger(instance, 'resize'); + }).on('dialog-closed', function () { + H5P.trigger(instance, 'resize'); + }); + + dialog.open(); +}; + +/** + * Display a dialog containing the embed code. + * + * @param {H5P.jQuery} $element + * Element to insert dialog after. + * @param {string} embedCode + * The embed code. + * @param {string} resizeCode + * The advanced resize code + * @param {Object} size + * The content's size. + * @param {number} size.width + * @param {number} size.height + */ +H5P.openEmbedDialog = function ($element, embedCode, resizeCode, size, instance) { + var fullEmbedCode = embedCode + resizeCode; + var dialog = new H5P.Dialog('embed', H5P.t('embed'), '' + H5P.t('size') + ': × px
      ' + H5P.t('showAdvanced') + '

      ' + H5P.t('advancedHelp') + '

      ', $element); + + // Selecting embed code when dialog is opened + H5P.jQuery(dialog).on('dialog-opened', function (event, $dialog) { + var $inner = $dialog.find('.h5p-inner'); + var $scroll = $inner.find('.h5p-scroll-content'); + var diff = $scroll.outerHeight() - $scroll.innerHeight(); + var positionInner = function () { + H5P.trigger(instance, 'resize'); + }; + + // Handle changing of width/height + var $w = $dialog.find('.h5p-embed-size:eq(0)'); + var $h = $dialog.find('.h5p-embed-size:eq(1)'); + var getNum = function ($e, d) { + var num = parseFloat($e.val()); + if (isNaN(num)) { + return d; + } + return Math.ceil(num); + }; + var updateEmbed = function () { + $dialog.find('.h5p-embed-code-container:first').val(fullEmbedCode.replace(':w', getNum($w, size.width)).replace(':h', getNum($h, size.height))); + }; + + $w.change(updateEmbed); + $h.change(updateEmbed); + updateEmbed(); + + // Select text and expand textareas + $dialog.find('.h5p-embed-code-container').each(function () { + H5P.jQuery(this).css('height', this.scrollHeight + 'px').focus(function () { + H5P.jQuery(this).select(); + }); + }); + $dialog.find('.h5p-embed-code-container').eq(0).select(); + positionInner(); + + // Expand advanced embed + var expand = function () { + var $expander = H5P.jQuery(this); + var $content = $expander.next(); + if ($content.is(':visible')) { + $expander.removeClass('h5p-open').text(H5P.t('showAdvanced')); + $content.hide(); + } + else { + $expander.addClass('h5p-open').text(H5P.t('hideAdvanced')); + $content.show(); + } + $dialog.find('.h5p-embed-code-container').each(function () { + H5P.jQuery(this).css('height', this.scrollHeight + 'px'); + }); + positionInner(); + }; + $dialog.find('.h5p-expander').click(expand).keypress(function (event) { + if (event.keyCode === 32) { + expand.apply(this); + } + }); + }).on('dialog-closed', function () { + H5P.trigger(instance, 'resize'); + }); + + dialog.open(); +}; + +/** + * Show a toast message. + * + * The reference element could be dom elements the toast should be attached to, + * or e.g. the document body for general toast messages. + * + * @param {DOM} element Reference element to show toast message for. + * @param {string} message Message to show. + * @param {object} [config] Configuration. + * @param {string} [config.style=h5p-toast] Style name for the tooltip. + * @param {number} [config.duration=3000] Toast message length in ms. + * @param {object} [config.position] Relative positioning of the toast. + * @param {string} [config.position.horizontal=centered] [before|left|centered|right|after]. + * @param {string} [config.position.vertical=below] [above|top|centered|bottom|below]. + * @param {number} [config.position.offsetHorizontal=0] Extra horizontal offset. + * @param {number} [config.position.offsetVertical=0] Extra vetical offset. + * @param {boolean} [config.position.noOverflowLeft=false] True to prevent overflow left. + * @param {boolean} [config.position.noOverflowRight=false] True to prevent overflow right. + * @param {boolean} [config.position.noOverflowTop=false] True to prevent overflow top. + * @param {boolean} [config.position.noOverflowBottom=false] True to prevent overflow bottom. + * @param {boolean} [config.position.noOverflowX=false] True to prevent overflow left and right. + * @param {boolean} [config.position.noOverflowY=false] True to prevent overflow top and bottom. + * @param {object} [config.position.overflowReference=document.body] DOM reference for overflow. + */ +H5P.attachToastTo = function (element, message, config) { + if (element === undefined || message === undefined) { + return; + } + + const eventPath = function (evt) { + var path = (evt.composedPath && evt.composedPath()) || evt.path; + var target = evt.target; + + if (path != null) { + // Safari doesn't include Window, but it should. + return (path.indexOf(window) < 0) ? path.concat(window) : path; + } + + if (target === window) { + return [window]; + } + + function getParents(node, memo) { + memo = memo || []; + var parentNode = node.parentNode; + + if (!parentNode) { + return memo; + } + else { + return getParents(parentNode, memo.concat(parentNode)); + } + } + + return [target].concat(getParents(target), window); + }; + + /** + * Handle click while toast is showing. + */ + const clickHandler = function (event) { + /* + * A common use case will be to attach toasts to buttons that are clicked. + * The click would remove the toast message instantly without this check. + * Children of the clicked element are also ignored. + */ + var path = eventPath(event); + if (path.indexOf(element) !== -1) { + return; + } + clearTimeout(timer); + removeToast(); + }; + + + + /** + * Remove the toast message. + */ + const removeToast = function () { + document.removeEventListener('click', clickHandler); + if (toast.parentNode) { + toast.parentNode.removeChild(toast); + } + }; + + /** + * Get absolute coordinates for the toast. + * + * @param {DOM} element Reference element to show toast message for. + * @param {DOM} toast Toast element. + * @param {object} [position={}] Relative positioning of the toast message. + * @param {string} [position.horizontal=centered] [before|left|centered|right|after]. + * @param {string} [position.vertical=below] [above|top|centered|bottom|below]. + * @param {number} [position.offsetHorizontal=0] Extra horizontal offset. + * @param {number} [position.offsetVertical=0] Extra vetical offset. + * @param {boolean} [position.noOverflowLeft=false] True to prevent overflow left. + * @param {boolean} [position.noOverflowRight=false] True to prevent overflow right. + * @param {boolean} [position.noOverflowTop=false] True to prevent overflow top. + * @param {boolean} [position.noOverflowBottom=false] True to prevent overflow bottom. + * @param {boolean} [position.noOverflowX=false] True to prevent overflow left and right. + * @param {boolean} [position.noOverflowY=false] True to prevent overflow top and bottom. + * @return {object} + */ + const getToastCoordinates = function (element, toast, position) { + position = position || {}; + position.offsetHorizontal = position.offsetHorizontal || 0; + position.offsetVertical = position.offsetVertical || 0; + + const toastRect = toast.getBoundingClientRect(); + const elementRect = element.getBoundingClientRect(); + + let left = 0; + let top = 0; + + // Compute horizontal position + switch (position.horizontal) { + case 'before': + left = elementRect.left - toastRect.width - position.offsetHorizontal; + break; + case 'after': + left = elementRect.left + elementRect.width + position.offsetHorizontal; + break; + case 'left': + left = elementRect.left + position.offsetHorizontal; + break; + case 'right': + left = elementRect.left + elementRect.width - toastRect.width - position.offsetHorizontal; + break; + case 'centered': + left = elementRect.left + elementRect.width / 2 - toastRect.width / 2 + position.offsetHorizontal; + break; + default: + left = elementRect.left + elementRect.width / 2 - toastRect.width / 2 + position.offsetHorizontal; + } + + // Compute vertical position + switch (position.vertical) { + case 'above': + top = elementRect.top - toastRect.height - position.offsetVertical; + break; + case 'below': + top = elementRect.top + elementRect.height + position.offsetVertical; + break; + case 'top': + top = elementRect.top + position.offsetVertical; + break; + case 'bottom': + top = elementRect.top + elementRect.height - toastRect.height - position.offsetVertical; + break; + case 'centered': + top = elementRect.top + elementRect.height / 2 - toastRect.height / 2 + position.offsetVertical; + break; + default: + top = elementRect.top + elementRect.height + position.offsetVertical; + } + + // Prevent overflow + const overflowElement = document.body; + const bounds = overflowElement.getBoundingClientRect(); + if ((position.noOverflowLeft || position.noOverflowX) && (left < bounds.x)) { + left = bounds.x; + } + if ((position.noOverflowRight || position.noOverflowX) && ((left + toastRect.width) > (bounds.x + bounds.width))) { + left = bounds.x + bounds.width - toastRect.width; + } + if ((position.noOverflowTop || position.noOverflowY) && (top < bounds.y)) { + top = bounds.y; + } + if ((position.noOverflowBottom || position.noOverflowY) && ((top + toastRect.height) > (bounds.y + bounds.height))) { + left = bounds.y + bounds.height - toastRect.height; + } + + return {left: left, top: top}; + }; + + // Sanitization + config = config || {}; + config.style = config.style || 'h5p-toast'; + config.duration = config.duration || 3000; + + // Build toast + const toast = document.createElement('div'); + toast.setAttribute('id', config.style); + toast.classList.add('h5p-toast-disabled'); + toast.classList.add(config.style); + + const msg = document.createElement('span'); + msg.innerHTML = message; + toast.appendChild(msg); + + document.body.appendChild(toast); + + // The message has to be set before getting the coordinates + const coordinates = getToastCoordinates(element, toast, config.position); + toast.style.left = Math.round(coordinates.left) + 'px'; + toast.style.top = Math.round(coordinates.top) + 'px'; + + toast.classList.remove('h5p-toast-disabled'); + const timer = setTimeout(removeToast, config.duration); + + // The toast can also be removed by clicking somewhere + document.addEventListener('click', clickHandler); +}; + +/** + * Copyrights for a H5P Content Library. + * + * @class + */ +H5P.ContentCopyrights = function () { + var label; + var media = []; + var content = []; + + /** + * Set label. + * + * @param {string} newLabel + */ + this.setLabel = function (newLabel) { + label = newLabel; + }; + + /** + * Add sub content. + * + * @param {H5P.MediaCopyright} newMedia + */ + this.addMedia = function (newMedia) { + if (newMedia !== undefined) { + media.push(newMedia); + } + }; + + /** + * Add sub content in front. + * + * @param {H5P.MediaCopyright} newMedia + */ + this.addMediaInFront = function (newMedia) { + if (newMedia !== undefined) { + media.unshift(newMedia); + } + }; + + /** + * Add sub content. + * + * @param {H5P.ContentCopyrights} newContent + */ + this.addContent = function (newContent) { + if (newContent !== undefined) { + content.push(newContent); + } + }; + + /** + * Print content copyright. + * + * @returns {string} HTML. + */ + this.toString = function () { + var html = ''; + + // Add media rights + for (var i = 0; i < media.length; i++) { + html += media[i]; + } + + // Add sub content rights + for (i = 0; i < content.length; i++) { + html += content[i]; + } + + + if (html !== '') { + // Add a label to this info + if (label !== undefined) { + html = '

      ' + label + '

      ' + html; + } + + // Add wrapper + html = '
      ' + html + '
      '; + } + + return html; + }; +}; + +/** + * A ordered list of copyright fields for media. + * + * @class + * @param {Object} copyright + * Copyright information fields. + * @param {Object} [labels] + * Translation of labels. + * @param {Array} [order] + * Order of the fields. + * @param {Object} [extraFields] + * Add extra copyright fields. + */ +H5P.MediaCopyright = function (copyright, labels, order, extraFields) { + var thumbnail; + var list = new H5P.DefinitionList(); + + /** + * Get translated label for field. + * + * @private + * @param {string} fieldName + * @returns {string} + */ + var getLabel = function (fieldName) { + if (labels === undefined || labels[fieldName] === undefined) { + return H5P.t(fieldName); + } + + return labels[fieldName]; + }; + + /** + * Get humanized value for the license field. + * + * @private + * @param {string} license + * @param {string} [version] + * @returns {string} + */ + var humanizeLicense = function (license, version) { + var copyrightLicense = H5P.copyrightLicenses[license]; + + // Build license string + var value = ''; + if (!(license === 'PD' && version)) { + // Add license label + value += (copyrightLicense.hasOwnProperty('label') ? copyrightLicense.label : copyrightLicense); + } + + // Check for version info + var versionInfo; + if (copyrightLicense.versions) { + if (copyrightLicense.versions.default && (!version || !copyrightLicense.versions[version])) { + version = copyrightLicense.versions.default; + } + if (version && copyrightLicense.versions[version]) { + versionInfo = copyrightLicense.versions[version]; + } + } + + if (versionInfo) { + // Add license version + if (value) { + value += ' '; + } + value += (versionInfo.hasOwnProperty('label') ? versionInfo.label : versionInfo); + } + + // Add link if specified + var link; + if (copyrightLicense.hasOwnProperty('link')) { + link = copyrightLicense.link.replace(':version', copyrightLicense.linkVersions ? copyrightLicense.linkVersions[version] : version); + } + else if (versionInfo && copyrightLicense.hasOwnProperty('link')) { + link = versionInfo.link; + } + if (link) { + value = '' + value + ''; + } + + // Generate parenthesis + var parenthesis = ''; + if (license !== 'PD' && license !== 'C') { + parenthesis += license; + } + if (version && version !== 'CC0 1.0') { + if (parenthesis && license !== 'GNU GPL') { + parenthesis += ' '; + } + parenthesis += version; + } + if (parenthesis) { + value += ' (' + parenthesis + ')'; + } + if (license === 'C') { + value += ' ©'; + } + + return value; + }; + + if (copyright !== undefined) { + // Add the extra fields + for (var field in extraFields) { + if (extraFields.hasOwnProperty(field)) { + copyright[field] = extraFields[field]; + } + } + + if (order === undefined) { + // Set default order + order = ['contentType', 'title', 'license', 'author', 'year', 'source', 'licenseExtras', 'changes']; + } + + for (var i = 0; i < order.length; i++) { + var fieldName = order[i]; + if (copyright[fieldName] !== undefined && copyright[fieldName] !== '') { + var humanValue = copyright[fieldName]; + if (fieldName === 'license') { + humanValue = humanizeLicense(copyright.license, copyright.version); + } + if (fieldName === 'source') { + humanValue = (humanValue) ? '' + humanValue + '' : undefined; + } + list.add(new H5P.Field(getLabel(fieldName), humanValue)); + } + } + } + + /** + * Set thumbnail. + * + * @param {H5P.Thumbnail} newThumbnail + */ + this.setThumbnail = function (newThumbnail) { + thumbnail = newThumbnail; + }; + + /** + * Checks if this copyright is undisclosed. + * I.e. only has the license attribute set, and it's undisclosed. + * + * @returns {boolean} + */ + this.undisclosed = function () { + if (list.size() === 1) { + var field = list.get(0); + if (field.getLabel() === getLabel('license') && field.getValue() === humanizeLicense('U')) { + return true; + } + } + return false; + }; + + /** + * Print media copyright. + * + * @returns {string} HTML. + */ + this.toString = function () { + var html = ''; + + if (this.undisclosed()) { + return html; // No need to print a copyright with a single undisclosed license. + } + + if (thumbnail !== undefined) { + html += thumbnail; + } + html += list; + + if (html !== '') { + html = ''; + } + + return html; + }; +}; + +/** + * A simple and elegant class for creating thumbnails of images. + * + * @class + * @param {string} source + * @param {number} width + * @param {number} height + */ +H5P.Thumbnail = function (source, width, height) { + var thumbWidth, thumbHeight = 100; + if (width !== undefined) { + thumbWidth = Math.round(thumbHeight * (width / height)); + } + + /** + * Print thumbnail. + * + * @returns {string} HTML. + */ + this.toString = function () { + return '' + H5P.t('thumbnail') + ''; + }; +}; + +/** + * Simple data structure class for storing a single field. + * + * @class + * @param {string} label + * @param {string} value + */ +H5P.Field = function (label, value) { + /** + * Public. Get field label. + * + * @returns {String} + */ + this.getLabel = function () { + return label; + }; + + /** + * Public. Get field value. + * + * @returns {String} + */ + this.getValue = function () { + return value; + }; +}; + +/** + * Simple class for creating a definition list. + * + * @class + */ +H5P.DefinitionList = function () { + var fields = []; + + /** + * Add field to list. + * + * @param {H5P.Field} field + */ + this.add = function (field) { + fields.push(field); + }; + + /** + * Get Number of fields. + * + * @returns {number} + */ + this.size = function () { + return fields.length; + }; + + /** + * Get field at given index. + * + * @param {number} index + * @returns {H5P.Field} + */ + this.get = function (index) { + return fields[index]; + }; + + /** + * Print definition list. + * + * @returns {string} HTML. + */ + this.toString = function () { + var html = ''; + for (var i = 0; i < fields.length; i++) { + var field = fields[i]; + html += '
      ' + field.getLabel() + '
      ' + field.getValue() + '
      '; + } + return (html === '' ? html : '
      ' + html + '
      '); + }; +}; + +/** + * THIS FUNCTION/CLASS IS DEPRECATED AND WILL BE REMOVED. + * + * Helper object for keeping coordinates in the same format all over. + * + * @deprecated + * Will be removed march 2016. + * @class + * @param {number} x + * @param {number} y + * @param {number} w + * @param {number} h + */ +H5P.Coords = function (x, y, w, h) { + if ( !(this instanceof H5P.Coords) ) + return new H5P.Coords(x, y, w, h); + + /** @member {number} */ + this.x = 0; + /** @member {number} */ + this.y = 0; + /** @member {number} */ + this.w = 1; + /** @member {number} */ + this.h = 1; + + if (typeof(x) === 'object') { + this.x = x.x; + this.y = x.y; + this.w = x.w; + this.h = x.h; + } + else { + if (x !== undefined) { + this.x = x; + } + if (y !== undefined) { + this.y = y; + } + if (w !== undefined) { + this.w = w; + } + if (h !== undefined) { + this.h = h; + } + } + return this; +}; + +/** + * Parse library string into values. + * + * @param {string} library + * library in the format "machineName majorVersion.minorVersion" + * @returns {Object} + * library as an object with machineName, majorVersion and minorVersion properties + * return false if the library parameter is invalid + */ +H5P.libraryFromString = function (library) { + var regExp = /(.+)\s(\d+)\.(\d+)$/g; + var res = regExp.exec(library); + if (res !== null) { + return { + 'machineName': res[1], + 'majorVersion': parseInt(res[2]), + 'minorVersion': parseInt(res[3]) + }; + } + else { + return false; + } +}; + +/** + * Get the path to the library + * + * @param {string} library + * The library identifier in the format "machineName-majorVersion.minorVersion". + * @returns {string} + * The full path to the library. + */ +H5P.getLibraryPath = function (library) { + if (H5PIntegration.urlLibraries !== undefined) { + // This is an override for those implementations that has a different libraries URL, e.g. Moodle + return H5PIntegration.urlLibraries + '/' + library; + } + else { + return H5PIntegration.url + '/libraries/' + library; + } +}; + +/** + * Recursivly clone the given object. + * + * @param {Object|Array} object + * Object to clone. + * @param {boolean} [recursive] + * @returns {Object|Array} + * A clone of object. + */ +H5P.cloneObject = function (object, recursive) { + // TODO: Consider if this needs to be in core. Doesn't $.extend do the same? + var clone = object instanceof Array ? [] : {}; + + for (var i in object) { + if (object.hasOwnProperty(i)) { + if (recursive !== undefined && recursive && typeof object[i] === 'object') { + clone[i] = H5P.cloneObject(object[i], recursive); + } + else { + clone[i] = object[i]; + } + } + } + + return clone; +}; + +/** + * Remove all empty spaces before and after the value. + * + * @param {string} value + * @returns {string} + */ +H5P.trim = function (value) { + return value.replace(/^\s+|\s+$/g, ''); + + // TODO: Only include this or String.trim(). What is best? + // I'm leaning towards implementing the missing ones: http://kangax.github.io/compat-table/es5/ + // So should we make this function deprecated? +}; + +/** + * Check if JavaScript path/key is loaded. + * + * @param {string} path + * @returns {boolean} + */ +H5P.jsLoaded = function (path) { + H5PIntegration.loadedJs = H5PIntegration.loadedJs || []; + return H5P.jQuery.inArray(path, H5PIntegration.loadedJs) !== -1; +}; + +/** + * Check if styles path/key is loaded. + * + * @param {string} path + * @returns {boolean} + */ +H5P.cssLoaded = function (path) { + H5PIntegration.loadedCss = H5PIntegration.loadedCss || []; + return H5P.jQuery.inArray(path, H5PIntegration.loadedCss) !== -1; +}; + +/** + * Shuffle an array in place. + * + * @param {Array} array + * Array to shuffle + * @returns {Array} + * The passed array is returned for chaining. + */ +H5P.shuffleArray = function (array) { + // TODO: Consider if this should be a part of core. I'm guessing very few libraries are going to use it. + if (!(array instanceof Array)) { + return; + } + + var i = array.length, j, tempi, tempj; + if ( i === 0 ) return false; + while ( --i ) { + j = Math.floor( Math.random() * ( i + 1 ) ); + tempi = array[i]; + tempj = array[j]; + array[i] = tempj; + array[j] = tempi; + } + return array; +}; + +/** + * Post finished results for user. + * + * @deprecated + * Do not use this function directly, trigger the finish event instead. + * Will be removed march 2016 + * @param {number} contentId + * Identifies the content + * @param {number} score + * Achieved score/points + * @param {number} maxScore + * The maximum score/points that can be achieved + * @param {number} [time] + * Reported time consumption/usage + */ +H5P.setFinished = function (contentId, score, maxScore, time) { + var validScore = typeof score === 'number' || score instanceof Number; + if (validScore && H5PIntegration.postUserStatistics === true) { + /** + * Return unix timestamp for the given JS Date. + * + * @private + * @param {Date} date + * @returns {Number} + */ + var toUnix = function (date) { + return Math.round(date.getTime() / 1000); + }; + + // Post the results + const data = { + contentId: contentId, + score: score, + maxScore: maxScore, + opened: toUnix(H5P.opened[contentId]), + finished: toUnix(new Date()), + time: time + }; + H5P.jQuery.post(H5PIntegration.ajax.setFinished, data) + .fail(function () { + H5P.offlineRequestQueue.add(H5PIntegration.ajax.setFinished, data); + }); + } +}; + +// Add indexOf to browsers that lack them. (IEs) +if (!Array.prototype.indexOf) { + Array.prototype.indexOf = function (needle) { + for (var i = 0; i < this.length; i++) { + if (this[i] === needle) { + return i; + } + } + return -1; + }; +} + +// Need to define trim() since this is not available on older IEs, +// and trim is used in several libs +if (String.prototype.trim === undefined) { + String.prototype.trim = function () { + return H5P.trim(this); + }; +} + +/** + * Trigger an event on an instance + * + * Helper function that triggers an event if the instance supports event handling + * + * @param {Object} instance + * Instance of H5P content + * @param {string} eventType + * Type of event to trigger + * @param {*} data + * @param {Object} extras + */ +H5P.trigger = function (instance, eventType, data, extras) { + // Try new event system first + if (instance.trigger !== undefined) { + instance.trigger(eventType, data, extras); + } + // Try deprecated event system + else if (instance.$ !== undefined && instance.$.trigger !== undefined) { + instance.$.trigger(eventType); + } +}; + +/** + * Register an event handler + * + * Helper function that registers an event handler for an event type if + * the instance supports event handling + * + * @param {Object} instance + * Instance of H5P content + * @param {string} eventType + * Type of event to listen for + * @param {H5P.EventCallback} handler + * Callback that gets triggered for events of the specified type + */ +H5P.on = function (instance, eventType, handler) { + // Try new event system first + if (instance.on !== undefined) { + instance.on(eventType, handler); + } + // Try deprecated event system + else if (instance.$ !== undefined && instance.$.on !== undefined) { + instance.$.on(eventType, handler); + } +}; + +/** + * Generate random UUID + * + * @returns {string} UUID + */ +H5P.createUUID = function () { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (char) { + var random = Math.random()*16|0, newChar = char === 'x' ? random : (random&0x3|0x8); + return newChar.toString(16); + }); +}; + +/** + * Create title + * + * @param {string} rawTitle + * @param {number} maxLength + * @returns {string} + */ +H5P.createTitle = function (rawTitle, maxLength) { + if (!rawTitle) { + return ''; + } + if (maxLength === undefined) { + maxLength = 60; + } + var title = H5P.jQuery('
      ') + .text( + // Strip tags + rawTitle.replace(/(<([^>]+)>)/ig,"") + // Escape + ).text(); + if (title.length > maxLength) { + title = title.substr(0, maxLength - 3) + '...'; + } + return title; +}; + +// Wrap in privates +(function ($) { + + /** + * Creates ajax requests for inserting, updateing and deleteing + * content user data. + * + * @private + * @param {number} contentId What content to store the data for. + * @param {string} dataType Identifies the set of data for this content. + * @param {string} subContentId Identifies sub content + * @param {function} [done] Callback when ajax is done. + * @param {object} [data] To be stored for future use. + * @param {boolean} [preload=false] Data is loaded when content is loaded. + * @param {boolean} [invalidate=false] Data is invalidated when content changes. + * @param {boolean} [async=true] + */ + function contentUserDataAjax(contentId, dataType, subContentId, done, data, preload, invalidate, async) { + if (H5PIntegration.user === undefined) { + // Not logged in, no use in saving. + done('Not signed in.'); + return; + } + + var options = { + url: H5PIntegration.ajax.contentUserData.replace(':contentId', contentId).replace(':dataType', dataType).replace(':subContentId', subContentId ? subContentId : 0), + dataType: 'json', + async: async === undefined ? true : async + }; + if (data !== undefined) { + options.type = 'POST'; + options.data = { + data: (data === null ? 0 : data), + preload: (preload ? 1 : 0), + invalidate: (invalidate ? 1 : 0) + }; + } + else { + options.type = 'GET'; + } + if (done !== undefined) { + options.error = function (xhr, error) { + done(error); + }; + options.success = function (response) { + if (!response.success) { + done(response.message); + return; + } + + if (response.data === false || response.data === undefined) { + done(); + return; + } + + done(undefined, response.data); + }; + } + + $.ajax(options); + } + + /** + * Get user data for given content. + * + * @param {number} contentId + * What content to get data for. + * @param {string} dataId + * Identifies the set of data for this content. + * @param {function} done + * Callback with error and data parameters. + * @param {string} [subContentId] + * Identifies which data belongs to sub content. + */ + H5P.getUserData = function (contentId, dataId, done, subContentId) { + if (!subContentId) { + subContentId = 0; // Default + } + + H5PIntegration.contents = H5PIntegration.contents || {}; + var content = H5PIntegration.contents['cid-' + contentId] || {}; + var preloadedData = content.contentUserData; + if (preloadedData && preloadedData[subContentId] && preloadedData[subContentId][dataId] !== undefined) { + if (preloadedData[subContentId][dataId] === 'RESET') { + done(undefined, null); + return; + } + try { + done(undefined, JSON.parse(preloadedData[subContentId][dataId])); + } + catch (err) { + done(err); + } + } + else { + contentUserDataAjax(contentId, dataId, subContentId, function (err, data) { + if (err || data === undefined) { + done(err, data); + return; // Error or no data + } + + // Cache in preloaded + if (content.contentUserData === undefined) { + content.contentUserData = preloadedData = {}; + } + if (preloadedData[subContentId] === undefined) { + preloadedData[subContentId] = {}; + } + preloadedData[subContentId][dataId] = data; + + // Done. Try to decode JSON + try { + done(undefined, JSON.parse(data)); + } + catch (e) { + done(e); + } + }); + } + }; + + /** + * Async error handling. + * + * @callback H5P.ErrorCallback + * @param {*} error + */ + + /** + * Set user data for given content. + * + * @param {number} contentId + * What content to get data for. + * @param {string} dataId + * Identifies the set of data for this content. + * @param {Object} data + * The data that is to be stored. + * @param {Object} [extras] + * Extra properties + * @param {string} [extras.subContentId] + * Identifies which data belongs to sub content. + * @param {boolean} [extras.preloaded=true] + * If the data should be loaded when content is loaded. + * @param {boolean} [extras.deleteOnChange=false] + * If the data should be invalidated when the content changes. + * @param {H5P.ErrorCallback} [extras.errorCallback] + * Callback with error as parameters. + * @param {boolean} [extras.async=true] + */ + H5P.setUserData = function (contentId, dataId, data, extras) { + var options = H5P.jQuery.extend(true, {}, { + subContentId: 0, + preloaded: true, + deleteOnChange: false, + async: true + }, extras); + + try { + data = JSON.stringify(data); + } + catch (err) { + if (options.errorCallback) { + options.errorCallback(err); + } + return; // Failed to serialize. + } + + var content = H5PIntegration.contents['cid-' + contentId]; + if (content === undefined) { + content = H5PIntegration.contents['cid-' + contentId] = {}; + } + if (!content.contentUserData) { + content.contentUserData = {}; + } + var preloadedData = content.contentUserData; + if (preloadedData[options.subContentId] === undefined) { + preloadedData[options.subContentId] = {}; + } + if (data === preloadedData[options.subContentId][dataId]) { + return; // No need to save this twice. + } + + preloadedData[options.subContentId][dataId] = data; + contentUserDataAjax(contentId, dataId, options.subContentId, function (error) { + if (options.errorCallback && error) { + options.errorCallback(error); + } + }, data, options.preloaded, options.deleteOnChange, options.async); + }; + + /** + * Delete user data for given content. + * + * @param {number} contentId + * What content to remove data for. + * @param {string} dataId + * Identifies the set of data for this content. + * @param {string} [subContentId] + * Identifies which data belongs to sub content. + */ + H5P.deleteUserData = function (contentId, dataId, subContentId) { + if (!subContentId) { + subContentId = 0; // Default + } + + // Remove from preloaded/cache + var preloadedData = H5PIntegration.contents['cid-' + contentId].contentUserData; + if (preloadedData && preloadedData[subContentId] && preloadedData[subContentId][dataId]) { + delete preloadedData[subContentId][dataId]; + } + + contentUserDataAjax(contentId, dataId, subContentId, undefined, null); + }; + + /** + * Function for getting content for a certain ID + * + * @param {number} contentId + * @return {Object} + */ + H5P.getContentForInstance = function (contentId) { + var key = 'cid-' + contentId; + var exists = H5PIntegration && H5PIntegration.contents && + H5PIntegration.contents[key]; + + return exists ? H5PIntegration.contents[key] : undefined; + }; + + /** + * Prepares the content parameters for storing in the clipboard. + * + * @class + * @param {Object} parameters The parameters for the content to store + * @param {string} [genericProperty] If only part of the parameters are generic, which part + * @param {string} [specificKey] If the parameters are specific, what content type does it fit + * @returns {Object} Ready for the clipboard + */ + H5P.ClipboardItem = function (parameters, genericProperty, specificKey) { + var self = this; + + /** + * Set relative dimensions when params contains a file with a width and a height. + * Very useful to be compatible with wysiwyg editors. + * + * @private + */ + var setDimensionsFromFile = function () { + if (!self.generic) { + return; + } + var params = self.specific[self.generic]; + if (!params.params.file || !params.params.file.width || !params.params.file.height) { + return; + } + + self.width = 20; // % + self.height = (params.params.file.height / params.params.file.width) * self.width; + }; + + if (!genericProperty) { + genericProperty = 'action'; + parameters = { + action: parameters + }; + } + + self.specific = parameters; + + if (genericProperty && parameters[genericProperty]) { + self.generic = genericProperty; + } + if (specificKey) { + self.from = specificKey; + } + + if (window.H5PEditor && H5PEditor.contentId) { + self.contentId = H5PEditor.contentId; + } + + if (!self.specific.width && !self.specific.height) { + setDimensionsFromFile(); + } + }; + + /** + * Store item in the H5P Clipboard. + * + * @param {H5P.ClipboardItem|*} clipboardItem + */ + H5P.clipboardify = function (clipboardItem) { + if (!(clipboardItem instanceof H5P.ClipboardItem)) { + clipboardItem = new H5P.ClipboardItem(clipboardItem); + } + H5P.setClipboard(clipboardItem); + }; + + /** + * Retrieve parsed clipboard data. + * + * @return {Object} + */ + H5P.getClipboard = function () { + return parseClipboard(); + }; + + /** + * Set item in the H5P Clipboard. + * + * @param {H5P.ClipboardItem|object} clipboardItem - Data to be set. + */ + H5P.setClipboard = function (clipboardItem) { + localStorage.setItem('h5pClipboard', JSON.stringify(clipboardItem)); + + // Trigger an event so all 'Paste' buttons may be enabled. + H5P.externalDispatcher.trigger('datainclipboard', {reset: false}); + }; + + /** + * Get config for a library + * + * @param string machineName + * @return Object + */ + H5P.getLibraryConfig = function (machineName) { + var hasConfig = H5PIntegration.libraryConfig && H5PIntegration.libraryConfig[machineName]; + return hasConfig ? H5PIntegration.libraryConfig[machineName] : {}; + }; + + /** + * Get item from the H5P Clipboard. + * + * @private + * @return {Object} + */ + var parseClipboard = function () { + var clipboardData = localStorage.getItem('h5pClipboard'); + if (!clipboardData) { + return; + } + + // Try to parse clipboard dat + try { + clipboardData = JSON.parse(clipboardData); + } + catch (err) { + console.error('Unable to parse JSON from clipboard.', err); + return; + } + + // Update file URLs and reset content Ids + recursiveUpdate(clipboardData.specific, function (path) { + var isTmpFile = (path.substr(-4, 4) === '#tmp'); + if (!isTmpFile && clipboardData.contentId && !path.match(/^https?:\/\//i)) { + // Comes from existing content + + if (H5PEditor.contentId) { + // .. to existing content + return '../' + clipboardData.contentId + '/' + path; + } + else { + // .. to new content + return (H5PEditor.contentRelUrl ? H5PEditor.contentRelUrl : '../content/') + clipboardData.contentId + '/' + path; + } + } + return path; // Will automatically be looked for in tmp folder + }); + + + if (clipboardData.generic) { + // Use reference instead of key + clipboardData.generic = clipboardData.specific[clipboardData.generic]; + } + + return clipboardData; + }; + + /** + * Update file URLs and reset content IDs. + * Useful when copying content. + * + * @private + * @param {object} params Reference + * @param {function} handler Modifies the path to work when pasted + */ + var recursiveUpdate = function (params, handler) { + for (var prop in params) { + if (params.hasOwnProperty(prop) && params[prop] instanceof Object) { + var obj = params[prop]; + if (obj.path !== undefined && obj.mime !== undefined) { + obj.path = handler(obj.path); + } + else { + if (obj.library !== undefined && obj.subContentId !== undefined) { + // Avoid multiple content with same ID + delete obj.subContentId; + } + recursiveUpdate(obj, handler); + } + } + } + }; + + // Init H5P when page is fully loadded + $(document).ready(function () { + + window.addEventListener('storage', function (event) { + // Pick up clipboard changes from other tabs + if (event.key === 'h5pClipboard') { + // Trigger an event so all 'Paste' buttons may be enabled. + H5P.externalDispatcher.trigger('datainclipboard', {reset: event.newValue === null}); + } + }); + + var ccVersions = { + 'default': '4.0', + '4.0': H5P.t('licenseCC40'), + '3.0': H5P.t('licenseCC30'), + '2.5': H5P.t('licenseCC25'), + '2.0': H5P.t('licenseCC20'), + '1.0': H5P.t('licenseCC10'), + }; + + /** + * Maps copyright license codes to their human readable counterpart. + * + * @type {Object} + */ + H5P.copyrightLicenses = { + 'U': H5P.t('licenseU'), + 'CC BY': { + label: H5P.t('licenseCCBY'), + link: 'http://creativecommons.org/licenses/by/:version', + versions: ccVersions + }, + 'CC BY-SA': { + label: H5P.t('licenseCCBYSA'), + link: 'http://creativecommons.org/licenses/by-sa/:version', + versions: ccVersions + }, + 'CC BY-ND': { + label: H5P.t('licenseCCBYND'), + link: 'http://creativecommons.org/licenses/by-nd/:version', + versions: ccVersions + }, + 'CC BY-NC': { + label: H5P.t('licenseCCBYNC'), + link: 'http://creativecommons.org/licenses/by-nc/:version', + versions: ccVersions + }, + 'CC BY-NC-SA': { + label: H5P.t('licenseCCBYNCSA'), + link: 'http://creativecommons.org/licenses/by-nc-sa/:version', + versions: ccVersions + }, + 'CC BY-NC-ND': { + label: H5P.t('licenseCCBYNCND'), + link: 'http://creativecommons.org/licenses/by-nc-nd/:version', + versions: ccVersions + }, + 'CC0 1.0': { + label: H5P.t('licenseCC010'), + link: 'https://creativecommons.org/publicdomain/zero/1.0/' + }, + 'GNU GPL': { + label: H5P.t('licenseGPL'), + link: 'http://www.gnu.org/licenses/gpl-:version-standalone.html', + linkVersions: { + 'v3': '3.0', + 'v2': '2.0', + 'v1': '1.0' + }, + versions: { + 'default': 'v3', + 'v3': H5P.t('licenseV3'), + 'v2': H5P.t('licenseV2'), + 'v1': H5P.t('licenseV1') + } + }, + 'PD': { + label: H5P.t('licensePD'), + versions: { + 'CC0 1.0': { + label: H5P.t('licenseCC010'), + link: 'https://creativecommons.org/publicdomain/zero/1.0/' + }, + 'CC PDM': { + label: H5P.t('licensePDM'), + link: 'https://creativecommons.org/publicdomain/mark/1.0/' + } + } + }, + 'ODC PDDL': 'Public Domain Dedication and Licence', + 'CC PDM': { + label: H5P.t('licensePDM'), + link: 'https://creativecommons.org/publicdomain/mark/1.0/' + }, + 'C': H5P.t('licenseC'), + }; + + /** + * Indicates if H5P is embedded on an external page using iframe. + * @member {boolean} H5P.externalEmbed + */ + + // Relay events to top window. This must be done before H5P.init + // since events may be fired on initialization. + if (H5P.isFramed && H5P.externalEmbed === false) { + H5P.externalDispatcher.on('*', function (event) { + window.parent.H5P.externalDispatcher.trigger.call(this, event); + }); + } + + /** + * Prevent H5P Core from initializing. Must be overriden before document ready. + * @member {boolean} H5P.preventInit + */ + if (!H5P.preventInit) { + // Note that this start script has to be an external resource for it to + // load in correct order in IE9. + H5P.init(document.body); + } + + if (H5PIntegration.saveFreq !== false) { + // When was the last state stored + var lastStoredOn = 0; + // Store the current state of the H5P when leaving the page. + var storeCurrentState = function () { + // Make sure at least 250 ms has passed since last save + var currentTime = new Date().getTime(); + if (currentTime - lastStoredOn > 250) { + lastStoredOn = currentTime; + for (var i = 0; i < H5P.instances.length; i++) { + var instance = H5P.instances[i]; + if (instance.getCurrentState instanceof Function || + typeof instance.getCurrentState === 'function') { + var state = instance.getCurrentState(); + if (state !== undefined) { + // Async is not used to prevent the request from being cancelled. + H5P.setUserData(instance.contentId, 'state', state, {deleteOnChange: true, async: false}); + } + } + } + } + }; + // iPad does not support beforeunload, therefore using unload + H5P.$window.one('beforeunload unload', function () { + // Only want to do this once + H5P.$window.off('pagehide beforeunload unload'); + storeCurrentState(); + }); + // pagehide is used on iPad when tabs are switched + H5P.$window.on('pagehide', storeCurrentState); + } + }); + +})(H5P.jQuery); diff --git a/src/core/h5p/assets/js/jquery.js b/src/core/h5p/assets/js/jquery.js new file mode 100644 index 000000000..a05d5568b --- /dev/null +++ b/src/core/h5p/assets/js/jquery.js @@ -0,0 +1,20 @@ +/*! jQuery v1.9.1 | (c) 2005, 2012 jQuery Foundation, Inc. | jquery.org/license +*/(function(e,t){var n,r,i=typeof t,o=e.document,a=e.location,s=e.jQuery,u=e.$,l={},c=[],p="1.9.1",f=c.concat,d=c.push,h=c.slice,g=c.indexOf,m=l.toString,y=l.hasOwnProperty,v=p.trim,b=function(e,t){return new b.fn.init(e,t,r)},x=/[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/.source,w=/\S+/g,T=/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,N=/^(?:(<[\w\W]+>)[^>]*|#([\w-]*))$/,C=/^<(\w+)\s*\/?>(?:<\/\1>|)$/,k=/^[\],:{}\s]*$/,E=/(?:^|:|,)(?:\s*\[)+/g,S=/\\(?:["\\\/bfnrt]|u[\da-fA-F]{4})/g,A=/"[^"\\\r\n]*"|true|false|null|-?(?:\d+\.|)\d+(?:[eE][+-]?\d+|)/g,j=/^-ms-/,D=/-([\da-z])/gi,L=function(e,t){return t.toUpperCase()},H=function(e){(o.addEventListener||"load"===e.type||"complete"===o.readyState)&&(q(),b.ready())},q=function(){o.addEventListener?(o.removeEventListener("DOMContentLoaded",H,!1),e.removeEventListener("load",H,!1)):(o.detachEvent("onreadystatechange",H),e.detachEvent("onload",H))};b.fn=b.prototype={jquery:p,constructor:b,init:function(e,n,r){var i,a;if(!e)return this;if("string"==typeof e){if(i="<"===e.charAt(0)&&">"===e.charAt(e.length-1)&&e.length>=3?[null,e,null]:N.exec(e),!i||!i[1]&&n)return!n||n.jquery?(n||r).find(e):this.constructor(n).find(e);if(i[1]){if(n=n instanceof b?n[0]:n,b.merge(this,b.parseHTML(i[1],n&&n.nodeType?n.ownerDocument||n:o,!0)),C.test(i[1])&&b.isPlainObject(n))for(i in n)b.isFunction(this[i])?this[i](n[i]):this.attr(i,n[i]);return this}if(a=o.getElementById(i[2]),a&&a.parentNode){if(a.id!==i[2])return r.find(e);this.length=1,this[0]=a}return this.context=o,this.selector=e,this}return e.nodeType?(this.context=this[0]=e,this.length=1,this):b.isFunction(e)?r.ready(e):(e.selector!==t&&(this.selector=e.selector,this.context=e.context),b.makeArray(e,this))},selector:"",length:0,size:function(){return this.length},toArray:function(){return h.call(this)},get:function(e){return null==e?this.toArray():0>e?this[this.length+e]:this[e]},pushStack:function(e){var t=b.merge(this.constructor(),e);return t.prevObject=this,t.context=this.context,t},each:function(e,t){return b.each(this,e,t)},ready:function(e){return b.ready.promise().done(e),this},slice:function(){return this.pushStack(h.apply(this,arguments))},first:function(){return this.eq(0)},last:function(){return this.eq(-1)},eq:function(e){var t=this.length,n=+e+(0>e?t:0);return this.pushStack(n>=0&&t>n?[this[n]]:[])},map:function(e){return this.pushStack(b.map(this,function(t,n){return e.call(t,n,t)}))},end:function(){return this.prevObject||this.constructor(null)},push:d,sort:[].sort,splice:[].splice},b.fn.init.prototype=b.fn,b.extend=b.fn.extend=function(){var e,n,r,i,o,a,s=arguments[0]||{},u=1,l=arguments.length,c=!1;for("boolean"==typeof s&&(c=s,s=arguments[1]||{},u=2),"object"==typeof s||b.isFunction(s)||(s={}),l===u&&(s=this,--u);l>u;u++)if(null!=(o=arguments[u]))for(i in o)e=s[i],r=o[i],s!==r&&(c&&r&&(b.isPlainObject(r)||(n=b.isArray(r)))?(n?(n=!1,a=e&&b.isArray(e)?e:[]):a=e&&b.isPlainObject(e)?e:{},s[i]=b.extend(c,a,r)):r!==t&&(s[i]=r));return s},b.extend({noConflict:function(t){return e.$===b&&(e.$=u),t&&e.jQuery===b&&(e.jQuery=s),b},isReady:!1,readyWait:1,holdReady:function(e){e?b.readyWait++:b.ready(!0)},ready:function(e){if(e===!0?!--b.readyWait:!b.isReady){if(!o.body)return setTimeout(b.ready);b.isReady=!0,e!==!0&&--b.readyWait>0||(n.resolveWith(o,[b]),b.fn.trigger&&b(o).trigger("ready").off("ready"))}},isFunction:function(e){return"function"===b.type(e)},isArray:Array.isArray||function(e){return"array"===b.type(e)},isWindow:function(e){return null!=e&&e==e.window},isNumeric:function(e){return!isNaN(parseFloat(e))&&isFinite(e)},type:function(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?l[m.call(e)]||"object":typeof e},isPlainObject:function(e){if(!e||"object"!==b.type(e)||e.nodeType||b.isWindow(e))return!1;try{if(e.constructor&&!y.call(e,"constructor")&&!y.call(e.constructor.prototype,"isPrototypeOf"))return!1}catch(n){return!1}var r;for(r in e);return r===t||y.call(e,r)},isEmptyObject:function(e){var t;for(t in e)return!1;return!0},error:function(e){throw Error(e)},parseHTML:function(e,t,n){if(!e||"string"!=typeof e)return null;"boolean"==typeof t&&(n=t,t=!1),t=t||o;var r=C.exec(e),i=!n&&[];return r?[t.createElement(r[1])]:(r=b.buildFragment([e],t,i),i&&b(i).remove(),b.merge([],r.childNodes))},parseJSON:function(n){return e.JSON&&e.JSON.parse?e.JSON.parse(n):null===n?n:"string"==typeof n&&(n=b.trim(n),n&&k.test(n.replace(S,"@").replace(A,"]").replace(E,"")))?Function("return "+n)():(b.error("Invalid JSON: "+n),t)},parseXML:function(n){var r,i;if(!n||"string"!=typeof n)return null;try{e.DOMParser?(i=new DOMParser,r=i.parseFromString(n,"text/xml")):(r=new ActiveXObject("Microsoft.XMLDOM"),r.async="false",r.loadXML(n))}catch(o){r=t}return r&&r.documentElement&&!r.getElementsByTagName("parsererror").length||b.error("Invalid XML: "+n),r},noop:function(){},globalEval:function(t){t&&b.trim(t)&&(e.execScript||function(t){e.eval.call(e,t)})(t)},camelCase:function(e){return e.replace(j,"ms-").replace(D,L)},nodeName:function(e,t){return e.nodeName&&e.nodeName.toLowerCase()===t.toLowerCase()},each:function(e,t,n){var r,i=0,o=e.length,a=M(e);if(n){if(a){for(;o>i;i++)if(r=t.apply(e[i],n),r===!1)break}else for(i in e)if(r=t.apply(e[i],n),r===!1)break}else if(a){for(;o>i;i++)if(r=t.call(e[i],i,e[i]),r===!1)break}else for(i in e)if(r=t.call(e[i],i,e[i]),r===!1)break;return e},trim:v&&!v.call("\ufeff\u00a0")?function(e){return null==e?"":v.call(e)}:function(e){return null==e?"":(e+"").replace(T,"")},makeArray:function(e,t){var n=t||[];return null!=e&&(M(Object(e))?b.merge(n,"string"==typeof e?[e]:e):d.call(n,e)),n},inArray:function(e,t,n){var r;if(t){if(g)return g.call(t,e,n);for(r=t.length,n=n?0>n?Math.max(0,r+n):n:0;r>n;n++)if(n in t&&t[n]===e)return n}return-1},merge:function(e,n){var r=n.length,i=e.length,o=0;if("number"==typeof r)for(;r>o;o++)e[i++]=n[o];else while(n[o]!==t)e[i++]=n[o++];return e.length=i,e},grep:function(e,t,n){var r,i=[],o=0,a=e.length;for(n=!!n;a>o;o++)r=!!t(e[o],o),n!==r&&i.push(e[o]);return i},map:function(e,t,n){var r,i=0,o=e.length,a=M(e),s=[];if(a)for(;o>i;i++)r=t(e[i],i,n),null!=r&&(s[s.length]=r);else for(i in e)r=t(e[i],i,n),null!=r&&(s[s.length]=r);return f.apply([],s)},guid:1,proxy:function(e,n){var r,i,o;return"string"==typeof n&&(o=e[n],n=e,e=o),b.isFunction(e)?(r=h.call(arguments,2),i=function(){return e.apply(n||this,r.concat(h.call(arguments)))},i.guid=e.guid=e.guid||b.guid++,i):t},access:function(e,n,r,i,o,a,s){var u=0,l=e.length,c=null==r;if("object"===b.type(r)){o=!0;for(u in r)b.access(e,n,u,r[u],!0,a,s)}else if(i!==t&&(o=!0,b.isFunction(i)||(s=!0),c&&(s?(n.call(e,i),n=null):(c=n,n=function(e,t,n){return c.call(b(e),n)})),n))for(;l>u;u++)n(e[u],r,s?i:i.call(e[u],u,n(e[u],r)));return o?e:c?n.call(e):l?n(e[0],r):a},now:function(){return(new Date).getTime()}}),b.ready.promise=function(t){if(!n)if(n=b.Deferred(),"complete"===o.readyState)setTimeout(b.ready);else if(o.addEventListener)o.addEventListener("DOMContentLoaded",H,!1),e.addEventListener("load",H,!1);else{o.attachEvent("onreadystatechange",H),e.attachEvent("onload",H);var r=!1;try{r=null==e.frameElement&&o.documentElement}catch(i){}r&&r.doScroll&&function a(){if(!b.isReady){try{r.doScroll("left")}catch(e){return setTimeout(a,50)}q(),b.ready()}}()}return n.promise(t)},b.each("Boolean Number String Function Array Date RegExp Object Error".split(" "),function(e,t){l["[object "+t+"]"]=t.toLowerCase()});function M(e){var t=e.length,n=b.type(e);return b.isWindow(e)?!1:1===e.nodeType&&t?!0:"array"===n||"function"!==n&&(0===t||"number"==typeof t&&t>0&&t-1 in e)}r=b(o);var _={};function F(e){var t=_[e]={};return b.each(e.match(w)||[],function(e,n){t[n]=!0}),t}b.Callbacks=function(e){e="string"==typeof e?_[e]||F(e):b.extend({},e);var n,r,i,o,a,s,u=[],l=!e.once&&[],c=function(t){for(r=e.memory&&t,i=!0,a=s||0,s=0,o=u.length,n=!0;u&&o>a;a++)if(u[a].apply(t[0],t[1])===!1&&e.stopOnFalse){r=!1;break}n=!1,u&&(l?l.length&&c(l.shift()):r?u=[]:p.disable())},p={add:function(){if(u){var t=u.length;(function i(t){b.each(t,function(t,n){var r=b.type(n);"function"===r?e.unique&&p.has(n)||u.push(n):n&&n.length&&"string"!==r&&i(n)})})(arguments),n?o=u.length:r&&(s=t,c(r))}return this},remove:function(){return u&&b.each(arguments,function(e,t){var r;while((r=b.inArray(t,u,r))>-1)u.splice(r,1),n&&(o>=r&&o--,a>=r&&a--)}),this},has:function(e){return e?b.inArray(e,u)>-1:!(!u||!u.length)},empty:function(){return u=[],this},disable:function(){return u=l=r=t,this},disabled:function(){return!u},lock:function(){return l=t,r||p.disable(),this},locked:function(){return!l},fireWith:function(e,t){return t=t||[],t=[e,t.slice?t.slice():t],!u||i&&!l||(n?l.push(t):c(t)),this},fire:function(){return p.fireWith(this,arguments),this},fired:function(){return!!i}};return p},b.extend({Deferred:function(e){var t=[["resolve","done",b.Callbacks("once memory"),"resolved"],["reject","fail",b.Callbacks("once memory"),"rejected"],["notify","progress",b.Callbacks("memory")]],n="pending",r={state:function(){return n},always:function(){return i.done(arguments).fail(arguments),this},then:function(){var e=arguments;return b.Deferred(function(n){b.each(t,function(t,o){var a=o[0],s=b.isFunction(e[t])&&e[t];i[o[1]](function(){var e=s&&s.apply(this,arguments);e&&b.isFunction(e.promise)?e.promise().done(n.resolve).fail(n.reject).progress(n.notify):n[a+"With"](this===r?n.promise():this,s?[e]:arguments)})}),e=null}).promise()},promise:function(e){return null!=e?b.extend(e,r):r}},i={};return r.pipe=r.then,b.each(t,function(e,o){var a=o[2],s=o[3];r[o[1]]=a.add,s&&a.add(function(){n=s},t[1^e][2].disable,t[2][2].lock),i[o[0]]=function(){return i[o[0]+"With"](this===i?r:this,arguments),this},i[o[0]+"With"]=a.fireWith}),r.promise(i),e&&e.call(i,i),i},when:function(e){var t=0,n=h.call(arguments),r=n.length,i=1!==r||e&&b.isFunction(e.promise)?r:0,o=1===i?e:b.Deferred(),a=function(e,t,n){return function(r){t[e]=this,n[e]=arguments.length>1?h.call(arguments):r,n===s?o.notifyWith(t,n):--i||o.resolveWith(t,n)}},s,u,l;if(r>1)for(s=Array(r),u=Array(r),l=Array(r);r>t;t++)n[t]&&b.isFunction(n[t].promise)?n[t].promise().done(a(t,l,n)).fail(o.reject).progress(a(t,u,s)):--i;return i||o.resolveWith(l,n),o.promise()}}),b.support=function(){var t,n,r,a,s,u,l,c,p,f,d=o.createElement("div");if(d.setAttribute("className","t"),d.innerHTML="
      a",n=d.getElementsByTagName("*"),r=d.getElementsByTagName("a")[0],!n||!r||!n.length)return{};s=o.createElement("select"),l=s.appendChild(o.createElement("option")),a=d.getElementsByTagName("input")[0],r.style.cssText="top:1px;float:left;opacity:.5",t={getSetAttribute:"t"!==d.className,leadingWhitespace:3===d.firstChild.nodeType,tbody:!d.getElementsByTagName("tbody").length,htmlSerialize:!!d.getElementsByTagName("link").length,style:/top/.test(r.getAttribute("style")),hrefNormalized:"/a"===r.getAttribute("href"),opacity:/^0.5/.test(r.style.opacity),cssFloat:!!r.style.cssFloat,checkOn:!!a.value,optSelected:l.selected,enctype:!!o.createElement("form").enctype,html5Clone:"<:nav>"!==o.createElement("nav").cloneNode(!0).outerHTML,boxModel:"CSS1Compat"===o.compatMode,deleteExpando:!0,noCloneEvent:!0,inlineBlockNeedsLayout:!1,shrinkWrapBlocks:!1,reliableMarginRight:!0,boxSizingReliable:!0,pixelPosition:!1},a.checked=!0,t.noCloneChecked=a.cloneNode(!0).checked,s.disabled=!0,t.optDisabled=!l.disabled;try{delete d.test}catch(h){t.deleteExpando=!1}a=o.createElement("input"),a.setAttribute("value",""),t.input=""===a.getAttribute("value"),a.value="t",a.setAttribute("type","radio"),t.radioValue="t"===a.value,a.setAttribute("checked","t"),a.setAttribute("name","t"),u=o.createDocumentFragment(),u.appendChild(a),t.appendChecked=a.checked,t.checkClone=u.cloneNode(!0).cloneNode(!0).lastChild.checked,d.attachEvent&&(d.attachEvent("onclick",function(){t.noCloneEvent=!1}),d.cloneNode(!0).click());for(f in{submit:!0,change:!0,focusin:!0})d.setAttribute(c="on"+f,"t"),t[f+"Bubbles"]=c in e||d.attributes[c].expando===!1;return d.style.backgroundClip="content-box",d.cloneNode(!0).style.backgroundClip="",t.clearCloneStyle="content-box"===d.style.backgroundClip,b(function(){var n,r,a,s="padding:0;margin:0;border:0;display:block;box-sizing:content-box;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;",u=o.getElementsByTagName("body")[0];u&&(n=o.createElement("div"),n.style.cssText="border:0;width:0;height:0;position:absolute;top:0;left:-9999px;margin-top:1px",u.appendChild(n).appendChild(d),d.innerHTML="
      t
      ",a=d.getElementsByTagName("td"),a[0].style.cssText="padding:0;margin:0;border:0;display:none",p=0===a[0].offsetHeight,a[0].style.display="",a[1].style.display="none",t.reliableHiddenOffsets=p&&0===a[0].offsetHeight,d.innerHTML="",d.style.cssText="box-sizing:border-box;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;padding:1px;border:1px;display:block;width:4px;margin-top:1%;position:absolute;top:1%;",t.boxSizing=4===d.offsetWidth,t.doesNotIncludeMarginInBodyOffset=1!==u.offsetTop,e.getComputedStyle&&(t.pixelPosition="1%"!==(e.getComputedStyle(d,null)||{}).top,t.boxSizingReliable="4px"===(e.getComputedStyle(d,null)||{width:"4px"}).width,r=d.appendChild(o.createElement("div")),r.style.cssText=d.style.cssText=s,r.style.marginRight=r.style.width="0",d.style.width="1px",t.reliableMarginRight=!parseFloat((e.getComputedStyle(r,null)||{}).marginRight)),typeof d.style.zoom!==i&&(d.innerHTML="",d.style.cssText=s+"width:1px;padding:1px;display:inline;zoom:1",t.inlineBlockNeedsLayout=3===d.offsetWidth,d.style.display="block",d.innerHTML="
      ",d.firstChild.style.width="5px",t.shrinkWrapBlocks=3!==d.offsetWidth,t.inlineBlockNeedsLayout&&(u.style.zoom=1)),u.removeChild(n),n=d=a=r=null)}),n=s=u=l=r=a=null,t}();var O=/(?:\{[\s\S]*\}|\[[\s\S]*\])$/,B=/([A-Z])/g;function P(e,n,r,i){if(b.acceptData(e)){var o,a,s=b.expando,u="string"==typeof n,l=e.nodeType,p=l?b.cache:e,f=l?e[s]:e[s]&&s;if(f&&p[f]&&(i||p[f].data)||!u||r!==t)return f||(l?e[s]=f=c.pop()||b.guid++:f=s),p[f]||(p[f]={},l||(p[f].toJSON=b.noop)),("object"==typeof n||"function"==typeof n)&&(i?p[f]=b.extend(p[f],n):p[f].data=b.extend(p[f].data,n)),o=p[f],i||(o.data||(o.data={}),o=o.data),r!==t&&(o[b.camelCase(n)]=r),u?(a=o[n],null==a&&(a=o[b.camelCase(n)])):a=o,a}}function R(e,t,n){if(b.acceptData(e)){var r,i,o,a=e.nodeType,s=a?b.cache:e,u=a?e[b.expando]:b.expando;if(s[u]){if(t&&(o=n?s[u]:s[u].data)){b.isArray(t)?t=t.concat(b.map(t,b.camelCase)):t in o?t=[t]:(t=b.camelCase(t),t=t in o?[t]:t.split(" "));for(r=0,i=t.length;i>r;r++)delete o[t[r]];if(!(n?$:b.isEmptyObject)(o))return}(n||(delete s[u].data,$(s[u])))&&(a?b.cleanData([e],!0):b.support.deleteExpando||s!=s.window?delete s[u]:s[u]=null)}}}b.extend({cache:{},expando:"jQuery"+(p+Math.random()).replace(/\D/g,""),noData:{embed:!0,object:"clsid:D27CDB6E-AE6D-11cf-96B8-444553540000",applet:!0},hasData:function(e){return e=e.nodeType?b.cache[e[b.expando]]:e[b.expando],!!e&&!$(e)},data:function(e,t,n){return P(e,t,n)},removeData:function(e,t){return R(e,t)},_data:function(e,t,n){return P(e,t,n,!0)},_removeData:function(e,t){return R(e,t,!0)},acceptData:function(e){if(e.nodeType&&1!==e.nodeType&&9!==e.nodeType)return!1;var t=e.nodeName&&b.noData[e.nodeName.toLowerCase()];return!t||t!==!0&&e.getAttribute("classid")===t}}),b.fn.extend({data:function(e,n){var r,i,o=this[0],a=0,s=null;if(e===t){if(this.length&&(s=b.data(o),1===o.nodeType&&!b._data(o,"parsedAttrs"))){for(r=o.attributes;r.length>a;a++)i=r[a].name,i.indexOf("data-")||(i=b.camelCase(i.slice(5)),W(o,i,s[i]));b._data(o,"parsedAttrs",!0)}return s}return"object"==typeof e?this.each(function(){b.data(this,e)}):b.access(this,function(n){return n===t?o?W(o,e,b.data(o,e)):null:(this.each(function(){b.data(this,e,n)}),t)},null,n,arguments.length>1,null,!0)},removeData:function(e){return this.each(function(){b.removeData(this,e)})}});function W(e,n,r){if(r===t&&1===e.nodeType){var i="data-"+n.replace(B,"-$1").toLowerCase();if(r=e.getAttribute(i),"string"==typeof r){try{r="true"===r?!0:"false"===r?!1:"null"===r?null:+r+""===r?+r:O.test(r)?b.parseJSON(r):r}catch(o){}b.data(e,n,r)}else r=t}return r}function $(e){var t;for(t in e)if(("data"!==t||!b.isEmptyObject(e[t]))&&"toJSON"!==t)return!1;return!0}b.extend({queue:function(e,n,r){var i;return e?(n=(n||"fx")+"queue",i=b._data(e,n),r&&(!i||b.isArray(r)?i=b._data(e,n,b.makeArray(r)):i.push(r)),i||[]):t},dequeue:function(e,t){t=t||"fx";var n=b.queue(e,t),r=n.length,i=n.shift(),o=b._queueHooks(e,t),a=function(){b.dequeue(e,t)};"inprogress"===i&&(i=n.shift(),r--),o.cur=i,i&&("fx"===t&&n.unshift("inprogress"),delete o.stop,i.call(e,a,o)),!r&&o&&o.empty.fire()},_queueHooks:function(e,t){var n=t+"queueHooks";return b._data(e,n)||b._data(e,n,{empty:b.Callbacks("once memory").add(function(){b._removeData(e,t+"queue"),b._removeData(e,n)})})}}),b.fn.extend({queue:function(e,n){var r=2;return"string"!=typeof e&&(n=e,e="fx",r--),r>arguments.length?b.queue(this[0],e):n===t?this:this.each(function(){var t=b.queue(this,e,n);b._queueHooks(this,e),"fx"===e&&"inprogress"!==t[0]&&b.dequeue(this,e)})},dequeue:function(e){return this.each(function(){b.dequeue(this,e)})},delay:function(e,t){return e=b.fx?b.fx.speeds[e]||e:e,t=t||"fx",this.queue(t,function(t,n){var r=setTimeout(t,e);n.stop=function(){clearTimeout(r)}})},clearQueue:function(e){return this.queue(e||"fx",[])},promise:function(e,n){var r,i=1,o=b.Deferred(),a=this,s=this.length,u=function(){--i||o.resolveWith(a,[a])};"string"!=typeof e&&(n=e,e=t),e=e||"fx";while(s--)r=b._data(a[s],e+"queueHooks"),r&&r.empty&&(i++,r.empty.add(u));return u(),o.promise(n)}});var I,z,X=/[\t\r\n]/g,U=/\r/g,V=/^(?:input|select|textarea|button|object)$/i,Y=/^(?:a|area)$/i,J=/^(?:checked|selected|autofocus|autoplay|async|controls|defer|disabled|hidden|loop|multiple|open|readonly|required|scoped)$/i,G=/^(?:checked|selected)$/i,Q=b.support.getSetAttribute,K=b.support.input;b.fn.extend({attr:function(e,t){return b.access(this,b.attr,e,t,arguments.length>1)},removeAttr:function(e){return this.each(function(){b.removeAttr(this,e)})},prop:function(e,t){return b.access(this,b.prop,e,t,arguments.length>1)},removeProp:function(e){return e=b.propFix[e]||e,this.each(function(){try{this[e]=t,delete this[e]}catch(n){}})},addClass:function(e){var t,n,r,i,o,a=0,s=this.length,u="string"==typeof e&&e;if(b.isFunction(e))return this.each(function(t){b(this).addClass(e.call(this,t,this.className))});if(u)for(t=(e||"").match(w)||[];s>a;a++)if(n=this[a],r=1===n.nodeType&&(n.className?(" "+n.className+" ").replace(X," "):" ")){o=0;while(i=t[o++])0>r.indexOf(" "+i+" ")&&(r+=i+" ");n.className=b.trim(r)}return this},removeClass:function(e){var t,n,r,i,o,a=0,s=this.length,u=0===arguments.length||"string"==typeof e&&e;if(b.isFunction(e))return this.each(function(t){b(this).removeClass(e.call(this,t,this.className))});if(u)for(t=(e||"").match(w)||[];s>a;a++)if(n=this[a],r=1===n.nodeType&&(n.className?(" "+n.className+" ").replace(X," "):"")){o=0;while(i=t[o++])while(r.indexOf(" "+i+" ")>=0)r=r.replace(" "+i+" "," ");n.className=e?b.trim(r):""}return this},toggleClass:function(e,t){var n=typeof e,r="boolean"==typeof t;return b.isFunction(e)?this.each(function(n){b(this).toggleClass(e.call(this,n,this.className,t),t)}):this.each(function(){if("string"===n){var o,a=0,s=b(this),u=t,l=e.match(w)||[];while(o=l[a++])u=r?u:!s.hasClass(o),s[u?"addClass":"removeClass"](o)}else(n===i||"boolean"===n)&&(this.className&&b._data(this,"__className__",this.className),this.className=this.className||e===!1?"":b._data(this,"__className__")||"")})},hasClass:function(e){var t=" "+e+" ",n=0,r=this.length;for(;r>n;n++)if(1===this[n].nodeType&&(" "+this[n].className+" ").replace(X," ").indexOf(t)>=0)return!0;return!1},val:function(e){var n,r,i,o=this[0];{if(arguments.length)return i=b.isFunction(e),this.each(function(n){var o,a=b(this);1===this.nodeType&&(o=i?e.call(this,n,a.val()):e,null==o?o="":"number"==typeof o?o+="":b.isArray(o)&&(o=b.map(o,function(e){return null==e?"":e+""})),r=b.valHooks[this.type]||b.valHooks[this.nodeName.toLowerCase()],r&&"set"in r&&r.set(this,o,"value")!==t||(this.value=o))});if(o)return r=b.valHooks[o.type]||b.valHooks[o.nodeName.toLowerCase()],r&&"get"in r&&(n=r.get(o,"value"))!==t?n:(n=o.value,"string"==typeof n?n.replace(U,""):null==n?"":n)}}}),b.extend({valHooks:{option:{get:function(e){var t=e.attributes.value;return!t||t.specified?e.value:e.text}},select:{get:function(e){var t,n,r=e.options,i=e.selectedIndex,o="select-one"===e.type||0>i,a=o?null:[],s=o?i+1:r.length,u=0>i?s:o?i:0;for(;s>u;u++)if(n=r[u],!(!n.selected&&u!==i||(b.support.optDisabled?n.disabled:null!==n.getAttribute("disabled"))||n.parentNode.disabled&&b.nodeName(n.parentNode,"optgroup"))){if(t=b(n).val(),o)return t;a.push(t)}return a},set:function(e,t){var n=b.makeArray(t);return b(e).find("option").each(function(){this.selected=b.inArray(b(this).val(),n)>=0}),n.length||(e.selectedIndex=-1),n}}},attr:function(e,n,r){var o,a,s,u=e.nodeType;if(e&&3!==u&&8!==u&&2!==u)return typeof e.getAttribute===i?b.prop(e,n,r):(a=1!==u||!b.isXMLDoc(e),a&&(n=n.toLowerCase(),o=b.attrHooks[n]||(J.test(n)?z:I)),r===t?o&&a&&"get"in o&&null!==(s=o.get(e,n))?s:(typeof e.getAttribute!==i&&(s=e.getAttribute(n)),null==s?t:s):null!==r?o&&a&&"set"in o&&(s=o.set(e,r,n))!==t?s:(e.setAttribute(n,r+""),r):(b.removeAttr(e,n),t))},removeAttr:function(e,t){var n,r,i=0,o=t&&t.match(w);if(o&&1===e.nodeType)while(n=o[i++])r=b.propFix[n]||n,J.test(n)?!Q&&G.test(n)?e[b.camelCase("default-"+n)]=e[r]=!1:e[r]=!1:b.attr(e,n,""),e.removeAttribute(Q?n:r)},attrHooks:{type:{set:function(e,t){if(!b.support.radioValue&&"radio"===t&&b.nodeName(e,"input")){var n=e.value;return e.setAttribute("type",t),n&&(e.value=n),t}}}},propFix:{tabindex:"tabIndex",readonly:"readOnly","for":"htmlFor","class":"className",maxlength:"maxLength",cellspacing:"cellSpacing",cellpadding:"cellPadding",rowspan:"rowSpan",colspan:"colSpan",usemap:"useMap",frameborder:"frameBorder",contenteditable:"contentEditable"},prop:function(e,n,r){var i,o,a,s=e.nodeType;if(e&&3!==s&&8!==s&&2!==s)return a=1!==s||!b.isXMLDoc(e),a&&(n=b.propFix[n]||n,o=b.propHooks[n]),r!==t?o&&"set"in o&&(i=o.set(e,r,n))!==t?i:e[n]=r:o&&"get"in o&&null!==(i=o.get(e,n))?i:e[n]},propHooks:{tabIndex:{get:function(e){var n=e.getAttributeNode("tabindex");return n&&n.specified?parseInt(n.value,10):V.test(e.nodeName)||Y.test(e.nodeName)&&e.href?0:t}}}}),z={get:function(e,n){var r=b.prop(e,n),i="boolean"==typeof r&&e.getAttribute(n),o="boolean"==typeof r?K&&Q?null!=i:G.test(n)?e[b.camelCase("default-"+n)]:!!i:e.getAttributeNode(n);return o&&o.value!==!1?n.toLowerCase():t},set:function(e,t,n){return t===!1?b.removeAttr(e,n):K&&Q||!G.test(n)?e.setAttribute(!Q&&b.propFix[n]||n,n):e[b.camelCase("default-"+n)]=e[n]=!0,n}},K&&Q||(b.attrHooks.value={get:function(e,n){var r=e.getAttributeNode(n);return b.nodeName(e,"input")?e.defaultValue:r&&r.specified?r.value:t},set:function(e,n,r){return b.nodeName(e,"input")?(e.defaultValue=n,t):I&&I.set(e,n,r)}}),Q||(I=b.valHooks.button={get:function(e,n){var r=e.getAttributeNode(n);return r&&("id"===n||"name"===n||"coords"===n?""!==r.value:r.specified)?r.value:t},set:function(e,n,r){var i=e.getAttributeNode(r);return i||e.setAttributeNode(i=e.ownerDocument.createAttribute(r)),i.value=n+="","value"===r||n===e.getAttribute(r)?n:t}},b.attrHooks.contenteditable={get:I.get,set:function(e,t,n){I.set(e,""===t?!1:t,n)}},b.each(["width","height"],function(e,n){b.attrHooks[n]=b.extend(b.attrHooks[n],{set:function(e,r){return""===r?(e.setAttribute(n,"auto"),r):t}})})),b.support.hrefNormalized||(b.each(["href","src","width","height"],function(e,n){b.attrHooks[n]=b.extend(b.attrHooks[n],{get:function(e){var r=e.getAttribute(n,2);return null==r?t:r}})}),b.each(["href","src"],function(e,t){b.propHooks[t]={get:function(e){return e.getAttribute(t,4)}}})),b.support.style||(b.attrHooks.style={get:function(e){return e.style.cssText||t},set:function(e,t){return e.style.cssText=t+""}}),b.support.optSelected||(b.propHooks.selected=b.extend(b.propHooks.selected,{get:function(e){var t=e.parentNode;return t&&(t.selectedIndex,t.parentNode&&t.parentNode.selectedIndex),null}})),b.support.enctype||(b.propFix.enctype="encoding"),b.support.checkOn||b.each(["radio","checkbox"],function(){b.valHooks[this]={get:function(e){return null===e.getAttribute("value")?"on":e.value}}}),b.each(["radio","checkbox"],function(){b.valHooks[this]=b.extend(b.valHooks[this],{set:function(e,n){return b.isArray(n)?e.checked=b.inArray(b(e).val(),n)>=0:t}})});var Z=/^(?:input|select|textarea)$/i,et=/^key/,tt=/^(?:mouse|contextmenu)|click/,nt=/^(?:focusinfocus|focusoutblur)$/,rt=/^([^.]*)(?:\.(.+)|)$/;function it(){return!0}function ot(){return!1}b.event={global:{},add:function(e,n,r,o,a){var s,u,l,c,p,f,d,h,g,m,y,v=b._data(e);if(v){r.handler&&(c=r,r=c.handler,a=c.selector),r.guid||(r.guid=b.guid++),(u=v.events)||(u=v.events={}),(f=v.handle)||(f=v.handle=function(e){return typeof b===i||e&&b.event.triggered===e.type?t:b.event.dispatch.apply(f.elem,arguments)},f.elem=e),n=(n||"").match(w)||[""],l=n.length;while(l--)s=rt.exec(n[l])||[],g=y=s[1],m=(s[2]||"").split(".").sort(),p=b.event.special[g]||{},g=(a?p.delegateType:p.bindType)||g,p=b.event.special[g]||{},d=b.extend({type:g,origType:y,data:o,handler:r,guid:r.guid,selector:a,needsContext:a&&b.expr.match.needsContext.test(a),namespace:m.join(".")},c),(h=u[g])||(h=u[g]=[],h.delegateCount=0,p.setup&&p.setup.call(e,o,m,f)!==!1||(e.addEventListener?e.addEventListener(g,f,!1):e.attachEvent&&e.attachEvent("on"+g,f))),p.add&&(p.add.call(e,d),d.handler.guid||(d.handler.guid=r.guid)),a?h.splice(h.delegateCount++,0,d):h.push(d),b.event.global[g]=!0;e=null}},remove:function(e,t,n,r,i){var o,a,s,u,l,c,p,f,d,h,g,m=b.hasData(e)&&b._data(e);if(m&&(c=m.events)){t=(t||"").match(w)||[""],l=t.length;while(l--)if(s=rt.exec(t[l])||[],d=g=s[1],h=(s[2]||"").split(".").sort(),d){p=b.event.special[d]||{},d=(r?p.delegateType:p.bindType)||d,f=c[d]||[],s=s[2]&&RegExp("(^|\\.)"+h.join("\\.(?:.*\\.|)")+"(\\.|$)"),u=o=f.length;while(o--)a=f[o],!i&&g!==a.origType||n&&n.guid!==a.guid||s&&!s.test(a.namespace)||r&&r!==a.selector&&("**"!==r||!a.selector)||(f.splice(o,1),a.selector&&f.delegateCount--,p.remove&&p.remove.call(e,a));u&&!f.length&&(p.teardown&&p.teardown.call(e,h,m.handle)!==!1||b.removeEvent(e,d,m.handle),delete c[d])}else for(d in c)b.event.remove(e,d+t[l],n,r,!0);b.isEmptyObject(c)&&(delete m.handle,b._removeData(e,"events"))}},trigger:function(n,r,i,a){var s,u,l,c,p,f,d,h=[i||o],g=y.call(n,"type")?n.type:n,m=y.call(n,"namespace")?n.namespace.split("."):[];if(l=f=i=i||o,3!==i.nodeType&&8!==i.nodeType&&!nt.test(g+b.event.triggered)&&(g.indexOf(".")>=0&&(m=g.split("."),g=m.shift(),m.sort()),u=0>g.indexOf(":")&&"on"+g,n=n[b.expando]?n:new b.Event(g,"object"==typeof n&&n),n.isTrigger=!0,n.namespace=m.join("."),n.namespace_re=n.namespace?RegExp("(^|\\.)"+m.join("\\.(?:.*\\.|)")+"(\\.|$)"):null,n.result=t,n.target||(n.target=i),r=null==r?[n]:b.makeArray(r,[n]),p=b.event.special[g]||{},a||!p.trigger||p.trigger.apply(i,r)!==!1)){if(!a&&!p.noBubble&&!b.isWindow(i)){for(c=p.delegateType||g,nt.test(c+g)||(l=l.parentNode);l;l=l.parentNode)h.push(l),f=l;f===(i.ownerDocument||o)&&h.push(f.defaultView||f.parentWindow||e)}d=0;while((l=h[d++])&&!n.isPropagationStopped())n.type=d>1?c:p.bindType||g,s=(b._data(l,"events")||{})[n.type]&&b._data(l,"handle"),s&&s.apply(l,r),s=u&&l[u],s&&b.acceptData(l)&&s.apply&&s.apply(l,r)===!1&&n.preventDefault();if(n.type=g,!(a||n.isDefaultPrevented()||p._default&&p._default.apply(i.ownerDocument,r)!==!1||"click"===g&&b.nodeName(i,"a")||!b.acceptData(i)||!u||!i[g]||b.isWindow(i))){f=i[u],f&&(i[u]=null),b.event.triggered=g;try{i[g]()}catch(v){}b.event.triggered=t,f&&(i[u]=f)}return n.result}},dispatch:function(e){e=b.event.fix(e);var n,r,i,o,a,s=[],u=h.call(arguments),l=(b._data(this,"events")||{})[e.type]||[],c=b.event.special[e.type]||{};if(u[0]=e,e.delegateTarget=this,!c.preDispatch||c.preDispatch.call(this,e)!==!1){s=b.event.handlers.call(this,e,l),n=0;while((o=s[n++])&&!e.isPropagationStopped()){e.currentTarget=o.elem,a=0;while((i=o.handlers[a++])&&!e.isImmediatePropagationStopped())(!e.namespace_re||e.namespace_re.test(i.namespace))&&(e.handleObj=i,e.data=i.data,r=((b.event.special[i.origType]||{}).handle||i.handler).apply(o.elem,u),r!==t&&(e.result=r)===!1&&(e.preventDefault(),e.stopPropagation()))}return c.postDispatch&&c.postDispatch.call(this,e),e.result}},handlers:function(e,n){var r,i,o,a,s=[],u=n.delegateCount,l=e.target;if(u&&l.nodeType&&(!e.button||"click"!==e.type))for(;l!=this;l=l.parentNode||this)if(1===l.nodeType&&(l.disabled!==!0||"click"!==e.type)){for(o=[],a=0;u>a;a++)i=n[a],r=i.selector+" ",o[r]===t&&(o[r]=i.needsContext?b(r,this).index(l)>=0:b.find(r,this,null,[l]).length),o[r]&&o.push(i);o.length&&s.push({elem:l,handlers:o})}return n.length>u&&s.push({elem:this,handlers:n.slice(u)}),s},fix:function(e){if(e[b.expando])return e;var t,n,r,i=e.type,a=e,s=this.fixHooks[i];s||(this.fixHooks[i]=s=tt.test(i)?this.mouseHooks:et.test(i)?this.keyHooks:{}),r=s.props?this.props.concat(s.props):this.props,e=new b.Event(a),t=r.length;while(t--)n=r[t],e[n]=a[n];return e.target||(e.target=a.srcElement||o),3===e.target.nodeType&&(e.target=e.target.parentNode),e.metaKey=!!e.metaKey,s.filter?s.filter(e,a):e},props:"altKey bubbles cancelable ctrlKey currentTarget eventPhase metaKey relatedTarget shiftKey target timeStamp view which".split(" "),fixHooks:{},keyHooks:{props:"char charCode key keyCode".split(" "),filter:function(e,t){return null==e.which&&(e.which=null!=t.charCode?t.charCode:t.keyCode),e}},mouseHooks:{props:"button buttons clientX clientY fromElement offsetX offsetY pageX pageY screenX screenY toElement".split(" "),filter:function(e,n){var r,i,a,s=n.button,u=n.fromElement;return null==e.pageX&&null!=n.clientX&&(i=e.target.ownerDocument||o,a=i.documentElement,r=i.body,e.pageX=n.clientX+(a&&a.scrollLeft||r&&r.scrollLeft||0)-(a&&a.clientLeft||r&&r.clientLeft||0),e.pageY=n.clientY+(a&&a.scrollTop||r&&r.scrollTop||0)-(a&&a.clientTop||r&&r.clientTop||0)),!e.relatedTarget&&u&&(e.relatedTarget=u===e.target?n.toElement:u),e.which||s===t||(e.which=1&s?1:2&s?3:4&s?2:0),e}},special:{load:{noBubble:!0},click:{trigger:function(){return b.nodeName(this,"input")&&"checkbox"===this.type&&this.click?(this.click(),!1):t}},focus:{trigger:function(){if(this!==o.activeElement&&this.focus)try{return this.focus(),!1}catch(e){}},delegateType:"focusin"},blur:{trigger:function(){return this===o.activeElement&&this.blur?(this.blur(),!1):t},delegateType:"focusout"},beforeunload:{postDispatch:function(e){e.result!==t&&(e.originalEvent.returnValue=e.result)}}},simulate:function(e,t,n,r){var i=b.extend(new b.Event,n,{type:e,isSimulated:!0,originalEvent:{}});r?b.event.trigger(i,null,t):b.event.dispatch.call(t,i),i.isDefaultPrevented()&&n.preventDefault()}},b.removeEvent=o.removeEventListener?function(e,t,n){e.removeEventListener&&e.removeEventListener(t,n,!1)}:function(e,t,n){var r="on"+t;e.detachEvent&&(typeof e[r]===i&&(e[r]=null),e.detachEvent(r,n))},b.Event=function(e,n){return this instanceof b.Event?(e&&e.type?(this.originalEvent=e,this.type=e.type,this.isDefaultPrevented=e.defaultPrevented||e.returnValue===!1||e.getPreventDefault&&e.getPreventDefault()?it:ot):this.type=e,n&&b.extend(this,n),this.timeStamp=e&&e.timeStamp||b.now(),this[b.expando]=!0,t):new b.Event(e,n)},b.Event.prototype={isDefaultPrevented:ot,isPropagationStopped:ot,isImmediatePropagationStopped:ot,preventDefault:function(){var e=this.originalEvent;this.isDefaultPrevented=it,e&&(e.preventDefault?e.preventDefault():e.returnValue=!1)},stopPropagation:function(){var e=this.originalEvent;this.isPropagationStopped=it,e&&(e.stopPropagation&&e.stopPropagation(),e.cancelBubble=!0)},stopImmediatePropagation:function(){this.isImmediatePropagationStopped=it,this.stopPropagation()}},b.each({mouseenter:"mouseover",mouseleave:"mouseout"},function(e,t){b.event.special[e]={delegateType:t,bindType:t,handle:function(e){var n,r=this,i=e.relatedTarget,o=e.handleObj; +return(!i||i!==r&&!b.contains(r,i))&&(e.type=o.origType,n=o.handler.apply(this,arguments),e.type=t),n}}}),b.support.submitBubbles||(b.event.special.submit={setup:function(){return b.nodeName(this,"form")?!1:(b.event.add(this,"click._submit keypress._submit",function(e){var n=e.target,r=b.nodeName(n,"input")||b.nodeName(n,"button")?n.form:t;r&&!b._data(r,"submitBubbles")&&(b.event.add(r,"submit._submit",function(e){e._submit_bubble=!0}),b._data(r,"submitBubbles",!0))}),t)},postDispatch:function(e){e._submit_bubble&&(delete e._submit_bubble,this.parentNode&&!e.isTrigger&&b.event.simulate("submit",this.parentNode,e,!0))},teardown:function(){return b.nodeName(this,"form")?!1:(b.event.remove(this,"._submit"),t)}}),b.support.changeBubbles||(b.event.special.change={setup:function(){return Z.test(this.nodeName)?(("checkbox"===this.type||"radio"===this.type)&&(b.event.add(this,"propertychange._change",function(e){"checked"===e.originalEvent.propertyName&&(this._just_changed=!0)}),b.event.add(this,"click._change",function(e){this._just_changed&&!e.isTrigger&&(this._just_changed=!1),b.event.simulate("change",this,e,!0)})),!1):(b.event.add(this,"beforeactivate._change",function(e){var t=e.target;Z.test(t.nodeName)&&!b._data(t,"changeBubbles")&&(b.event.add(t,"change._change",function(e){!this.parentNode||e.isSimulated||e.isTrigger||b.event.simulate("change",this.parentNode,e,!0)}),b._data(t,"changeBubbles",!0))}),t)},handle:function(e){var n=e.target;return this!==n||e.isSimulated||e.isTrigger||"radio"!==n.type&&"checkbox"!==n.type?e.handleObj.handler.apply(this,arguments):t},teardown:function(){return b.event.remove(this,"._change"),!Z.test(this.nodeName)}}),b.support.focusinBubbles||b.each({focus:"focusin",blur:"focusout"},function(e,t){var n=0,r=function(e){b.event.simulate(t,e.target,b.event.fix(e),!0)};b.event.special[t]={setup:function(){0===n++&&o.addEventListener(e,r,!0)},teardown:function(){0===--n&&o.removeEventListener(e,r,!0)}}}),b.fn.extend({on:function(e,n,r,i,o){var a,s;if("object"==typeof e){"string"!=typeof n&&(r=r||n,n=t);for(a in e)this.on(a,n,r,e[a],o);return this}if(null==r&&null==i?(i=n,r=n=t):null==i&&("string"==typeof n?(i=r,r=t):(i=r,r=n,n=t)),i===!1)i=ot;else if(!i)return this;return 1===o&&(s=i,i=function(e){return b().off(e),s.apply(this,arguments)},i.guid=s.guid||(s.guid=b.guid++)),this.each(function(){b.event.add(this,e,i,r,n)})},one:function(e,t,n,r){return this.on(e,t,n,r,1)},off:function(e,n,r){var i,o;if(e&&e.preventDefault&&e.handleObj)return i=e.handleObj,b(e.delegateTarget).off(i.namespace?i.origType+"."+i.namespace:i.origType,i.selector,i.handler),this;if("object"==typeof e){for(o in e)this.off(o,n,e[o]);return this}return(n===!1||"function"==typeof n)&&(r=n,n=t),r===!1&&(r=ot),this.each(function(){b.event.remove(this,e,r,n)})},bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},trigger:function(e,t){return this.each(function(){b.event.trigger(e,t,this)})},triggerHandler:function(e,n){var r=this[0];return r?b.event.trigger(e,n,r,!0):t}}),function(e,t){var n,r,i,o,a,s,u,l,c,p,f,d,h,g,m,y,v,x="sizzle"+-new Date,w=e.document,T={},N=0,C=0,k=it(),E=it(),S=it(),A=typeof t,j=1<<31,D=[],L=D.pop,H=D.push,q=D.slice,M=D.indexOf||function(e){var t=0,n=this.length;for(;n>t;t++)if(this[t]===e)return t;return-1},_="[\\x20\\t\\r\\n\\f]",F="(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+",O=F.replace("w","w#"),B="([*^$|!~]?=)",P="\\["+_+"*("+F+")"+_+"*(?:"+B+_+"*(?:(['\"])((?:\\\\.|[^\\\\])*?)\\3|("+O+")|)|)"+_+"*\\]",R=":("+F+")(?:\\(((['\"])((?:\\\\.|[^\\\\])*?)\\3|((?:\\\\.|[^\\\\()[\\]]|"+P.replace(3,8)+")*)|.*)\\)|)",W=RegExp("^"+_+"+|((?:^|[^\\\\])(?:\\\\.)*)"+_+"+$","g"),$=RegExp("^"+_+"*,"+_+"*"),I=RegExp("^"+_+"*([\\x20\\t\\r\\n\\f>+~])"+_+"*"),z=RegExp(R),X=RegExp("^"+O+"$"),U={ID:RegExp("^#("+F+")"),CLASS:RegExp("^\\.("+F+")"),NAME:RegExp("^\\[name=['\"]?("+F+")['\"]?\\]"),TAG:RegExp("^("+F.replace("w","w*")+")"),ATTR:RegExp("^"+P),PSEUDO:RegExp("^"+R),CHILD:RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+_+"*(even|odd|(([+-]|)(\\d*)n|)"+_+"*(?:([+-]|)"+_+"*(\\d+)|))"+_+"*\\)|)","i"),needsContext:RegExp("^"+_+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+_+"*((?:-\\d)?\\d*)"+_+"*\\)|)(?=[^-]|$)","i")},V=/[\x20\t\r\n\f]*[+~]/,Y=/^[^{]+\{\s*\[native code/,J=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,G=/^(?:input|select|textarea|button)$/i,Q=/^h\d$/i,K=/'|\\/g,Z=/\=[\x20\t\r\n\f]*([^'"\]]*)[\x20\t\r\n\f]*\]/g,et=/\\([\da-fA-F]{1,6}[\x20\t\r\n\f]?|.)/g,tt=function(e,t){var n="0x"+t-65536;return n!==n?t:0>n?String.fromCharCode(n+65536):String.fromCharCode(55296|n>>10,56320|1023&n)};try{q.call(w.documentElement.childNodes,0)[0].nodeType}catch(nt){q=function(e){var t,n=[];while(t=this[e++])n.push(t);return n}}function rt(e){return Y.test(e+"")}function it(){var e,t=[];return e=function(n,r){return t.push(n+=" ")>i.cacheLength&&delete e[t.shift()],e[n]=r}}function ot(e){return e[x]=!0,e}function at(e){var t=p.createElement("div");try{return e(t)}catch(n){return!1}finally{t=null}}function st(e,t,n,r){var i,o,a,s,u,l,f,g,m,v;if((t?t.ownerDocument||t:w)!==p&&c(t),t=t||p,n=n||[],!e||"string"!=typeof e)return n;if(1!==(s=t.nodeType)&&9!==s)return[];if(!d&&!r){if(i=J.exec(e))if(a=i[1]){if(9===s){if(o=t.getElementById(a),!o||!o.parentNode)return n;if(o.id===a)return n.push(o),n}else if(t.ownerDocument&&(o=t.ownerDocument.getElementById(a))&&y(t,o)&&o.id===a)return n.push(o),n}else{if(i[2])return H.apply(n,q.call(t.getElementsByTagName(e),0)),n;if((a=i[3])&&T.getByClassName&&t.getElementsByClassName)return H.apply(n,q.call(t.getElementsByClassName(a),0)),n}if(T.qsa&&!h.test(e)){if(f=!0,g=x,m=t,v=9===s&&e,1===s&&"object"!==t.nodeName.toLowerCase()){l=ft(e),(f=t.getAttribute("id"))?g=f.replace(K,"\\$&"):t.setAttribute("id",g),g="[id='"+g+"'] ",u=l.length;while(u--)l[u]=g+dt(l[u]);m=V.test(e)&&t.parentNode||t,v=l.join(",")}if(v)try{return H.apply(n,q.call(m.querySelectorAll(v),0)),n}catch(b){}finally{f||t.removeAttribute("id")}}}return wt(e.replace(W,"$1"),t,n,r)}a=st.isXML=function(e){var t=e&&(e.ownerDocument||e).documentElement;return t?"HTML"!==t.nodeName:!1},c=st.setDocument=function(e){var n=e?e.ownerDocument||e:w;return n!==p&&9===n.nodeType&&n.documentElement?(p=n,f=n.documentElement,d=a(n),T.tagNameNoComments=at(function(e){return e.appendChild(n.createComment("")),!e.getElementsByTagName("*").length}),T.attributes=at(function(e){e.innerHTML="";var t=typeof e.lastChild.getAttribute("multiple");return"boolean"!==t&&"string"!==t}),T.getByClassName=at(function(e){return e.innerHTML="",e.getElementsByClassName&&e.getElementsByClassName("e").length?(e.lastChild.className="e",2===e.getElementsByClassName("e").length):!1}),T.getByName=at(function(e){e.id=x+0,e.innerHTML="
      ",f.insertBefore(e,f.firstChild);var t=n.getElementsByName&&n.getElementsByName(x).length===2+n.getElementsByName(x+0).length;return T.getIdNotName=!n.getElementById(x),f.removeChild(e),t}),i.attrHandle=at(function(e){return e.innerHTML="",e.firstChild&&typeof e.firstChild.getAttribute!==A&&"#"===e.firstChild.getAttribute("href")})?{}:{href:function(e){return e.getAttribute("href",2)},type:function(e){return e.getAttribute("type")}},T.getIdNotName?(i.find.ID=function(e,t){if(typeof t.getElementById!==A&&!d){var n=t.getElementById(e);return n&&n.parentNode?[n]:[]}},i.filter.ID=function(e){var t=e.replace(et,tt);return function(e){return e.getAttribute("id")===t}}):(i.find.ID=function(e,n){if(typeof n.getElementById!==A&&!d){var r=n.getElementById(e);return r?r.id===e||typeof r.getAttributeNode!==A&&r.getAttributeNode("id").value===e?[r]:t:[]}},i.filter.ID=function(e){var t=e.replace(et,tt);return function(e){var n=typeof e.getAttributeNode!==A&&e.getAttributeNode("id");return n&&n.value===t}}),i.find.TAG=T.tagNameNoComments?function(e,n){return typeof n.getElementsByTagName!==A?n.getElementsByTagName(e):t}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},i.find.NAME=T.getByName&&function(e,n){return typeof n.getElementsByName!==A?n.getElementsByName(name):t},i.find.CLASS=T.getByClassName&&function(e,n){return typeof n.getElementsByClassName===A||d?t:n.getElementsByClassName(e)},g=[],h=[":focus"],(T.qsa=rt(n.querySelectorAll))&&(at(function(e){e.innerHTML="",e.querySelectorAll("[selected]").length||h.push("\\["+_+"*(?:checked|disabled|ismap|multiple|readonly|selected|value)"),e.querySelectorAll(":checked").length||h.push(":checked")}),at(function(e){e.innerHTML="",e.querySelectorAll("[i^='']").length&&h.push("[*^$]="+_+"*(?:\"\"|'')"),e.querySelectorAll(":enabled").length||h.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),h.push(",.*:")})),(T.matchesSelector=rt(m=f.matchesSelector||f.mozMatchesSelector||f.webkitMatchesSelector||f.oMatchesSelector||f.msMatchesSelector))&&at(function(e){T.disconnectedMatch=m.call(e,"div"),m.call(e,"[s!='']:x"),g.push("!=",R)}),h=RegExp(h.join("|")),g=RegExp(g.join("|")),y=rt(f.contains)||f.compareDocumentPosition?function(e,t){var n=9===e.nodeType?e.documentElement:e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},v=f.compareDocumentPosition?function(e,t){var r;return e===t?(u=!0,0):(r=t.compareDocumentPosition&&e.compareDocumentPosition&&e.compareDocumentPosition(t))?1&r||e.parentNode&&11===e.parentNode.nodeType?e===n||y(w,e)?-1:t===n||y(w,t)?1:0:4&r?-1:1:e.compareDocumentPosition?-1:1}:function(e,t){var r,i=0,o=e.parentNode,a=t.parentNode,s=[e],l=[t];if(e===t)return u=!0,0;if(!o||!a)return e===n?-1:t===n?1:o?-1:a?1:0;if(o===a)return ut(e,t);r=e;while(r=r.parentNode)s.unshift(r);r=t;while(r=r.parentNode)l.unshift(r);while(s[i]===l[i])i++;return i?ut(s[i],l[i]):s[i]===w?-1:l[i]===w?1:0},u=!1,[0,0].sort(v),T.detectDuplicates=u,p):p},st.matches=function(e,t){return st(e,null,null,t)},st.matchesSelector=function(e,t){if((e.ownerDocument||e)!==p&&c(e),t=t.replace(Z,"='$1']"),!(!T.matchesSelector||d||g&&g.test(t)||h.test(t)))try{var n=m.call(e,t);if(n||T.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(r){}return st(t,p,null,[e]).length>0},st.contains=function(e,t){return(e.ownerDocument||e)!==p&&c(e),y(e,t)},st.attr=function(e,t){var n;return(e.ownerDocument||e)!==p&&c(e),d||(t=t.toLowerCase()),(n=i.attrHandle[t])?n(e):d||T.attributes?e.getAttribute(t):((n=e.getAttributeNode(t))||e.getAttribute(t))&&e[t]===!0?t:n&&n.specified?n.value:null},st.error=function(e){throw Error("Syntax error, unrecognized expression: "+e)},st.uniqueSort=function(e){var t,n=[],r=1,i=0;if(u=!T.detectDuplicates,e.sort(v),u){for(;t=e[r];r++)t===e[r-1]&&(i=n.push(r));while(i--)e.splice(n[i],1)}return e};function ut(e,t){var n=t&&e,r=n&&(~t.sourceIndex||j)-(~e.sourceIndex||j);if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function lt(e){return function(t){var n=t.nodeName.toLowerCase();return"input"===n&&t.type===e}}function ct(e){return function(t){var n=t.nodeName.toLowerCase();return("input"===n||"button"===n)&&t.type===e}}function pt(e){return ot(function(t){return t=+t,ot(function(n,r){var i,o=e([],n.length,t),a=o.length;while(a--)n[i=o[a]]&&(n[i]=!(r[i]=n[i]))})})}o=st.getText=function(e){var t,n="",r=0,i=e.nodeType;if(i){if(1===i||9===i||11===i){if("string"==typeof e.textContent)return e.textContent;for(e=e.firstChild;e;e=e.nextSibling)n+=o(e)}else if(3===i||4===i)return e.nodeValue}else for(;t=e[r];r++)n+=o(t);return n},i=st.selectors={cacheLength:50,createPseudo:ot,match:U,find:{},relative:{">":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(et,tt),e[3]=(e[4]||e[5]||"").replace(et,tt),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||st.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&st.error(e[0]),e},PSEUDO:function(e){var t,n=!e[5]&&e[2];return U.CHILD.test(e[0])?null:(e[4]?e[2]=e[4]:n&&z.test(n)&&(t=ft(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){return"*"===e?function(){return!0}:(e=e.replace(et,tt).toLowerCase(),function(t){return t.nodeName&&t.nodeName.toLowerCase()===e})},CLASS:function(e){var t=k[e+" "];return t||(t=RegExp("(^|"+_+")"+e+"("+_+"|$)"))&&k(e,function(e){return t.test(e.className||typeof e.getAttribute!==A&&e.getAttribute("class")||"")})},ATTR:function(e,t,n){return function(r){var i=st.attr(r,e);return null==i?"!="===t:t?(i+="","="===t?i===n:"!="===t?i!==n:"^="===t?n&&0===i.indexOf(n):"*="===t?n&&i.indexOf(n)>-1:"$="===t?n&&i.slice(-n.length)===n:"~="===t?(" "+i+" ").indexOf(n)>-1:"|="===t?i===n||i.slice(0,n.length+1)===n+"-":!1):!0}},CHILD:function(e,t,n,r,i){var o="nth"!==e.slice(0,3),a="last"!==e.slice(-4),s="of-type"===t;return 1===r&&0===i?function(e){return!!e.parentNode}:function(t,n,u){var l,c,p,f,d,h,g=o!==a?"nextSibling":"previousSibling",m=t.parentNode,y=s&&t.nodeName.toLowerCase(),v=!u&&!s;if(m){if(o){while(g){p=t;while(p=p[g])if(s?p.nodeName.toLowerCase()===y:1===p.nodeType)return!1;h=g="only"===e&&!h&&"nextSibling"}return!0}if(h=[a?m.firstChild:m.lastChild],a&&v){c=m[x]||(m[x]={}),l=c[e]||[],d=l[0]===N&&l[1],f=l[0]===N&&l[2],p=d&&m.childNodes[d];while(p=++d&&p&&p[g]||(f=d=0)||h.pop())if(1===p.nodeType&&++f&&p===t){c[e]=[N,d,f];break}}else if(v&&(l=(t[x]||(t[x]={}))[e])&&l[0]===N)f=l[1];else while(p=++d&&p&&p[g]||(f=d=0)||h.pop())if((s?p.nodeName.toLowerCase()===y:1===p.nodeType)&&++f&&(v&&((p[x]||(p[x]={}))[e]=[N,f]),p===t))break;return f-=i,f===r||0===f%r&&f/r>=0}}},PSEUDO:function(e,t){var n,r=i.pseudos[e]||i.setFilters[e.toLowerCase()]||st.error("unsupported pseudo: "+e);return r[x]?r(t):r.length>1?(n=[e,e,"",t],i.setFilters.hasOwnProperty(e.toLowerCase())?ot(function(e,n){var i,o=r(e,t),a=o.length;while(a--)i=M.call(e,o[a]),e[i]=!(n[i]=o[a])}):function(e){return r(e,0,n)}):r}},pseudos:{not:ot(function(e){var t=[],n=[],r=s(e.replace(W,"$1"));return r[x]?ot(function(e,t,n,i){var o,a=r(e,null,i,[]),s=e.length;while(s--)(o=a[s])&&(e[s]=!(t[s]=o))}):function(e,i,o){return t[0]=e,r(t,null,o,n),!n.pop()}}),has:ot(function(e){return function(t){return st(e,t).length>0}}),contains:ot(function(e){return function(t){return(t.textContent||t.innerText||o(t)).indexOf(e)>-1}}),lang:ot(function(e){return X.test(e||"")||st.error("unsupported lang: "+e),e=e.replace(et,tt).toLowerCase(),function(t){var n;do if(n=d?t.getAttribute("xml:lang")||t.getAttribute("lang"):t.lang)return n=n.toLowerCase(),n===e||0===n.indexOf(e+"-");while((t=t.parentNode)&&1===t.nodeType);return!1}}),target:function(t){var n=e.location&&e.location.hash;return n&&n.slice(1)===t.id},root:function(e){return e===f},focus:function(e){return e===p.activeElement&&(!p.hasFocus||p.hasFocus())&&!!(e.type||e.href||~e.tabIndex)},enabled:function(e){return e.disabled===!1},disabled:function(e){return e.disabled===!0},checked:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&!!e.checked||"option"===t&&!!e.selected},selected:function(e){return e.parentNode&&e.parentNode.selectedIndex,e.selected===!0},empty:function(e){for(e=e.firstChild;e;e=e.nextSibling)if(e.nodeName>"@"||3===e.nodeType||4===e.nodeType)return!1;return!0},parent:function(e){return!i.pseudos.empty(e)},header:function(e){return Q.test(e.nodeName)},input:function(e){return G.test(e.nodeName)},button:function(e){var t=e.nodeName.toLowerCase();return"input"===t&&"button"===e.type||"button"===t},text:function(e){var t;return"input"===e.nodeName.toLowerCase()&&"text"===e.type&&(null==(t=e.getAttribute("type"))||t.toLowerCase()===e.type)},first:pt(function(){return[0]}),last:pt(function(e,t){return[t-1]}),eq:pt(function(e,t,n){return[0>n?n+t:n]}),even:pt(function(e,t){var n=0;for(;t>n;n+=2)e.push(n);return e}),odd:pt(function(e,t){var n=1;for(;t>n;n+=2)e.push(n);return e}),lt:pt(function(e,t,n){var r=0>n?n+t:n;for(;--r>=0;)e.push(r);return e}),gt:pt(function(e,t,n){var r=0>n?n+t:n;for(;t>++r;)e.push(r);return e})}};for(n in{radio:!0,checkbox:!0,file:!0,password:!0,image:!0})i.pseudos[n]=lt(n);for(n in{submit:!0,reset:!0})i.pseudos[n]=ct(n);function ft(e,t){var n,r,o,a,s,u,l,c=E[e+" "];if(c)return t?0:c.slice(0);s=e,u=[],l=i.preFilter;while(s){(!n||(r=$.exec(s)))&&(r&&(s=s.slice(r[0].length)||s),u.push(o=[])),n=!1,(r=I.exec(s))&&(n=r.shift(),o.push({value:n,type:r[0].replace(W," ")}),s=s.slice(n.length));for(a in i.filter)!(r=U[a].exec(s))||l[a]&&!(r=l[a](r))||(n=r.shift(),o.push({value:n,type:a,matches:r}),s=s.slice(n.length));if(!n)break}return t?s.length:s?st.error(e):E(e,u).slice(0)}function dt(e){var t=0,n=e.length,r="";for(;n>t;t++)r+=e[t].value;return r}function ht(e,t,n){var i=t.dir,o=n&&"parentNode"===i,a=C++;return t.first?function(t,n,r){while(t=t[i])if(1===t.nodeType||o)return e(t,n,r)}:function(t,n,s){var u,l,c,p=N+" "+a;if(s){while(t=t[i])if((1===t.nodeType||o)&&e(t,n,s))return!0}else while(t=t[i])if(1===t.nodeType||o)if(c=t[x]||(t[x]={}),(l=c[i])&&l[0]===p){if((u=l[1])===!0||u===r)return u===!0}else if(l=c[i]=[p],l[1]=e(t,n,s)||r,l[1]===!0)return!0}}function gt(e){return e.length>1?function(t,n,r){var i=e.length;while(i--)if(!e[i](t,n,r))return!1;return!0}:e[0]}function mt(e,t,n,r,i){var o,a=[],s=0,u=e.length,l=null!=t;for(;u>s;s++)(o=e[s])&&(!n||n(o,r,i))&&(a.push(o),l&&t.push(s));return a}function yt(e,t,n,r,i,o){return r&&!r[x]&&(r=yt(r)),i&&!i[x]&&(i=yt(i,o)),ot(function(o,a,s,u){var l,c,p,f=[],d=[],h=a.length,g=o||xt(t||"*",s.nodeType?[s]:s,[]),m=!e||!o&&t?g:mt(g,f,e,s,u),y=n?i||(o?e:h||r)?[]:a:m;if(n&&n(m,y,s,u),r){l=mt(y,d),r(l,[],s,u),c=l.length;while(c--)(p=l[c])&&(y[d[c]]=!(m[d[c]]=p))}if(o){if(i||e){if(i){l=[],c=y.length;while(c--)(p=y[c])&&l.push(m[c]=p);i(null,y=[],l,u)}c=y.length;while(c--)(p=y[c])&&(l=i?M.call(o,p):f[c])>-1&&(o[l]=!(a[l]=p))}}else y=mt(y===a?y.splice(h,y.length):y),i?i(null,a,y,u):H.apply(a,y)})}function vt(e){var t,n,r,o=e.length,a=i.relative[e[0].type],s=a||i.relative[" "],u=a?1:0,c=ht(function(e){return e===t},s,!0),p=ht(function(e){return M.call(t,e)>-1},s,!0),f=[function(e,n,r){return!a&&(r||n!==l)||((t=n).nodeType?c(e,n,r):p(e,n,r))}];for(;o>u;u++)if(n=i.relative[e[u].type])f=[ht(gt(f),n)];else{if(n=i.filter[e[u].type].apply(null,e[u].matches),n[x]){for(r=++u;o>r;r++)if(i.relative[e[r].type])break;return yt(u>1&>(f),u>1&&dt(e.slice(0,u-1)).replace(W,"$1"),n,r>u&&vt(e.slice(u,r)),o>r&&vt(e=e.slice(r)),o>r&&dt(e))}f.push(n)}return gt(f)}function bt(e,t){var n=0,o=t.length>0,a=e.length>0,s=function(s,u,c,f,d){var h,g,m,y=[],v=0,b="0",x=s&&[],w=null!=d,T=l,C=s||a&&i.find.TAG("*",d&&u.parentNode||u),k=N+=null==T?1:Math.random()||.1;for(w&&(l=u!==p&&u,r=n);null!=(h=C[b]);b++){if(a&&h){g=0;while(m=e[g++])if(m(h,u,c)){f.push(h);break}w&&(N=k,r=++n)}o&&((h=!m&&h)&&v--,s&&x.push(h))}if(v+=b,o&&b!==v){g=0;while(m=t[g++])m(x,y,u,c);if(s){if(v>0)while(b--)x[b]||y[b]||(y[b]=L.call(f));y=mt(y)}H.apply(f,y),w&&!s&&y.length>0&&v+t.length>1&&st.uniqueSort(f)}return w&&(N=k,l=T),x};return o?ot(s):s}s=st.compile=function(e,t){var n,r=[],i=[],o=S[e+" "];if(!o){t||(t=ft(e)),n=t.length;while(n--)o=vt(t[n]),o[x]?r.push(o):i.push(o);o=S(e,bt(i,r))}return o};function xt(e,t,n){var r=0,i=t.length;for(;i>r;r++)st(e,t[r],n);return n}function wt(e,t,n,r){var o,a,u,l,c,p=ft(e);if(!r&&1===p.length){if(a=p[0]=p[0].slice(0),a.length>2&&"ID"===(u=a[0]).type&&9===t.nodeType&&!d&&i.relative[a[1].type]){if(t=i.find.ID(u.matches[0].replace(et,tt),t)[0],!t)return n;e=e.slice(a.shift().value.length)}o=U.needsContext.test(e)?0:a.length;while(o--){if(u=a[o],i.relative[l=u.type])break;if((c=i.find[l])&&(r=c(u.matches[0].replace(et,tt),V.test(a[0].type)&&t.parentNode||t))){if(a.splice(o,1),e=r.length&&dt(a),!e)return H.apply(n,q.call(r,0)),n;break}}}return s(e,p)(r,t,d,n,V.test(e)),n}i.pseudos.nth=i.pseudos.eq;function Tt(){}i.filters=Tt.prototype=i.pseudos,i.setFilters=new Tt,c(),st.attr=b.attr,b.find=st,b.expr=st.selectors,b.expr[":"]=b.expr.pseudos,b.unique=st.uniqueSort,b.text=st.getText,b.isXMLDoc=st.isXML,b.contains=st.contains}(e);var at=/Until$/,st=/^(?:parents|prev(?:Until|All))/,ut=/^.[^:#\[\.,]*$/,lt=b.expr.match.needsContext,ct={children:!0,contents:!0,next:!0,prev:!0};b.fn.extend({find:function(e){var t,n,r,i=this.length;if("string"!=typeof e)return r=this,this.pushStack(b(e).filter(function(){for(t=0;i>t;t++)if(b.contains(r[t],this))return!0}));for(n=[],t=0;i>t;t++)b.find(e,this[t],n);return n=this.pushStack(i>1?b.unique(n):n),n.selector=(this.selector?this.selector+" ":"")+e,n},has:function(e){var t,n=b(e,this),r=n.length;return this.filter(function(){for(t=0;r>t;t++)if(b.contains(this,n[t]))return!0})},not:function(e){return this.pushStack(ft(this,e,!1))},filter:function(e){return this.pushStack(ft(this,e,!0))},is:function(e){return!!e&&("string"==typeof e?lt.test(e)?b(e,this.context).index(this[0])>=0:b.filter(e,this).length>0:this.filter(e).length>0)},closest:function(e,t){var n,r=0,i=this.length,o=[],a=lt.test(e)||"string"!=typeof e?b(e,t||this.context):0;for(;i>r;r++){n=this[r];while(n&&n.ownerDocument&&n!==t&&11!==n.nodeType){if(a?a.index(n)>-1:b.find.matchesSelector(n,e)){o.push(n);break}n=n.parentNode}}return this.pushStack(o.length>1?b.unique(o):o)},index:function(e){return e?"string"==typeof e?b.inArray(this[0],b(e)):b.inArray(e.jquery?e[0]:e,this):this[0]&&this[0].parentNode?this.first().prevAll().length:-1},add:function(e,t){var n="string"==typeof e?b(e,t):b.makeArray(e&&e.nodeType?[e]:e),r=b.merge(this.get(),n);return this.pushStack(b.unique(r))},addBack:function(e){return this.add(null==e?this.prevObject:this.prevObject.filter(e))}}),b.fn.andSelf=b.fn.addBack;function pt(e,t){do e=e[t];while(e&&1!==e.nodeType);return e}b.each({parent:function(e){var t=e.parentNode;return t&&11!==t.nodeType?t:null},parents:function(e){return b.dir(e,"parentNode")},parentsUntil:function(e,t,n){return b.dir(e,"parentNode",n)},next:function(e){return pt(e,"nextSibling")},prev:function(e){return pt(e,"previousSibling")},nextAll:function(e){return b.dir(e,"nextSibling")},prevAll:function(e){return b.dir(e,"previousSibling")},nextUntil:function(e,t,n){return b.dir(e,"nextSibling",n)},prevUntil:function(e,t,n){return b.dir(e,"previousSibling",n)},siblings:function(e){return b.sibling((e.parentNode||{}).firstChild,e)},children:function(e){return b.sibling(e.firstChild)},contents:function(e){return b.nodeName(e,"iframe")?e.contentDocument||e.contentWindow.document:b.merge([],e.childNodes)}},function(e,t){b.fn[e]=function(n,r){var i=b.map(this,t,n);return at.test(e)||(r=n),r&&"string"==typeof r&&(i=b.filter(r,i)),i=this.length>1&&!ct[e]?b.unique(i):i,this.length>1&&st.test(e)&&(i=i.reverse()),this.pushStack(i)}}),b.extend({filter:function(e,t,n){return n&&(e=":not("+e+")"),1===t.length?b.find.matchesSelector(t[0],e)?[t[0]]:[]:b.find.matches(e,t)},dir:function(e,n,r){var i=[],o=e[n];while(o&&9!==o.nodeType&&(r===t||1!==o.nodeType||!b(o).is(r)))1===o.nodeType&&i.push(o),o=o[n];return i},sibling:function(e,t){var n=[];for(;e;e=e.nextSibling)1===e.nodeType&&e!==t&&n.push(e);return n}});function ft(e,t,n){if(t=t||0,b.isFunction(t))return b.grep(e,function(e,r){var i=!!t.call(e,r,e);return i===n});if(t.nodeType)return b.grep(e,function(e){return e===t===n});if("string"==typeof t){var r=b.grep(e,function(e){return 1===e.nodeType});if(ut.test(t))return b.filter(t,r,!n);t=b.filter(t,r)}return b.grep(e,function(e){return b.inArray(e,t)>=0===n})}function dt(e){var t=ht.split("|"),n=e.createDocumentFragment();if(n.createElement)while(t.length)n.createElement(t.pop());return n}var ht="abbr|article|aside|audio|bdi|canvas|data|datalist|details|figcaption|figure|footer|header|hgroup|mark|meter|nav|output|progress|section|summary|time|video",gt=/ jQuery\d+="(?:null|\d+)"/g,mt=RegExp("<(?:"+ht+")[\\s/>]","i"),yt=/^\s+/,vt=/<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi,bt=/<([\w:]+)/,xt=/
      ","
      "],tr:[2,"","
      "],col:[2,"","
      "],td:[3,"","
      "],_default:b.support.htmlSerialize?[0,"",""]:[1,"X
      ","
      "]},jt=dt(o),Dt=jt.appendChild(o.createElement("div"));At.optgroup=At.option,At.tbody=At.tfoot=At.colgroup=At.caption=At.thead,At.th=At.td,b.fn.extend({text:function(e){return b.access(this,function(e){return e===t?b.text(this):this.empty().append((this[0]&&this[0].ownerDocument||o).createTextNode(e))},null,e,arguments.length)},wrapAll:function(e){if(b.isFunction(e))return this.each(function(t){b(this).wrapAll(e.call(this,t))});if(this[0]){var t=b(e,this[0].ownerDocument).eq(0).clone(!0);this[0].parentNode&&t.insertBefore(this[0]),t.map(function(){var e=this;while(e.firstChild&&1===e.firstChild.nodeType)e=e.firstChild;return e}).append(this)}return this},wrapInner:function(e){return b.isFunction(e)?this.each(function(t){b(this).wrapInner(e.call(this,t))}):this.each(function(){var t=b(this),n=t.contents();n.length?n.wrapAll(e):t.append(e)})},wrap:function(e){var t=b.isFunction(e);return this.each(function(n){b(this).wrapAll(t?e.call(this,n):e)})},unwrap:function(){return this.parent().each(function(){b.nodeName(this,"body")||b(this).replaceWith(this.childNodes)}).end()},append:function(){return this.domManip(arguments,!0,function(e){(1===this.nodeType||11===this.nodeType||9===this.nodeType)&&this.appendChild(e)})},prepend:function(){return this.domManip(arguments,!0,function(e){(1===this.nodeType||11===this.nodeType||9===this.nodeType)&&this.insertBefore(e,this.firstChild)})},before:function(){return this.domManip(arguments,!1,function(e){this.parentNode&&this.parentNode.insertBefore(e,this)})},after:function(){return this.domManip(arguments,!1,function(e){this.parentNode&&this.parentNode.insertBefore(e,this.nextSibling)})},remove:function(e,t){var n,r=0;for(;null!=(n=this[r]);r++)(!e||b.filter(e,[n]).length>0)&&(t||1!==n.nodeType||b.cleanData(Ot(n)),n.parentNode&&(t&&b.contains(n.ownerDocument,n)&&Mt(Ot(n,"script")),n.parentNode.removeChild(n)));return this},empty:function(){var e,t=0;for(;null!=(e=this[t]);t++){1===e.nodeType&&b.cleanData(Ot(e,!1));while(e.firstChild)e.removeChild(e.firstChild);e.options&&b.nodeName(e,"select")&&(e.options.length=0)}return this},clone:function(e,t){return e=null==e?!1:e,t=null==t?e:t,this.map(function(){return b.clone(this,e,t)})},html:function(e){return b.access(this,function(e){var n=this[0]||{},r=0,i=this.length;if(e===t)return 1===n.nodeType?n.innerHTML.replace(gt,""):t;if(!("string"!=typeof e||Tt.test(e)||!b.support.htmlSerialize&&mt.test(e)||!b.support.leadingWhitespace&&yt.test(e)||At[(bt.exec(e)||["",""])[1].toLowerCase()])){e=e.replace(vt,"<$1>");try{for(;i>r;r++)n=this[r]||{},1===n.nodeType&&(b.cleanData(Ot(n,!1)),n.innerHTML=e);n=0}catch(o){}}n&&this.empty().append(e)},null,e,arguments.length)},replaceWith:function(e){var t=b.isFunction(e);return t||"string"==typeof e||(e=b(e).not(this).detach()),this.domManip([e],!0,function(e){var t=this.nextSibling,n=this.parentNode;n&&(b(this).remove(),n.insertBefore(e,t))})},detach:function(e){return this.remove(e,!0)},domManip:function(e,n,r){e=f.apply([],e);var i,o,a,s,u,l,c=0,p=this.length,d=this,h=p-1,g=e[0],m=b.isFunction(g);if(m||!(1>=p||"string"!=typeof g||b.support.checkClone)&&Ct.test(g))return this.each(function(i){var o=d.eq(i);m&&(e[0]=g.call(this,i,n?o.html():t)),o.domManip(e,n,r)});if(p&&(l=b.buildFragment(e,this[0].ownerDocument,!1,this),i=l.firstChild,1===l.childNodes.length&&(l=i),i)){for(n=n&&b.nodeName(i,"tr"),s=b.map(Ot(l,"script"),Ht),a=s.length;p>c;c++)o=l,c!==h&&(o=b.clone(o,!0,!0),a&&b.merge(s,Ot(o,"script"))),r.call(n&&b.nodeName(this[c],"table")?Lt(this[c],"tbody"):this[c],o,c);if(a)for(u=s[s.length-1].ownerDocument,b.map(s,qt),c=0;a>c;c++)o=s[c],kt.test(o.type||"")&&!b._data(o,"globalEval")&&b.contains(u,o)&&(o.src?b.ajax({url:o.src,type:"GET",dataType:"script",async:!1,global:!1,"throws":!0}):b.globalEval((o.text||o.textContent||o.innerHTML||"").replace(St,"")));l=i=null}return this}});function Lt(e,t){return e.getElementsByTagName(t)[0]||e.appendChild(e.ownerDocument.createElement(t))}function Ht(e){var t=e.getAttributeNode("type");return e.type=(t&&t.specified)+"/"+e.type,e}function qt(e){var t=Et.exec(e.type);return t?e.type=t[1]:e.removeAttribute("type"),e}function Mt(e,t){var n,r=0;for(;null!=(n=e[r]);r++)b._data(n,"globalEval",!t||b._data(t[r],"globalEval"))}function _t(e,t){if(1===t.nodeType&&b.hasData(e)){var n,r,i,o=b._data(e),a=b._data(t,o),s=o.events;if(s){delete a.handle,a.events={};for(n in s)for(r=0,i=s[n].length;i>r;r++)b.event.add(t,n,s[n][r])}a.data&&(a.data=b.extend({},a.data))}}function Ft(e,t){var n,r,i;if(1===t.nodeType){if(n=t.nodeName.toLowerCase(),!b.support.noCloneEvent&&t[b.expando]){i=b._data(t);for(r in i.events)b.removeEvent(t,r,i.handle);t.removeAttribute(b.expando)}"script"===n&&t.text!==e.text?(Ht(t).text=e.text,qt(t)):"object"===n?(t.parentNode&&(t.outerHTML=e.outerHTML),b.support.html5Clone&&e.innerHTML&&!b.trim(t.innerHTML)&&(t.innerHTML=e.innerHTML)):"input"===n&&Nt.test(e.type)?(t.defaultChecked=t.checked=e.checked,t.value!==e.value&&(t.value=e.value)):"option"===n?t.defaultSelected=t.selected=e.defaultSelected:("input"===n||"textarea"===n)&&(t.defaultValue=e.defaultValue)}}b.each({appendTo:"append",prependTo:"prepend",insertBefore:"before",insertAfter:"after",replaceAll:"replaceWith"},function(e,t){b.fn[e]=function(e){var n,r=0,i=[],o=b(e),a=o.length-1;for(;a>=r;r++)n=r===a?this:this.clone(!0),b(o[r])[t](n),d.apply(i,n.get());return this.pushStack(i)}});function Ot(e,n){var r,o,a=0,s=typeof e.getElementsByTagName!==i?e.getElementsByTagName(n||"*"):typeof e.querySelectorAll!==i?e.querySelectorAll(n||"*"):t;if(!s)for(s=[],r=e.childNodes||e;null!=(o=r[a]);a++)!n||b.nodeName(o,n)?s.push(o):b.merge(s,Ot(o,n));return n===t||n&&b.nodeName(e,n)?b.merge([e],s):s}function Bt(e){Nt.test(e.type)&&(e.defaultChecked=e.checked)}b.extend({clone:function(e,t,n){var r,i,o,a,s,u=b.contains(e.ownerDocument,e);if(b.support.html5Clone||b.isXMLDoc(e)||!mt.test("<"+e.nodeName+">")?o=e.cloneNode(!0):(Dt.innerHTML=e.outerHTML,Dt.removeChild(o=Dt.firstChild)),!(b.support.noCloneEvent&&b.support.noCloneChecked||1!==e.nodeType&&11!==e.nodeType||b.isXMLDoc(e)))for(r=Ot(o),s=Ot(e),a=0;null!=(i=s[a]);++a)r[a]&&Ft(i,r[a]);if(t)if(n)for(s=s||Ot(e),r=r||Ot(o),a=0;null!=(i=s[a]);a++)_t(i,r[a]);else _t(e,o);return r=Ot(o,"script"),r.length>0&&Mt(r,!u&&Ot(e,"script")),r=s=i=null,o},buildFragment:function(e,t,n,r){var i,o,a,s,u,l,c,p=e.length,f=dt(t),d=[],h=0;for(;p>h;h++)if(o=e[h],o||0===o)if("object"===b.type(o))b.merge(d,o.nodeType?[o]:o);else if(wt.test(o)){s=s||f.appendChild(t.createElement("div")),u=(bt.exec(o)||["",""])[1].toLowerCase(),c=At[u]||At._default,s.innerHTML=c[1]+o.replace(vt,"<$1>")+c[2],i=c[0];while(i--)s=s.lastChild;if(!b.support.leadingWhitespace&&yt.test(o)&&d.push(t.createTextNode(yt.exec(o)[0])),!b.support.tbody){o="table"!==u||xt.test(o)?""!==c[1]||xt.test(o)?0:s:s.firstChild,i=o&&o.childNodes.length;while(i--)b.nodeName(l=o.childNodes[i],"tbody")&&!l.childNodes.length&&o.removeChild(l) +}b.merge(d,s.childNodes),s.textContent="";while(s.firstChild)s.removeChild(s.firstChild);s=f.lastChild}else d.push(t.createTextNode(o));s&&f.removeChild(s),b.support.appendChecked||b.grep(Ot(d,"input"),Bt),h=0;while(o=d[h++])if((!r||-1===b.inArray(o,r))&&(a=b.contains(o.ownerDocument,o),s=Ot(f.appendChild(o),"script"),a&&Mt(s),n)){i=0;while(o=s[i++])kt.test(o.type||"")&&n.push(o)}return s=null,f},cleanData:function(e,t){var n,r,o,a,s=0,u=b.expando,l=b.cache,p=b.support.deleteExpando,f=b.event.special;for(;null!=(n=e[s]);s++)if((t||b.acceptData(n))&&(o=n[u],a=o&&l[o])){if(a.events)for(r in a.events)f[r]?b.event.remove(n,r):b.removeEvent(n,r,a.handle);l[o]&&(delete l[o],p?delete n[u]:typeof n.removeAttribute!==i?n.removeAttribute(u):n[u]=null,c.push(o))}}});var Pt,Rt,Wt,$t=/alpha\([^)]*\)/i,It=/opacity\s*=\s*([^)]*)/,zt=/^(top|right|bottom|left)$/,Xt=/^(none|table(?!-c[ea]).+)/,Ut=/^margin/,Vt=RegExp("^("+x+")(.*)$","i"),Yt=RegExp("^("+x+")(?!px)[a-z%]+$","i"),Jt=RegExp("^([+-])=("+x+")","i"),Gt={BODY:"block"},Qt={position:"absolute",visibility:"hidden",display:"block"},Kt={letterSpacing:0,fontWeight:400},Zt=["Top","Right","Bottom","Left"],en=["Webkit","O","Moz","ms"];function tn(e,t){if(t in e)return t;var n=t.charAt(0).toUpperCase()+t.slice(1),r=t,i=en.length;while(i--)if(t=en[i]+n,t in e)return t;return r}function nn(e,t){return e=t||e,"none"===b.css(e,"display")||!b.contains(e.ownerDocument,e)}function rn(e,t){var n,r,i,o=[],a=0,s=e.length;for(;s>a;a++)r=e[a],r.style&&(o[a]=b._data(r,"olddisplay"),n=r.style.display,t?(o[a]||"none"!==n||(r.style.display=""),""===r.style.display&&nn(r)&&(o[a]=b._data(r,"olddisplay",un(r.nodeName)))):o[a]||(i=nn(r),(n&&"none"!==n||!i)&&b._data(r,"olddisplay",i?n:b.css(r,"display"))));for(a=0;s>a;a++)r=e[a],r.style&&(t&&"none"!==r.style.display&&""!==r.style.display||(r.style.display=t?o[a]||"":"none"));return e}b.fn.extend({css:function(e,n){return b.access(this,function(e,n,r){var i,o,a={},s=0;if(b.isArray(n)){for(o=Rt(e),i=n.length;i>s;s++)a[n[s]]=b.css(e,n[s],!1,o);return a}return r!==t?b.style(e,n,r):b.css(e,n)},e,n,arguments.length>1)},show:function(){return rn(this,!0)},hide:function(){return rn(this)},toggle:function(e){var t="boolean"==typeof e;return this.each(function(){(t?e:nn(this))?b(this).show():b(this).hide()})}}),b.extend({cssHooks:{opacity:{get:function(e,t){if(t){var n=Wt(e,"opacity");return""===n?"1":n}}}},cssNumber:{columnCount:!0,fillOpacity:!0,fontWeight:!0,lineHeight:!0,opacity:!0,orphans:!0,widows:!0,zIndex:!0,zoom:!0},cssProps:{"float":b.support.cssFloat?"cssFloat":"styleFloat"},style:function(e,n,r,i){if(e&&3!==e.nodeType&&8!==e.nodeType&&e.style){var o,a,s,u=b.camelCase(n),l=e.style;if(n=b.cssProps[u]||(b.cssProps[u]=tn(l,u)),s=b.cssHooks[n]||b.cssHooks[u],r===t)return s&&"get"in s&&(o=s.get(e,!1,i))!==t?o:l[n];if(a=typeof r,"string"===a&&(o=Jt.exec(r))&&(r=(o[1]+1)*o[2]+parseFloat(b.css(e,n)),a="number"),!(null==r||"number"===a&&isNaN(r)||("number"!==a||b.cssNumber[u]||(r+="px"),b.support.clearCloneStyle||""!==r||0!==n.indexOf("background")||(l[n]="inherit"),s&&"set"in s&&(r=s.set(e,r,i))===t)))try{l[n]=r}catch(c){}}},css:function(e,n,r,i){var o,a,s,u=b.camelCase(n);return n=b.cssProps[u]||(b.cssProps[u]=tn(e.style,u)),s=b.cssHooks[n]||b.cssHooks[u],s&&"get"in s&&(a=s.get(e,!0,r)),a===t&&(a=Wt(e,n,i)),"normal"===a&&n in Kt&&(a=Kt[n]),""===r||r?(o=parseFloat(a),r===!0||b.isNumeric(o)?o||0:a):a},swap:function(e,t,n,r){var i,o,a={};for(o in t)a[o]=e.style[o],e.style[o]=t[o];i=n.apply(e,r||[]);for(o in t)e.style[o]=a[o];return i}}),e.getComputedStyle?(Rt=function(t){return e.getComputedStyle(t,null)},Wt=function(e,n,r){var i,o,a,s=r||Rt(e),u=s?s.getPropertyValue(n)||s[n]:t,l=e.style;return s&&(""!==u||b.contains(e.ownerDocument,e)||(u=b.style(e,n)),Yt.test(u)&&Ut.test(n)&&(i=l.width,o=l.minWidth,a=l.maxWidth,l.minWidth=l.maxWidth=l.width=u,u=s.width,l.width=i,l.minWidth=o,l.maxWidth=a)),u}):o.documentElement.currentStyle&&(Rt=function(e){return e.currentStyle},Wt=function(e,n,r){var i,o,a,s=r||Rt(e),u=s?s[n]:t,l=e.style;return null==u&&l&&l[n]&&(u=l[n]),Yt.test(u)&&!zt.test(n)&&(i=l.left,o=e.runtimeStyle,a=o&&o.left,a&&(o.left=e.currentStyle.left),l.left="fontSize"===n?"1em":u,u=l.pixelLeft+"px",l.left=i,a&&(o.left=a)),""===u?"auto":u});function on(e,t,n){var r=Vt.exec(t);return r?Math.max(0,r[1]-(n||0))+(r[2]||"px"):t}function an(e,t,n,r,i){var o=n===(r?"border":"content")?4:"width"===t?1:0,a=0;for(;4>o;o+=2)"margin"===n&&(a+=b.css(e,n+Zt[o],!0,i)),r?("content"===n&&(a-=b.css(e,"padding"+Zt[o],!0,i)),"margin"!==n&&(a-=b.css(e,"border"+Zt[o]+"Width",!0,i))):(a+=b.css(e,"padding"+Zt[o],!0,i),"padding"!==n&&(a+=b.css(e,"border"+Zt[o]+"Width",!0,i)));return a}function sn(e,t,n){var r=!0,i="width"===t?e.offsetWidth:e.offsetHeight,o=Rt(e),a=b.support.boxSizing&&"border-box"===b.css(e,"boxSizing",!1,o);if(0>=i||null==i){if(i=Wt(e,t,o),(0>i||null==i)&&(i=e.style[t]),Yt.test(i))return i;r=a&&(b.support.boxSizingReliable||i===e.style[t]),i=parseFloat(i)||0}return i+an(e,t,n||(a?"border":"content"),r,o)+"px"}function un(e){var t=o,n=Gt[e];return n||(n=ln(e,t),"none"!==n&&n||(Pt=(Pt||b("' + + ''; + + return this.fileProvider.writeFile(indexPath, html); + }).then((fileEntry) => { + return fileEntry.toURL(); + }); + }); + } + + /** + * Delete cached assets from DB and filesystem. + * + * @param libraryId Library identifier. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + protected deleteCachedAssets(libraryId: number, siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const db = site.getDb(); + + // Get all the hashes that use this library. + return db.getRecords(this.LIBRARIES_CACHEDASSETS_TABLE, {libraryid: libraryId}).then((entries) => { + // Delete the files with these hashes. + const promises = [], + hashes = []; + + entries.forEach((entry) => { + hashes.push(entry.hash); + + const cachedAssetsFolder = this.getCachedAssetsFolderPath(entry.foldername, site.getId()); + + ['js', 'css'].forEach((type) => { + const path = this.textUtils.concatenatePaths(cachedAssetsFolder, entry.hash + '.' + type); + + promises.push(this.fileProvider.removeFile(path).catch(() => { + // Ignore errors, maybe there's no cached asset of this type. + })); + }); + }); + + return Promise.all(promises).then(() => { + return db.deleteRecordsList(this.LIBRARIES_CACHEDASSETS_TABLE, 'hash', hashes); + }); + }); + }); + } + + /** + * Delete all package content data. + * + * @param fileUrl File URL. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + deleteContentByUrl(fileUrl: string, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.getContentDataByUrl(fileUrl, siteId).then((data) => { + const promises = []; + + promises.push(this.deleteContentData(data.id, siteId)); + + promises.push(this.deleteContentFolder(data.foldername, siteId)); + + return this.utils.allPromises(promises); + }); + } + + /** + * Delete content data from DB. + * + * @param id Content ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + deleteContentData(id: number, siteId?: string): Promise { + const promises = []; + + // Delete the content data. + promises.push(this.sitesProvider.getSiteDb(siteId).then((db) => { + return db.deleteRecords(this.CONTENT_TABLE, {id: id}); + })); + + // Remove content library dependencies. + promises.push(this.deleteLibraryUsage(id, siteId)); + + return Promise.all(promises); + } + + /** + * Deletes a content folder from the file system. + * + * @param folderName Folder name of the content. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + deleteContentFolder(folderName: string, siteId?: string): Promise { + return this.fileProvider.removeDir(this.getContentFolderPath(folderName, siteId)); + } + + /** + * Delete content indexes from filesystem. + * + * @param libraryId Library identifier. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + protected deleteContentIndexesForLibrary(libraryId: number, siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const db = site.getDb(); + + // Get the folder names of all the packages that use this library. + const query = 'SELECT DISTINCT hc.foldername ' + + 'FROM ' + this.CONTENTS_LIBRARIES_TABLE + ' hcl ' + + 'JOIN ' + this.CONTENT_TABLE + ' hc ON hcl.h5pid = hc.id ' + + 'WHERE hcl.libraryid = ?', + queryArgs = []; + + queryArgs.push(libraryId); + + return db.execute(query, queryArgs).then((result) => { + const promises = []; + + for (let i = 0; i < result.rows.length; i++) { + const entry = result.rows.item(i); + + // Delete the index.html file. + promises.push(this.fileProvider.removeFile(this.getContentIndexPath(entry.foldername, site.getId())) + .catch(() => { + // Ignore errors. + })); + } + + return Promise.all(promises); + }); + }); + } + + /** + * Delete library data from DB. + * + * @param id Library ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + deleteLibraryData(id: number, siteId?: string): Promise { + return this.sitesProvider.getSiteDb(siteId).then((db) => { + return db.deleteRecords(this.LIBRARIES_TABLE, {id: id}); + }); + } + + /** + * Delete all dependencies belonging to given library. + * + * @param libraryId Library ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + deleteLibraryDependencies(libraryId: number, siteId?: string): Promise { + return this.sitesProvider.getSiteDb(siteId).then((db) => { + return db.deleteRecords(this.LIBRARY_DEPENDENCIES_TABLE, {libraryid: libraryId}); + }); + } + + /** + * Deletes a library from the file system. + * + * @param libraryData The library data. + * @param folderName Folder name. If not provided, it will be calculated. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + deleteLibraryFolder(libraryData: any, folderName?: string, siteId?: string): Promise { + return this.fileProvider.removeDir(this.getLibraryFolderPath(libraryData, siteId, folderName)); + } + + /** + * Delete what libraries a content item is using. + * + * @param id Package ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + deleteLibraryUsage(id: number, siteId?: string): Promise { + return this.sitesProvider.getSiteDb(siteId).then((db) => { + return db.deleteRecords(this.CONTENTS_LIBRARIES_TABLE, {h5pid: id}); + }); + } + + /** + * Extract an H5P file. Some of this code was copied from the isValidPackage function in Moodle's H5PValidator. + * This function won't validate most things because it should've been done by the server already. + * + * @param fileUrl The file URL used to download the file. + * @param file The file entry of the downloaded file. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + extractH5PFile(fileUrl: string, file: FileEntry, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // Unzip the file. + const folderName = this.mimeUtils.removeExtension(file.name), + destFolder = this.textUtils.concatenatePaths(CoreFileProvider.TMPFOLDER, 'h5p/' + folderName); + + // Unzip the file. + return this.fileProvider.unzipFile(file.toURL(), destFolder).then(() => { + // Read the contents of the unzipped dir. + return this.fileProvider.getDirectoryContents(destFolder); + }).then((contents) => { + return this.processH5PFiles(destFolder, contents).then((data) => { + const content: any = {}; + + // Save the libraries that were processed. + return this.saveLibraries(data.librariesJsonData, folderName, siteId).then(() => { + // Now treat contents. + + // Find main library version + for (const i in data.mainJsonData.preloadedDependencies) { + const dependency = data.mainJsonData.preloadedDependencies[i]; + + if (dependency.machineName === data.mainJsonData.mainLibrary) { + return this.getLibraryIdByData(dependency).then((id) => { + dependency.libraryId = id; + content.library = dependency; + }); + } + } + }).then(() => { + // Save the content data in DB. + content.params = JSON.stringify(data.contentJsonData); + + return this.saveContentData(content, folderName, fileUrl, siteId); + }).then(() => { + // Save the content files in their right place. + const contentPath = this.textUtils.concatenatePaths(destFolder, 'content'); + + return this.saveContentInFS(contentPath, folderName, siteId).catch((error) => { + // An error occurred, delete the DB data because the content data has been deleted. + return this.deleteContentData(content.id, siteId).catch(() => { + // Ignore errors. + }).then(() => { + return Promise.reject(error); + }); + }); + }).then(() => { + // Create the content player. + + return this.loadContentData(content.id, undefined, siteId).then((contentData) => { + const embedType = this.h5pUtils.determineEmbedType(contentData.embedType, contentData.library.embedTypes); + + return this.createContentIndex(content.id, fileUrl, contentData, embedType, siteId); + }); + }).finally(() => { + // Remove tmp folder. + return this.fileProvider.removeDir(destFolder).catch(() => { + // Ignore errors, it will be deleted eventually. + }); + }); + }); + }); + } + + /** + * Filter content run parameters and rebuild content dependency cache. + * + * @param content Content data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the filtered params, resolved with null if error. + */ + filterParameters(content: CoreH5PContentData, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + if (content.filtered) { + return Promise.resolve(content.filtered); + } + + if (typeof content.library == 'undefined' || typeof content.params == 'undefined') { + return Promise.resolve(null); + } + + const params = { + library: this.libraryToString(content.library), + params: this.textUtils.parseJSON(content.params, false) + }; + + if (!params.params) { + return null; + } + + const validator = new CoreH5PContentValidator(this, this.h5pUtils, this.textUtils, this.utils, this.translate, siteId); + + // Validate the main library and its dependencies. + return validator.validateLibrary(params, {options: [params.library]}).then(() => { + + // Handle addons. + return this.loadAddons(siteId); + }).then((addons) => { + // Validate addons. Use a chain of promises to calculate the weight properly. + let promise = Promise.resolve(); + + addons.forEach((addon) => { + const addTo = addon.addTo; + + if (addTo && addTo.content && addTo.content.types && addTo.content.types.length) { + for (let i = 0; i < addTo.content.types.length; i++) { + const type = addTo.content.types[i]; + + if (type && type.text && type.text.regex && + this.h5pUtils.textAddonMatches(params.params, type.text.regex)) { + + promise = promise.then(() => { + return validator.addon(addon); + }); + + // An addon shall only be added once. + break; + } + } + } + }); + + return promise; + }).then(() => { + // Update content dependencies. + content.dependencies = validator.getDependencies(); + + const paramsStr = JSON.stringify(params.params); + + // Sometimes the parameters are filtered before content has been created + if (content.id) { + // Update library usage. + return this.deleteLibraryUsage(content.id, siteId).catch(() => { + // Ignore errors. + }).then(() => { + return this.saveLibraryUsage(content.id, content.dependencies, siteId); + }).then(() => { + if (!content.slug) { + content.slug = this.h5pUtils.slugify(content.title); + } + + // Cache. + return this.updateContentFields(content.id, {filtered: paramsStr}, siteId).then(() => { + return paramsStr; + }); + }); + } + + return paramsStr; + }).catch(() => { + return null; + }); + } + + /** + * Recursive. Goes through the dependency tree for the given library and + * adds all the dependencies to the given array in a flat format. + * + * @param dependencies Object where to save the dependencies. + * @param library The library to find all dependencies for. + * @param nextWeight An integer determining the order of the libraries when they are loaded. + * @param editor Used internally to force all preloaded sub dependencies of an editor dependency to be editor dependencies. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the next weight. + */ + findLibraryDependencies(dependencies: {[key: string]: CoreH5PContentDepsTreeDependency}, + library: CoreH5PLibraryData | CoreH5PLibraryAddonData, nextWeight: number = 1, editor: boolean = false, + siteId?: string): Promise { + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + let promise = Promise.resolve(); // We need to create a chain of promises to calculate the weight properly. + + ['dynamic', 'preloaded', 'editor'].forEach((type) => { + const property = type + 'Dependencies'; + + if (!library[property]) { + return; // Skip, no such dependencies. + } + + if (type === 'preloaded' && editor) { + // All preloaded dependencies of an editor library is set to editor. + type = 'editor'; + } + + library[property].forEach((dependency: CoreH5PLibraryBasicData) => { + + promise = promise.then(() => { + const dependencyKey = type + '-' + dependency.machineName; + if (dependencies[dependencyKey]) { + return; // Skip, already have this. + } + + // Get the dependency library data and its subdependencies. + return this.loadLibrary(dependency.machineName, dependency.majorVersion, dependency.minorVersion, siteId) + .then((dependencyLibrary) => { + + dependencies[dependencyKey] = { + library: dependencyLibrary, + type: type + }; + + // Get all its subdependencies. + return this.findLibraryDependencies(dependencies, dependencyLibrary, nextWeight, type === 'editor', siteId); + }).then((weight) => { + nextWeight = weight; + dependencies[dependencyKey].weight = nextWeight++; + }); + }); + }); + }); + + return promise.then(() => { + return nextWeight; + }); + } + + /** + * Validate and fix display options, updating them if needed. + * + * @param displayOptions The display options to validate. + * @param id Package ID. + */ + fixDisplayOptions(displayOptions: CoreH5PDisplayOptions, id: number): CoreH5PDisplayOptions { + + // Never allow downloading in the app. + displayOptions[CoreH5PProvider.DISPLAY_OPTION_DOWNLOAD] = false; + + // Embed - force setting it if always on or always off. In web, this is done when storing in DB. + const embed = this.getOption(CoreH5PProvider.DISPLAY_OPTION_EMBED, CoreH5PDisplayOptionBehaviour.ALWAYS_SHOW); + if (embed == CoreH5PDisplayOptionBehaviour.ALWAYS_SHOW || embed == CoreH5PDisplayOptionBehaviour.NEVER_SHOW) { + displayOptions[CoreH5PProvider.DISPLAY_OPTION_EMBED] = (embed == CoreH5PDisplayOptionBehaviour.ALWAYS_SHOW); + } + + if (!this.getOption(CoreH5PProvider.DISPLAY_OPTION_FRAME, true)) { + displayOptions[CoreH5PProvider.DISPLAY_OPTION_FRAME] = false; + } else { + displayOptions[CoreH5PProvider.DISPLAY_OPTION_EMBED] = this.setDisplayOptionOverrides( + CoreH5PProvider.DISPLAY_OPTION_EMBED, CoreH5PPermission.EMBED_H5P, id, + displayOptions[CoreH5PProvider.DISPLAY_OPTION_EMBED]); + + if (this.getOption(CoreH5PProvider.DISPLAY_OPTION_COPYRIGHT, true) == false) { + displayOptions[CoreH5PProvider.DISPLAY_OPTION_COPYRIGHT] = false; + } + } + + displayOptions[CoreH5PProvider.DISPLAY_OPTION_COPY] = this.hasPermission(CoreH5PPermission.COPY_H5P, id); + + return displayOptions; + } + + /** + * Get the assets of a package. + * + * @param id Content id. + * @param content Content data. + * @param embedType Embed type. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the assets. + */ + protected getAssets(id: number, content: CoreH5PContentData, embedType: string, siteId?: string) + : Promise<{settings: any, cssRequires: string[], jsRequires: string[]}> { + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const cssRequires = [], + jsRequires = [], + contentId = this.getContentId(id); + let settings; + + return this.getCoreSettings(id, siteId).then((coreSettings) => { + settings = coreSettings; + + settings.core = { + styles: [], + scripts: [] + }; + settings.loadedJs = []; + settings.loadedCss = []; + + const libUrl = this.getCoreH5PPath(), + relPath = this.urlUtils.removeProtocolAndWWW(libUrl); + + // Add core stylesheets. + CoreH5PProvider.STYLES.forEach((style) => { + settings.core.styles.push(relPath + style); + cssRequires.push(libUrl + style); + }); + + // Add core JavaScript. + this.getScripts().forEach((script) => { + settings.core.scripts.push(script); + jsRequires.push(script); + }); + + /* The filterParameters function should be called before getting the dependency files because it rebuilds content + dependency cache. */ + return this.filterParameters(content, siteId); + }).then((params) => { + settings.contents = settings.contents || {}; + settings.contents[contentId] = settings.contents[contentId] || {}; + settings.contents[contentId].jsonContent = params; + + return this.getContentDependencyFiles(id, content.folderName, siteId); + }).then((files) => { + + // H5P checks the embedType in here, but we'll always use iframe so there's no need to do it. + // JavaScripts and stylesheets will be loaded through h5p.js. + settings.contents[contentId].scripts = this.h5pUtils.getAssetsUrls(files.scripts); + settings.contents[contentId].styles = this.h5pUtils.getAssetsUrls(files.styles); + + return { + settings: settings, + cssRequires: cssRequires, + jsRequires: jsRequires + }; + }); + } + + /** + * Will check if there are cache assets available for content. + * + * @param key Hashed key for cached asset + * @param folderName Name of the folder of the H5P package. + * @param siteId The site ID. + * @return Promise resolved with the files. + */ + getCachedAssets(key: string, folderName: string, siteId: string) + : Promise<{scripts?: CoreH5PDependencyAsset[], styles?: CoreH5PDependencyAsset[]}> { + + const files: {scripts?: CoreH5PDependencyAsset[], styles?: CoreH5PDependencyAsset[]} = {}, + promises = [], + cachedAssetsName = this.getCachedAssetsFolderName(), + jsPath = this.textUtils.concatenatePaths(cachedAssetsName, key + '.js'), + cssPath = this.textUtils.concatenatePaths(cachedAssetsName, key + '.css'); + let found = false; + + promises.push(this.fileProvider.getFileSize(jsPath).then((size) => { + if (size > 0) { + found = true; + files.scripts = [ + { + path: jsPath, + version: '' + } + ]; + } + }).catch(() => { + // Not found. + })); + + promises.push(this.fileProvider.getFileSize(cssPath).then((size) => { + if (size > 0) { + found = true; + files.styles = [ + { + path: cssPath, + version: '' + } + ]; + } + }).catch(() => { + // Not found. + })); + + return Promise.all(promises).then(() => { + return found ? files : null; + }); + } + + /** + * Get folder name of the content cached assets. + * + * @return Name. + */ + getCachedAssetsFolderName(): string { + return 'cachedassets'; + } + + /** + * Get relative path to a content cached assets. + * + * @param folderName Name of the folder of the content the assets belong to. + * @param siteId Site ID. + * @return Path. + */ + getCachedAssetsFolderPath(folderName: string, siteId: string): string { + return this.textUtils.concatenatePaths(this.getContentFolderPath(folderName, siteId), this.getCachedAssetsFolderName()); + } + + /** + * Get the identifier for the H5P content. This identifier is different than the ID stored in the DB. + * + * @param id Package ID. + * @return Content identifier. + */ + protected getContentId(id: number): string { + return 'cid-' + id; + } + + /** + * Get conent data from DB. + * + * @param id Content ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the content data. + */ + protected getContentData(id: number, siteId?: string): Promise { + return this.sitesProvider.getSiteDb(siteId).then((db) => { + return db.getRecord(this.CONTENT_TABLE, {id: id}); + }); + } + + /** + * Get conent data from DB. + * + * @param fileUrl H5P file URL. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the content data. + */ + protected getContentDataByUrl(fileUrl: string, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + const db = site.getDb(); + + // Try to use the folder name, it should be more reliable than the URL. + return this.getContentFolderNameByUrl(fileUrl, site.getId()).then((folderName) => { + + return db.getRecord(this.CONTENT_TABLE, {foldername: folderName}); + }, () => { + // Cannot get folder name, the h5p file was probably deleted. Just use the URL. + return db.getRecord(this.CONTENT_TABLE, {fileurl: fileUrl}); + }); + }); + } + + /** + * Get a package content path. + * + * @param folderName Name of the folder of the H5P package. + * @param siteId The site ID. + * @return Folder path. + */ + getContentFolderPath(folderName: string, siteId: string): string { + return this.textUtils.concatenatePaths(this.getExternalH5PFolderPath(siteId), 'packages/' + folderName + '/content'); + } + + /** + * Get the content index file. + * + * @param fileUrl URL of the H5P package. + * @param urlParams URL params. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the file URL if exists, rejected otherwise. + */ + getContentIndexFileUrl(fileUrl: string, urlParams?: {[name: string]: string}, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.getContentFolderNameByUrl(fileUrl, siteId).then((folderName) => { + return this.fileProvider.getFile(this.getContentIndexPath(folderName, siteId)); + }).then((file) => { + return file.toURL(); + }).then((url) => { + // Add display options to the URL. + return this.getContentDataByUrl(fileUrl, siteId).then((data) => { + const options = this.fixDisplayOptions(this.getDisplayOptionsFromUrlParams(urlParams), data.id); + + return this.urlUtils.addParamsToUrl(url, options, undefined, true); + }); + }); + + } + + /** + * Get the path to a content index. + * + * @param folderName Name of the folder of the H5P package. + * @param siteId The site ID. + * @return Folder path. + */ + getContentIndexPath(folderName: string, siteId: string): string { + return this.textUtils.concatenatePaths(this.getContentFolderPath(folderName, siteId), 'index.html'); + } + + /** + * Get a content folder name given the package URL. + * + * @param fileUrl Package URL. + * @param siteId Site ID. + * @return Promise resolved with the folder name. + */ + getContentFolderNameByUrl(fileUrl: string, siteId: string): Promise { + return this.filepoolProvider.getFilePathByUrl(siteId, fileUrl).then((path) => { + + const fileAndDir = this.fileProvider.getFileAndDirectoryFromPath(path); + + return this.mimeUtils.removeExtension(fileAndDir.name); + }); + } + + /** + * Get the path to the folder that contains the H5P core libraries. + * + * @return Folder path. + */ + getCoreH5PPath(): string { + return this.textUtils.concatenatePaths(this.fileProvider.getWWWPath(), '/h5p/'); + } + + /** + * Get the settings needed by the H5P library. + * + * @param id The H5P content ID. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the settings. + */ + getCoreSettings(id: number, siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + + const basePath = this.fileProvider.getBasePathInstant(), + ajaxPaths: any = {}; + ajaxPaths.xAPIResult = ''; + ajaxPaths.contentUserData = ''; + + return { + baseUrl: this.fileProvider.getWWWPath(), + url: this.textUtils.concatenatePaths(basePath, this.getExternalH5PFolderPath(site.getId())), + urlLibraries: this.textUtils.concatenatePaths(basePath, this.getLibrariesFolderPath(site.getId())), + postUserStatistics: false, + ajax: ajaxPaths, + saveFreq: false, + siteUrl: site.getURL(), + l10n: { + H5P: this.h5pUtils.getLocalization() + }, + user: [], + hubIsEnabled: false, + reportingIsEnabled: false, + crossorigin: null, + libraryConfig: null, + pluginCacheBuster: '', + libraryUrl: this.textUtils.concatenatePaths(this.getCoreH5PPath(), 'js') + }; + }); + } + + /** + * Finds library dependencies files of a certain package. + * + * @param id Content id. + * @param folderName Name of the folder of the content. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the files. + */ + protected getContentDependencyFiles(id: number, folderName: string, siteId?: string) + : Promise<{scripts: CoreH5PDependencyAsset[], styles: CoreH5PDependencyAsset[]}> { + + return this.loadContentDependencies(id, 'preloaded', siteId).then((dependencies) => { + return this.getDependenciesFiles(dependencies, folderName, this.getExternalH5PFolderPath(siteId), siteId); + }); + } + + /** + * Get all dependency assets of the given type. + * + * @param dependency The dependency. + * @param type Type of assets to get. + * @param assets Array where to store the assets. + * @param prefix Make paths relative to another dir. + */ + protected getDependencyAssets(dependency: CoreH5PContentDependencyData, type: string, assets: CoreH5PDependencyAsset[], + prefix: string = ''): void { + + // Check if dependency has any files of this type + if (!dependency[type] || dependency[type][0] === '') { + return; + } + + // Check if we should skip CSS. + if (type === 'preloadedCss' && this.utils.isTrueOrOne(dependency.dropCss)) { + return; + } + + for (const key in dependency[type]) { + const file = dependency[type][key]; + + assets.push({ + path: prefix + '/' + dependency.path + '/' + (typeof file != 'string' ? file.path : file).trim(), + version: dependency.version + }); + } + } + + /** + * Return file paths for all dependencies files. + * + * @param dependencies The dependencies to get the files. + * @param folderName Name of the folder of the content. + * @param prefix Make paths relative to another dir. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the files. + */ + protected getDependenciesFiles(dependencies: {[machineName: string]: CoreH5PContentDependencyData}, folderName: string, + prefix: string = '', siteId?: string): Promise<{scripts: CoreH5PDependencyAsset[], styles: CoreH5PDependencyAsset[]}> { + + // Build files list for assets. + const files = { + scripts: [], + styles: [] + }; + + // Avoid caching empty files. + if (!Object.keys(dependencies).length) { + return Promise.resolve(files); + } + + let promise, + cachedAssetsHash; + + if (this.aggregateAssets) { + // Get aggregated files for assets. + cachedAssetsHash = this.h5pUtils.getDependenciesHash(dependencies); + + promise = this.getCachedAssets(cachedAssetsHash, folderName, siteId); + } else { + promise = Promise.resolve(null); + } + + return promise.then((cachedAssets) => { + if (cachedAssets) { + // Cached assets found, return them. + return Object.assign(files, cachedAssets); + } + + // No cached assets, use content dependencies. + for (const key in dependencies) { + const dependency = dependencies[key]; + + if (!dependency.path) { + dependency.path = this.getDependencyPath(dependency); + dependency.preloadedJs = ( dependency.preloadedJs).split(','); + dependency.preloadedCss = ( dependency.preloadedCss).split(','); + } + + dependency.version = '?ver=' + dependency.majorVersion + '.' + dependency.minorVersion + '.' + + dependency.patchVersion; + + this.getDependencyAssets(dependency, 'preloadedJs', files.scripts, prefix); + this.getDependencyAssets(dependency, 'preloadedCss', files.styles, prefix); + } + + if (this.aggregateAssets) { + // Aggregate and store assets. + return this.cacheAssets(files, cachedAssetsHash, folderName, siteId).then(() => { + // Keep track of which libraries have been cached in case they are updated. + return this.saveCachedAssets(cachedAssetsHash, dependencies, folderName, siteId); + }).then(() => { + return files; + }); + } + + return files; + }); + } + + /** + * Get the path to the dependency. + * + * @param dependency Dependency library. + * @return The path to the dependency library + */ + protected getDependencyPath(dependency: CoreH5PContentDependencyData): string { + return 'libraries/' + dependency.machineName + '-' + dependency.majorVersion + '.' + dependency.minorVersion; + } + + /** + * Get the paths to the content dependencies. + * + * @param id The H5P content ID. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with an object containing the path of each content dependency. + */ + getDependencyRoots(id: number, siteId?: string): Promise<{[libString: string]: string}> { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const roots = {}; + + return this.loadContentDependencies(id, undefined, siteId).then((dependencies) => { + + for (const machineName in dependencies) { + const dependency = dependencies[machineName], + folderName = this.libraryToString(dependency, true); + + roots[folderName] = this.getLibraryFolderPath(dependency, siteId, folderName); + } + + return roots; + }); + } + + /** + * Convert display options to an object. + * + * @param disable Display options as a number. + * @return Display options as object. + */ + getDisplayOptionsAsObject(disable: number): CoreH5PDisplayOptions { + const displayOptions: CoreH5PDisplayOptions = {}; + + // tslint:disable: no-bitwise + displayOptions[CoreH5PProvider.DISPLAY_OPTION_FRAME] = !(disable & CoreH5PProvider.DISABLE_FRAME); + displayOptions[CoreH5PProvider.DISPLAY_OPTION_DOWNLOAD] = !(disable & CoreH5PProvider.DISABLE_DOWNLOAD); + displayOptions[CoreH5PProvider.DISPLAY_OPTION_EMBED] = !(disable & CoreH5PProvider.DISABLE_EMBED); + displayOptions[CoreH5PProvider.DISPLAY_OPTION_COPYRIGHT] = !(disable & CoreH5PProvider.DISABLE_COPYRIGHT); + displayOptions[CoreH5PProvider.DISPLAY_OPTION_ABOUT] = !!this.getOption(CoreH5PProvider.DISPLAY_OPTION_ABOUT, true); + + return displayOptions; + } + + /** + * Determine display option visibility when viewing H5P + * + * @param disable The display options as a number. + * @param id Package ID. + * @return Display options as object. + */ + getDisplayOptionsForView(disable: number, id: number): CoreH5PDisplayOptions { + return this.fixDisplayOptions(this.getDisplayOptionsAsObject(disable), id); + } + + /** + * Get display options from a URL params. + * + * @param params URL params. + * @return Display options as object. + */ + getDisplayOptionsFromUrlParams(params: {[name: string]: string}): CoreH5PDisplayOptions { + const displayOptions: CoreH5PDisplayOptions = {}; + + if (!params) { + return displayOptions; + } + + displayOptions[CoreH5PProvider.DISPLAY_OPTION_DOWNLOAD] = + this.utils.isTrueOrOne(params[CoreH5PProvider.DISPLAY_OPTION_DOWNLOAD]); + displayOptions[CoreH5PProvider.DISPLAY_OPTION_EMBED] = + this.utils.isTrueOrOne(params[CoreH5PProvider.DISPLAY_OPTION_EMBED]); + displayOptions[CoreH5PProvider.DISPLAY_OPTION_COPYRIGHT] = + this.utils.isTrueOrOne(params[CoreH5PProvider.DISPLAY_OPTION_COPYRIGHT]); + displayOptions[CoreH5PProvider.DISPLAY_OPTION_FRAME] = displayOptions[CoreH5PProvider.DISPLAY_OPTION_DOWNLOAD] || + displayOptions[CoreH5PProvider.DISPLAY_OPTION_EMBED] || displayOptions[CoreH5PProvider.DISPLAY_OPTION_COPYRIGHT]; + displayOptions[CoreH5PProvider.DISPLAY_OPTION_ABOUT] = !!this.getOption(CoreH5PProvider.DISPLAY_OPTION_ABOUT, true); + + return displayOptions; + } + + /** + * Embed code for settings. + * + * @param siteUrl The site URL. + * @param h5pUrl The URL of the .h5p file. + * @param embedEnabled Whether the option to embed the H5P content is enabled. + * @return The HTML code to reuse this H5P content in a different place. + */ + protected getEmbedCode(siteUrl: string, h5pUrl: string, embedEnabled?: boolean): string { + if (!embedEnabled) { + return ''; + } + + return ''; + } + + /** + * Get the encoded URL for embeding an H5P content. + * + * @param siteUrl The site URL. + * @param h5pUrl The URL of the .h5p file. + * @return The embed URL. + */ + protected getEmbedUrl(siteUrl: string, h5pUrl: string): string { + return this.textUtils.concatenatePaths(siteUrl, '/h5p/embed.php') + '?url=' + h5pUrl; + } + + /** + * Get path to the folder containing H5P files extracted from packages. + * + * @param siteId The site ID. + * @return Folder path. + */ + getExternalH5PFolderPath(siteId: string): string { + return this.textUtils.concatenatePaths(this.fileProvider.getSiteFolder(siteId), 'h5p'); + } + + /** + * Get library data. This code is based on the getLibraryData from Moodle's H5PValidator. + * This function won't validate most things because it should've been done by the server already. + * + * @param libDir Directory where the library files are. + * @param libPath Path to the directory where the library files are. + * @param h5pDir Path to the directory where this h5p files are. + * @return Library data. + */ + protected getLibraryData(libDir: DirectoryEntry, libPath: string, h5pDir: string): any { + const libraryJsonPath = this.textUtils.concatenatePaths(libPath, 'library.json'), + semanticsPath = this.textUtils.concatenatePaths(libPath, 'semantics.json'), + langPath = this.textUtils.concatenatePaths(libPath, 'language'), + iconPath = this.textUtils.concatenatePaths(libPath, 'icon.svg'), + promises = []; + let h5pData, + semanticsData, + langData, + hasIcon; + + // Read the library json file. + promises.push(this.fileProvider.readFile(libraryJsonPath, CoreFileProvider.FORMATJSON).then((data) => { + h5pData = data; + })); + + // Get library semantics if it exists. + promises.push(this.fileProvider.readFile(semanticsPath, CoreFileProvider.FORMATJSON).then((data) => { + semanticsData = data; + }).catch(() => { + // Probably doesn't exist, ignore. + })); + + // Get language data if it exists. + promises.push(this.fileProvider.getDirectoryContents(langPath).then((entries) => { + const subPromises = []; + langData = {}; + + entries.forEach((entry) => { + const langFilePath = this.textUtils.concatenatePaths(langPath, entry.name); + + subPromises.push(this.fileProvider.readFile(langFilePath, CoreFileProvider.FORMATJSON).then((data) => { + const parts = entry.name.split('.'); // The language code is in parts[0]. + langData[parts[0]] = data; + })); + }); + }).catch(() => { + // Probably doesn't exist, ignore. + })); + + // Check if it has icon. + promises.push(this.fileProvider.getFile(iconPath).then(() => { + hasIcon = true; + }).catch(() => { + hasIcon = false; + })); + + return Promise.all(promises).then(() => { + h5pData.semantics = semanticsData; + h5pData.language = langData; + h5pData.hasIcon = hasIcon; + + return h5pData; + }); + } + + /** + * Get a library data stored in DB. + * + * @param machineName Machine name. + * @param majorVersion Major version number. + * @param minorVersion Minor version number. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the library data, rejected if not found. + */ + protected getLibrary(machineName: string, majorVersion?: string | number, minorVersion?: string | number, siteId?: string) + : Promise { + + return this.sitesProvider.getSiteDb(siteId).then((db) => { + const conditions: any = { + machinename: machineName + }; + + if (typeof majorVersion != 'undefined') { + conditions.majorversion = majorVersion; + } + if (typeof minorVersion != 'undefined') { + conditions.minorversion = minorVersion; + } + + return db.getRecords(this.LIBRARIES_TABLE, conditions); + }).then((libraries): any => { + if (!libraries.length) { + return Promise.reject(null); + } + + return this.parseLibDBData(libraries[0]); + }); + } + + /** + * Get a library data stored in DB. + * + * @param libraryData Library data. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the library data, rejected if not found. + */ + protected getLibraryByData(libraryData: any, siteId?: string): Promise { + return this.getLibrary(libraryData.machineName, libraryData.majorVersion, libraryData.minorVersion, siteId); + } + + /** + * Get a library data stored in DB by ID. + * + * @param id Library ID. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the library data, rejected if not found. + */ + protected getLibraryById(id: number, siteId?: string): Promise { + return this.sitesProvider.getSiteDb(siteId).then((db) => { + return db.getRecord(this.LIBRARIES_TABLE, {id: id}).then((library) => { + return this.parseLibDBData(library); + }); + }); + } + + /** + * Get a library ID. If not found, return null. + * + * @param machineName Machine name. + * @param majorVersion Major version number. + * @param minorVersion Minor version number. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the library ID, null if not found. + */ + protected getLibraryId(machineName: string, majorVersion?: string | number, minorVersion?: string | number, siteId?: string) + : Promise { + + return this.getLibrary(machineName, majorVersion, minorVersion, siteId).then((library) => { + return (library && library.id) || null; + }).catch(() => { + return null; + }); + } + + /** + * Get a library ID. If not found, return null. + * + * @param libraryData Library data. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the library ID, null if not found. + */ + protected getLibraryIdByData(libraryData: any, siteId?: string): Promise { + return this.getLibraryId(libraryData.machineName, libraryData.majorVersion, libraryData.minorVersion, siteId); + } + + /** + * Get libraries folder path. + * + * @param siteId The site ID. + * @return Folder path. + */ + getLibrariesFolderPath(siteId: string): string { + return this.textUtils.concatenatePaths(this.getExternalH5PFolderPath(siteId), 'libraries'); + } + + /** + * Get a library's folder path. + * + * @param libraryData The library data. + * @param siteId The site ID. + * @param folderName Folder name. If not provided, it will be calculated. + * @return Folder path. + */ + getLibraryFolderPath(libraryData: any, siteId: string, folderName?: string): string { + if (!folderName) { + folderName = this.libraryToString(libraryData, true); + } + + return this.textUtils.concatenatePaths(this.getLibrariesFolderPath(siteId), folderName); + } + + /** + * Get the default behaviour for the display option defined. + * + * @param name Identifier for the setting. + * @param defaultValue Optional default value if settings is not set. + * @return Return the value for this display option. + */ + getOption(name: string, defaultValue: any = false): any { + // For now, all them are disabled by default, so only will be rendered when defined in the display options. + return CoreH5PDisplayOptionBehaviour.CONTROLLED_BY_AUTHOR_DEFAULT_OFF; + } + + /** + * Resizing script for settings. + * + * @return The HTML code with the resize script. + */ + protected getResizeCode(): string { + return ''; + } + + /** + * Get the URL to the resizer script. + * + * @return URL. + */ + getResizerScriptUrl(): string { + return this.textUtils.concatenatePaths(this.getCoreH5PPath(), 'js/h5p-resizer.js'); + } + + /** + * Get core JavaScript files. + * + * @return array The array containg urls of the core JavaScript files: + */ + getScripts(): string[] { + const libUrl = this.getCoreH5PPath(), + urls = []; + + CoreH5PProvider.SCRIPTS.forEach((script) => { + urls.push(libUrl + script); + }); + + return urls; + } + + /** + * Get a trusted H5P file. + * + * @param url The file URL. + * @param options Options. + * @param ignoreCache Whether to ignore cache. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the file data. + */ + getTrustedH5PFile(url: string, options?: CoreH5PGetTrustedFileOptions, ignoreCache?: boolean, siteId?: string) + : Promise { + + options = options || {}; + + return this.sitesProvider.getSite(siteId).then((site) => { + + const data = { + url: this.treatH5PUrl(url, site.getURL()), + frame: options.frame ? 1 : 0, + export: options.export ? 1 : 0, + embed: options.embed ? 1 : 0, + copyright: options.copyright ? 1 : 0, + }, + preSets: CoreSiteWSPreSets = { + cacheKey: this.getTrustedH5PFileCacheKey(url), + updateFrequency: CoreSite.FREQUENCY_RARELY + }; + + if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + return site.read('core_h5p_get_trusted_h5p_file', data, preSets).then((result: CoreH5PGetTrustedH5PFileResult): any => { + if (result.warnings && result.warnings.length) { + return Promise.reject(result.warnings[0]); + } + + if (result.files && result.files.length) { + return result.files[0]; + } + + return Promise.reject(null); + }); + }); + } + + /** + * Get cache key for trusted H5P file WS calls. + * + * @param url The file URL. + * @return Cache key. + */ + protected getTrustedH5PFileCacheKey(url: string): string { + return this.getTrustedH5PFilePrefixCacheKey() + url; + } + + /** + * Get prefixed cache key for trusted H5P file WS calls. + * + * @return Cache key. + */ + protected getTrustedH5PFilePrefixCacheKey(): string { + return this.ROOT_CACHE_KEY + 'trustedH5PFile:'; + } + + /** + * Check whether the user has permission to execute an action. + * + * @param permission Permission to check. + * @param id H5P package id. + * @return Whether the user has permission to execute an action. + */ + hasPermission(permission: number, id: number): boolean { + // H5P capabilities have not been introduced. + return null; + } + + /** + * Invalidates all trusted H5P file WS calls. + * + * @param siteId Site ID (empty for current site). + * @return Promise resolved when the data is invalidated. + */ + invalidateAllGetTrustedH5PFile(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKeyStartingWith(this.getTrustedH5PFilePrefixCacheKey()); + }); + } + + /** + * Invalidates get trusted H5P file WS call. + * + * @param url The URL of the file. + * @param siteId Site ID (empty for current site). + * @return Promise resolved when the data is invalidated. + */ + invalidateAvailableInContexts(url: string, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.invalidateWsCacheForKey(this.getTrustedH5PFileCacheKey(url)); + }); + } + + /** + * Performs actions required when a library has been installed. + * + * @param libraryId ID of library that was installed. + * @param siteId Site ID. + * @return Promise resolved when done. + */ + protected libraryInstalled(libraryId: number, siteId: string): Promise { + const promises = []; + + // Remove all indexes of contents that use this library. + promises.push(this.deleteContentIndexesForLibrary(libraryId, siteId)); + + if (this.aggregateAssets) { + // Remove cached assets that use this library. + promises.push(this.deleteCachedAssets(libraryId, siteId)); + } + + return this.utils.allPromises(promises); + } + + /** + * Writes library data as string on the form {machineName} {majorVersion}.{minorVersion}. + * + * @param libraryData Library data. + * @param folderName Use hyphen instead of space in returned string. + * @return String on the form {machineName} {majorVersion}.{minorVersion}. + */ + protected libraryToString(libraryData: any, folderName?: boolean): string { + return (libraryData.machineName ? libraryData.machineName : libraryData.name) + (folderName ? '-' : ' ') + + libraryData.majorVersion + '.' + libraryData.minorVersion; + } + + /** + * Load addon libraries. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the addon libraries. + */ + loadAddons(siteId?: string): Promise { + return this.sitesProvider.getSiteDb(siteId).then((db) => { + + const query = 'SELECT l1.id AS libraryId, l1.machinename AS machineName, ' + + 'l1.majorversion AS majorVersion, l1.minorversion AS minorVersion, ' + + 'l1.patchversion AS patchVersion, l1.addto AS addTo, ' + + 'l1.preloadedjs AS preloadedJs, l1.preloadedcss AS preloadedCss ' + + 'FROM ' + this.LIBRARIES_TABLE + ' l1 ' + + 'JOIN ' + this.LIBRARIES_TABLE + ' l2 ON l1.machinename = l2.machinename AND (' + + 'l1.majorversion < l2.majorversion OR (l1.majorversion = l2.majorversion AND ' + + 'l1.minorversion < l2.minorversion)) ' + + 'WHERE l1.addto IS NOT NULL AND l2.machinename IS NULL'; + + return db.execute(query).then((result) => { + const addons = []; + + for (let i = 0; i < result.rows.length; i++) { + addons.push(this.parseLibAddonData(result.rows.item(i))); + } + + return addons; + }); + }); + } + + /** + * Load content data from DB. + * + * @param id Content ID. + * @param fileUrl H5P file URL. Required if id is not provided. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the content data. + */ + protected loadContentData(id?: number, fileUrl?: string, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + let promise: Promise; + + if (id) { + promise = this.getContentData(id, siteId); + } else if (fileUrl) { + promise = this.getContentDataByUrl(fileUrl, siteId); + } else { + promise = Promise.reject(null); + } + + return promise.then((contentData) => { + + // Load the main library data. + return this.getLibraryById(contentData.mainlibraryid, siteId).then((libData) => { + + // Validate metadata. + const validator = new CoreH5PContentValidator(this, this.h5pUtils, this.textUtils, this.utils, this.translate, + siteId); + + // Validate empty metadata, like Moodle web does. + return validator.validateMetadata({}).then((metadata) => { + // Map the values to the names used by the H5P core (it's the same Moodle web does). + return { + id: contentData.id, + params: contentData.jsoncontent, + embedType: 'iframe', // Always use iframe. + disable: null, + folderName: contentData.foldername, + title: libData.title, + slug: this.h5pUtils.slugify(libData.title) + '-' + contentData.id, + filtered: contentData.filtered, + libraryMajorVersion: libData.majorversion, + libraryMinorVersion: libData.minorversion, + metadata: metadata, + library: { + id: libData.id, + name: libData.machinename, + majorVersion: libData.majorversion, + minorVersion: libData.minorversion, + embedTypes: libData.embedtypes, + fullscreen: libData.fullscreen + } + }; + }); + }); + }); + } + + /** + * Load dependencies for the given content of the given type. + * + * @param id Content ID. + * @param type The dependency type. + * @return Content dependencies, indexed by machine name. + */ + loadContentDependencies(id: number, type?: string, siteId?: string) + : Promise<{[machineName: string]: CoreH5PContentDependencyData}> { + + return this.sitesProvider.getSiteDb(siteId).then((db) => { + let query = 'SELECT hl.id AS libraryId, hl.machinename AS machineName, ' + + 'hl.majorversion AS majorVersion, hl.minorversion AS minorVersion, ' + + 'hl.patchversion AS patchVersion, hl.preloadedcss AS preloadedCss, ' + + 'hl.preloadedjs AS preloadedJs, hcl.dropcss AS dropCss, ' + + 'hcl.dependencytype as dependencyType ' + + 'FROM ' + this.CONTENTS_LIBRARIES_TABLE + ' hcl ' + + 'JOIN ' + this.LIBRARIES_TABLE + ' hl ON hcl.libraryid = hl.id ' + + 'WHERE hcl.h5pid = ?'; + const queryArgs = []; + queryArgs.push(id); + + if (type) { + query += ' AND hcl.dependencytype = ?'; + queryArgs.push(type); + } + + query += ' ORDER BY hcl.weight'; + + return db.execute(query, queryArgs).then((result) => { + const dependencies = {}; + + for (let i = 0; i < result.rows.length; i++) { + const dependency = result.rows.item(i); + + dependencies[dependency.machineName] = dependency; + } + + return dependencies; + }); + }); + } + + /** + * Loads a library and its dependencies. + * + * @param machineName The library's machine name. + * @param majorVersion The library's major version. + * @param minorVersion The library's minor version. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the library data. + */ + loadLibrary(machineName: string, majorVersion: number, minorVersion: number, siteId?: string): Promise { + + // First get the library data from DB. + return this.getLibrary(machineName, majorVersion, minorVersion, siteId).then((library) => { + const libraryData: CoreH5PLibraryData = { + libraryId: library.id, + title: library.title, + machineName: library.machinename, + majorVersion: library.majorversion, + minorVersion: library.minorversion, + patchVersion: library.patchversion, + runnable: library.runnable, + fullscreen: library.fullscreen, + embedTypes: library.embedtypes, + preloadedJs: library.preloadedjs, + preloadedCss: library.preloadedcss, + dropLibraryCss: library.droplibrarycss, + semantics: library.semantics, + preloadedDependencies: [], + dynamicDependencies: [], + editorDependencies: [] + }; + + // Now get the dependencies. + const sql = 'SELECT hl.id, hl.machinename, hl.majorversion, hl.minorversion, hll.dependencytype ' + + 'FROM ' + this.LIBRARY_DEPENDENCIES_TABLE + ' hll ' + + 'JOIN ' + this.LIBRARIES_TABLE + ' hl ON hll.requiredlibraryid = hl.id ' + + 'WHERE hll.libraryid = ? ' + + 'ORDER BY hl.id ASC'; + + const sqlParams = [ + library.id + ]; + + return this.sitesProvider.getSiteDb(siteId).then((db) => { + return db.execute(sql, sqlParams).then((result) => { + + for (let i = 0; i < result.rows.length; i++) { + const dependency = result.rows.item(i), + key = dependency.dependencytype + 'Dependencies'; + + libraryData[key].push({ + machineName: dependency.machinename, + majorVersion: dependency.majorversion, + minorVersion: dependency.minorversion + }); + } + + return libraryData; + }); + }); + }); + } + + /** + * Parse library addon data. + * + * @param library Library addon data. + * @return Parsed library. + */ + parseLibAddonData(library: any): CoreH5PLibraryAddonData { + library.addto = this.textUtils.parseJSON(library.addto, null); + + return library; + } + + /** + * Parse library DB data. + * + * @param library Library DB data. + * @return Parsed library. + */ + parseLibDBData(library: any): CoreH5PLibraryDBData { + library.semantics = this.textUtils.parseJSON(library.semantics, null); + library.addto = this.textUtils.parseJSON(library.addto, null); + + return library; + } + + /** + * Process libraries from an H5P library, getting the required data to save them. + * This code was copied from the isValidPackage function in Moodle's H5PValidator. + * This function won't validate most things because it should've been done by the server already. + * + * @param fileUrl The file URL used to download the file. + * @param file The file entry of the downloaded file. + * @return Promise resolved when done. + */ + protected processH5PFiles(destFolder: string, entries: (DirectoryEntry | FileEntry)[]) + : Promise<{librariesJsonData: any, mainJsonData: any, contentJsonData: any}> { + + const promises = [], + libraries: any = {}; + let contentJsonData, + mainH5PData; + + // Read the h5p.json file. + const h5pJsonPath = this.textUtils.concatenatePaths(destFolder, 'h5p.json'); + promises.push(this.fileProvider.readFile(h5pJsonPath, CoreFileProvider.FORMATJSON).then((data) => { + mainH5PData = data; + })); + + // Read the content.json file. + const contentJsonPath = this.textUtils.concatenatePaths(destFolder, 'content/content.json'); + promises.push(this.fileProvider.readFile(contentJsonPath, CoreFileProvider.FORMATJSON).then((data) => { + contentJsonData = data; + })); + + // Treat libraries. + entries.forEach((entry) => { + if (entry.name[0] == '.' || entry.name[0] == '_' || entry.name == 'content' || entry.isFile) { + // Skip files, the content folder and any folder starting with a . or _. + return; + } + + const libDirPath = this.textUtils.concatenatePaths(destFolder, entry.name); + + promises.push(this.getLibraryData( entry, libDirPath, destFolder).then((libraryH5PData) => { + libraryH5PData.uploadDirectory = libDirPath; + libraries[this.libraryToString(libraryH5PData)] = libraryH5PData; + })); + }); + + return Promise.all(promises).then(() => { + return { + librariesJsonData: libraries, + mainJsonData: mainH5PData, + contentJsonData: contentJsonData + }; + }); + } + + /** + * Stores hash keys for cached assets, aggregated JavaScripts and stylesheets, and connects it to libraries so that we + * know which cache file to delete when a library is updated. + * + * @param key Hash key for the given libraries. + * @param libraries List of dependencies used to create the key. + * @param folderName The name of the folder that contains the H5P. + * @param siteId The site ID. + * @return Promise resolved when done. + */ + protected saveCachedAssets(hash: string, dependencies: {[machineName: string]: CoreH5PContentDependencyData}, + folderName: string, siteId?: string): Promise { + + return this.sitesProvider.getSiteDb(siteId).then((db) => { + const promises = []; + + for (const key in dependencies) { + const data = { + hash: key, + libraryid: dependencies[key].libraryId, + foldername: folderName + }; + + promises.push(db.insertRecord(this.LIBRARIES_CACHEDASSETS_TABLE, data)); + } + + return Promise.all(promises); + }); + } + + /** + * Save content data in DB and clear cache. + * + * @param content Content to save. + * @param folderName The name of the folder that contains the H5P. + * @param fileUrl The online URL of the package. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with content ID. + */ + protected saveContentData(content: any, folderName: string, fileUrl: string, siteId?: string): Promise { + // Save in DB. + return this.sitesProvider.getSiteDb(siteId).then((db) => { + + const data: any = { + jsoncontent: content.params, + mainlibraryid: content.library.libraryId, + timemodified: Date.now(), + filtered: null, + foldername: folderName, + fileurl: fileUrl + }; + + if (typeof content.id != 'undefined') { + data.id = content.id; + } else { + data.timecreated = data.timemodified; + } + + return db.insertRecord(this.CONTENT_TABLE, data).then(() => { + if (!data.id) { + // New content. Get its ID. + return db.getRecord(this.CONTENT_TABLE, data).then((entry) => { + content.id = entry.id; + }); + } + }); + }).then(() => { + // If resetContentUserData is implemented in the future, it should be called in here. + return content.id; + }); + } + + /** + * Save the content in filesystem. + * + * @param contentPath Path to the current content folder (tmp). + * @param folderName Name to put to the content folder. + * @param siteId Site ID. + * @return Promise resolved when done. + */ + protected saveContentInFS(contentPath: string, folderName: string, siteId: string): Promise { + const folderPath = this.getContentFolderPath(folderName, siteId); + + // Delete existing content for this package. + return this.fileProvider.removeDir(folderPath).catch(() => { + // Ignore errors, maybe it doesn't exist. + }).then(() => { + // Copy the new one. + return this.fileProvider.moveDir(contentPath, folderPath); + }); + } + + /** + * Save libraries. This code is based on the saveLibraries function from Moodle's H5PStorage. + * + * @param librariesJsonData Data about libraries. + * @param folderName Name of the folder of the H5P package. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + protected saveLibraries(librariesJsonData: any, folderName: string, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const libraryIds = []; + + // First of all, try to create the dir where the libraries are stored. This way we don't have to do it for each lib. + return this.fileProvider.createDir(this.getLibrariesFolderPath(siteId)).then(() => { + const promises = []; + + // Go through libraries that came with this package. + for (const libString in librariesJsonData) { + const libraryData = librariesJsonData[libString]; + + // Find local library identifier. + promises.push(this.getLibraryByData(libraryData).catch(() => { + // Not found. + }).then((dbData) => { + if (dbData) { + // Library already installed. + libraryData.libraryId = dbData.id; + + if (libraryData.patchVersion <= dbData.patchversion) { + // Same or older version, no need to save. + libraryData.saveDependencies = false; + + return; + } + } + + libraryData.saveDependencies = true; + + // Convert metadataSettings values to boolean and json_encode it before saving. + libraryData.metadataSettings = libraryData.metadataSettings ? + this.h5pUtils.boolifyAndEncodeMetadataSettings(libraryData.metadataSettings) : null; + + // Save the library data in DB. + return this.saveLibraryData(libraryData, siteId).then(() => { + // Now save it in FS. + return this.saveLibraryInFS(libraryData, siteId).catch((error) => { + // An error occurred, delete the DB data because the lib FS data has been deleted. + return this.deleteLibraryData(libraryData.libraryId, siteId).catch(() => { + // Ignore errors. + }).then(() => { + return Promise.reject(error); + }); + }); + }).then(() => { + if (typeof libraryData.libraryId != 'undefined') { + return this.libraryInstalled(libraryData.libraryId, siteId); + } + }); + })); + } + + return Promise.all(promises); + }).then(() => { + // Go through the libraries again to save dependencies. + const promises = []; + + for (const libString in librariesJsonData) { + const libraryData = librariesJsonData[libString]; + if (!libraryData.saveDependencies) { + continue; + } + + libraryIds.push(libraryData.libraryId); + + // Remove any old dependencies. + promises.push(this.deleteLibraryDependencies(libraryData.libraryId).then(() => { + // Insert the different new ones. + const subPromises = []; + + if (typeof libraryData.preloadedDependencies != 'undefined') { + subPromises.push(this.saveLibraryDependencies(libraryData.libraryId, libraryData.preloadedDependencies, + 'preloaded')); + } + if (typeof libraryData.dynamicDependencies != 'undefined') { + subPromises.push(this.saveLibraryDependencies(libraryData.libraryId, libraryData.dynamicDependencies, + 'dynamic')); + } + if (typeof libraryData.editorDependencies != 'undefined') { + subPromises.push(this.saveLibraryDependencies(libraryData.libraryId, libraryData.editorDependencies, + 'editor')); + } + + return Promise.all(subPromises); + })); + } + + return Promise.all(promises); + }).then(() => { + // Make sure dependencies, parameter filtering and export files get regenerated for content who uses these libraries. + if (libraryIds.length) { + return this.clearFilteredParameters(libraryIds, siteId); + } + }); + } + + /** + * Save a library in filesystem. + * + * @param libraryData Library data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + protected saveLibraryInFS(libraryData: any, siteId?: string): Promise { + const folderPath = this.getLibraryFolderPath(libraryData, siteId); + + // Delete existing library version. + return this.fileProvider.removeDir(folderPath).catch(() => { + // Ignore errors, maybe it doesn't exist. + }).then(() => { + // Copy the new one. + return this.fileProvider.moveDir(libraryData.uploadDirectory, folderPath, true); + }); + } + + /** + * Save library data in DB. + * + * @param libraryData Library data to save. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + protected saveLibraryData(libraryData: any, siteId?: string): Promise { + // Some special properties needs some checking and converting before they can be saved. + const preloadedJS = this.h5pUtils.libraryParameterValuesToCsv(libraryData, 'preloadedJs', 'path'), + preloadedCSS = this.h5pUtils.libraryParameterValuesToCsv(libraryData, 'preloadedCss', 'path'), + dropLibraryCSS = this.h5pUtils.libraryParameterValuesToCsv(libraryData, 'dropLibraryCss', 'machineName'); + + if (typeof libraryData.semantics == 'undefined') { + libraryData.semantics = ''; + } + if (typeof libraryData.fullscreen == 'undefined') { + libraryData.fullscreen = 0; + } + + let embedTypes = ''; + if (typeof libraryData.embedTypes != 'undefined') { + embedTypes = libraryData.embedTypes.join(', '); + } + + return this.sitesProvider.getSite(siteId).then((site) => { + const db = site.getDb(), + data: any = { + title: libraryData.title, + machinename: libraryData.machineName, + majorversion: libraryData.majorVersion, + minorversion: libraryData.minorVersion, + patchversion: libraryData.patchVersion, + runnable: libraryData.runnable, + fullscreen: libraryData.fullscreen, + embedtypes: embedTypes, + preloadedjs: preloadedJS, + preloadedcss: preloadedCSS, + droplibrarycss: dropLibraryCSS, + semantics: typeof libraryData.semantics != 'undefined' ? JSON.stringify(libraryData.semantics) : null, + addto: typeof libraryData.addTo != 'undefined' ? JSON.stringify(libraryData.addTo) : null, + }; + + if (libraryData.libraryId) { + data.id = libraryData.libraryId; + } + + return db.insertRecord(this.LIBRARIES_TABLE, data).then(() => { + if (!data.id) { + // New library. Get its ID. + return db.getRecord(this.LIBRARIES_TABLE, data).then((entry) => { + libraryData.libraryId = entry.id; + }); + } else { + // Updated libary. Remove old dependencies. + return this.deleteLibraryDependencies(data.id, site.getId()); + } + }); + }); + } + + /** + * Save what libraries a library is depending on. + * + * @param libraryId Library Id for the library we're saving dependencies for. + * @param dependencies List of dependencies as associative arrays containing machineName, majorVersion, minorVersion. + * @param dependencytype The type of dependency. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + protected saveLibraryDependencies(libraryId: number, dependencies: any[], dependencyType: string, siteId?: string) + : Promise { + + return this.sitesProvider.getSiteDb(siteId).then((db) => { + + const promises = []; + + dependencies.forEach((dependency) => { + // Get the ID of the library. + promises.push(this.getLibraryIdByData(dependency, siteId).then((dependencyId) => { + // Create the relation. + const entry = { + libraryid: libraryId, + requiredlibraryid: dependencyId, + dependencytype: dependencyType + }; + + return db.insertRecord(this.LIBRARY_DEPENDENCIES_TABLE, entry); + })); + }); + + return Promise.all(promises); + }); + } + + /** + * Saves what libraries the content uses. + * + * @param id Id identifying the package. + * @param librariesInUse List of libraries the content uses. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + saveLibraryUsage(id: number, librariesInUse: {[key: string]: CoreH5PContentDepsTreeDependency}, siteId?: string) + : Promise { + + return this.sitesProvider.getSiteDb(siteId).then((db) => { + // Calculate the CSS to drop. + const dropLibraryCssList = {}, + promises = []; + + for (const key in librariesInUse) { + const dependency = librariesInUse[key]; + + if (( dependency.library).dropLibraryCss) { + const split = ( dependency.library).dropLibraryCss.split(', '); + + split.forEach((css) => { + dropLibraryCssList[css] = css; + }); + } + } + + for (const key in librariesInUse) { + const dependency = librariesInUse[key], + data = { + h5pid: id, + libraryId: dependency.library.libraryId, + dependencytype: dependency.type, + dropcss: dropLibraryCssList[dependency.library.machineName] ? 1 : 0, + weight: dependency.weight + }; + + promises.push(db.insertRecord(this.CONTENTS_LIBRARIES_TABLE, data)); + } + + return Promise.all(promises); + }); + + } + + /** + * Helper function used to figure out embed and download behaviour. + * + * @param optionName The option name. + * @param permission The permission. + * @param id The package ID. + * @param value Default value. + * @return The value to use. + */ + setDisplayOptionOverrides(optionName: string, permission: number, id: number, value: boolean): boolean { + const behaviour = this.getOption(optionName, CoreH5PDisplayOptionBehaviour.ALWAYS_SHOW); + + // If never show globally, force hide + if (behaviour == CoreH5PDisplayOptionBehaviour.NEVER_SHOW) { + value = false; + } else if (behaviour == CoreH5PDisplayOptionBehaviour.ALWAYS_SHOW) { + // If always show or permissions say so, force show + value = true; + } else if (behaviour == CoreH5PDisplayOptionBehaviour.CONTROLLED_BY_PERMISSIONS) { + value = this.hasPermission(permission, id); + } + + return value; + } + + /** + * Treat an H5P url before sending it to WS. + * + * @param url H5P file URL. + * @param siteUrl Site URL. + * @return Treated url. + */ + protected treatH5PUrl(url: string, siteUrl: string): string { + if (url.indexOf(this.textUtils.concatenatePaths(siteUrl, '/webservice/pluginfile.php')) === 0) { + url = url.replace('/webservice/pluginfile', '/pluginfile'); + } + + return url; + } + + /** + * This will update selected fields on the given content. + * + * @param id Content identifier. + * @param fields Object with the fields to update. + * @param siteId Site ID. If not defined, current site. + */ + protected updateContentFields(id: number, fields: any, siteId?: string): Promise { + + return this.sitesProvider.getSiteDb(siteId).then((db) => { + const data = Object.assign(fields); + delete data.slug; // Slug isn't stored in DB. + + return db.updateRecords(this.CONTENT_TABLE, data, {id: id}); + }); + } +} + +/** + * Display options behaviour constants. + */ +export class CoreH5PDisplayOptionBehaviour { + static NEVER_SHOW = 0; + static CONTROLLED_BY_AUTHOR_DEFAULT_ON = 1; + static CONTROLLED_BY_AUTHOR_DEFAULT_OFF = 2; + static ALWAYS_SHOW = 3; + static CONTROLLED_BY_PERMISSIONS = 4; +} + +/** + * Permission constants. + */ +export class CoreH5PPermission { + static DOWNLOAD_H5P = 0; + static EMBED_H5P = 1; + static CREATE_RESTRICTED = 2; + static UPDATE_LIBRARIES = 3; + static INSTALL_RECOMMENDED = 4; + static COPY_H5P = 4; +} + +/** + * Display options as object. + */ +export type CoreH5PDisplayOptions = { + frame?: boolean; + export?: boolean; + embed?: boolean; + copyright?: boolean; + icon?: boolean; + copy?: boolean; +}; + +/** + * Options for core_h5p_get_trusted_h5p_file. + */ +export type CoreH5PGetTrustedFileOptions = { + frame?: boolean; // Whether to show the bar options below the content. + export?: boolean; // Whether to allow to download the package. + embed?: boolean; // Whether to allow to copy the code to your site. + copyright?: boolean; // The copyright option. +}; + +/** + * Result of core_h5p_get_trusted_h5p_file. + */ +export type CoreH5PGetTrustedH5PFileResult = { + files: CoreWSExternalFile[]; // Files. + warnings: CoreWSExternalWarning[]; // List of warnings. +}; + +/** + * Dependency asset. + */ +export type CoreH5PDependencyAsset = { + path: string; // Path to the asset. + version: string; // Dependency version. +}; + +/** + * Content data stored in DB. + */ +export type CoreH5PContentDBData = { + id: number; // The id of the content. + jsoncontent: string; // The content in json format. + mainlibraryid: number; // The library we first instantiate for this node. + foldername: string; // Name of the folder that contains the contents. + fileurl: string; // The online URL of the H5P package. + filtered: string; // Filtered version of json_content. + timecreated: number; // Time created. + timemodified: number; // Time modified. +}; + +/** + * Content data, including main library data. + */ +export type CoreH5PContentData = { + id: number; // The id of the content. + params: string; // The content in json format. + embedType: string; // Embed type to use. + disable: number; // H5P Button display options. + folderName: string; // Name of the folder that contains the contents. + title: string; // Main library's title. + slug: string; // Lib title and ID slugified. + filtered: string; // Filtered version of json_content. + libraryMajorVersion: number; // Main library's major version. + libraryMinorVersion: number; // Main library's minor version. + metadata: any; // Content metadata. + library: { // Main library data. + id: number; // The id of the library. + name: string; // The library machine name. + majorVersion: number; // Major version. + minorVersion: number; // Minor version. + embedTypes: string; // List of supported embed types. + fullscreen: number; // Display fullscreen button. + }; + dependencies?: {[key: string]: CoreH5PContentDepsTreeDependency}; // Dependencies. Calculated in filterParameters. +}; + +/** + * Content dependency data. + */ +export type CoreH5PContentDependencyData = { + libraryId: number; // The id of the library if it is an existing library. + machineName: string; // The library machineName. + majorVersion: number; // The The library's majorVersion. + minorVersion: number; // The The library's minorVersion. + patchVersion: number; // The The library's patchVersion. + preloadedJs?: string | string[]; // Comma separated string with js file paths. If already parsed, list of paths. + preloadedCss?: string | string[]; // Comma separated string with css file paths. If already parsed, list of paths. + dropCss?: string; // CSV of machine names. + dependencyType: string; // The dependency type. + path?: string; // Path to the dependency. Calculated in getDependenciesFiles. + version?: string; // Version of the dependency. Calculated in getDependenciesFiles. +}; + +/** + * Data for each content dependency in the dependency tree. + */ +export type CoreH5PContentDepsTreeDependency = { + library: CoreH5PLibraryData | CoreH5PLibraryAddonData; // Library data. + type: string; // Dependency type. + weight?: number; // An integer determining the order of the libraries when they are loaded. +}; + +/** + * Library data. + */ +export type CoreH5PLibraryData = { + libraryId: number; // The id of the library. + title: string; // The human readable name of this library. + machineName: string; // The library machine name. + majorVersion: number; // Major version. + minorVersion: number; // Minor version. + patchVersion: number; // Patch version. + runnable: number; // Can this library be started by the module? I.e. not a dependency. + fullscreen: number; // Display fullscreen button. + embedTypes: string; // List of supported embed types. + preloadedJs?: string; // Comma separated list of scripts to load. + preloadedCss?: string; // Comma separated list of stylesheets to load. + dropLibraryCss?: string; // List of libraries that should not have CSS included if this library is used. Comma separated list. + semantics?: any; // The semantics definition. If it's a string, it's in json format. + preloadedDependencies: CoreH5PLibraryBasicData[]; // Dependencies. + dynamicDependencies: CoreH5PLibraryBasicData[]; // Dependencies. + editorDependencies: CoreH5PLibraryBasicData[]; // Dependencies. +}; + +/** + * Library basic data. + */ +export type CoreH5PLibraryBasicData = { + machineName: string; // The library machine name. + majorVersion: number; // Major version. + minorVersion: number; // Minor version. +}; + +/** + * "Addon" data (library). + */ +export type CoreH5PLibraryAddonData = { + libraryId: number; // The id of the library. + machineName: string; // The library machine name. + majorVersion: number; // Major version. + minorVersion: number; // Minor version. + patchVersion: number; // Patch version. + preloadedJs?: string; // Comma separated list of scripts to load. + preloadedCss?: string; // Comma separated list of stylesheets to load. + addTo?: any; // Plugin configuration data. +}; + +/** + * Library data stored in DB. + */ +export type CoreH5PLibraryDBData = { + id: number; // The id of the library. + machinename: string; // The library machine name. + title: string; // The human readable name of this library. + majorversion: number; // Major version. + minorversion: number; // Minor version. + patchversion: number; // Patch version. + runnable: number; // Can this library be started by the module? I.e. not a dependency. + fullscreen: number; // Display fullscreen button. + embedtypes: string; // List of supported embed types. + preloadedjs?: string; // Comma separated list of scripts to load. + preloadedcss?: string; // Comma separated list of stylesheets to load. + droplibrarycss?: string; // List of libraries that should not have CSS included if this library is used. Comma separated list. + semantics?: any; // The semantics definition. + addto?: any; // Plugin configuration data. +}; + +/** + * Library dependencies stored in DB. + */ +export type CoreH5PLibraryDependenciesDBData = { + id: number; // Id. + libraryid: number; // The id of an H5P library. + requiredlibraryid: number; // The dependent library to load. + dependencytype: string; // Type: preloaded, dynamic, or editor. +}; diff --git a/src/core/h5p/providers/pluginfile-handler.ts b/src/core/h5p/providers/pluginfile-handler.ts new file mode 100644 index 000000000..13657fc2d --- /dev/null +++ b/src/core/h5p/providers/pluginfile-handler.ts @@ -0,0 +1,136 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreFileProvider } from '@providers/file'; +import { CorePluginFileHandler } from '@providers/plugin-file-delegate'; +import { CoreMimetypeUtilsProvider } from '@providers/utils/mimetype'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreUrlUtilsProvider } from '@providers/utils/url'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreH5PProvider } from './h5p'; +import { CoreWSExternalFile } from '@providers/ws'; +import { FileEntry } from '@ionic-native/file'; + +/** + * Handler to treat H5P files. + */ +@Injectable() +export class CoreH5PPluginFileHandler implements CorePluginFileHandler { + name = 'CoreH5PPluginFileHandler'; + + constructor(protected urlUtils: CoreUrlUtilsProvider, + protected mimeUtils: CoreMimetypeUtilsProvider, + protected textUtils: CoreTextUtilsProvider, + protected utils: CoreUtilsProvider, + protected fileProvider: CoreFileProvider, + protected h5pProvider: CoreH5PProvider) { } + + /** + * React to a file being deleted. + * + * @param fileUrl The file URL used to download the file. + * @param path The path of the deleted file. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + fileDeleted(fileUrl: string, path: string, siteId?: string): Promise { + // If an h5p file is deleted, remove the contents folder. + return this.h5pProvider.deleteContentByUrl(fileUrl, siteId); + } + + /** + * Check whether a file can be downloaded. If so, return the file to download. + * + * @param file The file data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the file to use. Rejected if cannot download. + */ + getDownloadableFile(file: CoreWSExternalFile, siteId?: string): Promise { + return this.h5pProvider.getTrustedH5PFile(file.fileurl, {}, false, siteId); + } + + /** + * Given an HTML element, get the URLs of the files that should be downloaded and weren't treated by + * CoreFilepoolProvider.extractDownloadableFilesFromHtml. + * + * @param container Container where to get the URLs from. + * @return {string[]} List of URLs. + */ + getDownloadableFilesFromHTML(container: HTMLElement): string[] { + const iframes = Array.from(container.querySelectorAll('iframe.h5p-iframe')); + const urls = []; + + for (let i = 0; i < iframes.length; i++) { + const params = this.urlUtils.extractUrlParams(iframes[i].src); + + if (params.url) { + urls.push(params.url); + } + } + + return urls; + } + + /** + * Get a file size. + * + * @param file The file data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the size. + */ + getFileSize(file: CoreWSExternalFile, siteId?: string): Promise { + return this.h5pProvider.getTrustedH5PFile(file.fileurl, {}, false, siteId).then((file) => { + return file.filesize; + }).catch((error): any => { + if (this.utils.isWebServiceError(error)) { + // WS returned an error, it means it cannot be downloaded. + return 0; + } + + return Promise.reject(error); + }); + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return Whether or not the handler is enabled on a site level. + */ + isEnabled(): boolean | Promise { + return this.h5pProvider.canGetTrustedH5PFileInSite(); + } + + /** + * Check whether the file should be treated by this handler. It is used in functions where the component isn't used. + * + * @param file The file data. + * @return Whether the file should be treated by this handler. + */ + shouldHandleFile(file: CoreWSExternalFile): boolean { + return this.mimeUtils.guessExtensionFromUrl(file.fileurl) == 'h5p'; + } + + /** + * Treat a downloaded file. + * + * @param fileUrl The file URL used to download the file. + * @param file The file entry of the downloaded file. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + treatDownloadedFile(fileUrl: string, file: FileEntry, siteId?: string): Promise { + return this.h5pProvider.extractH5PFile(fileUrl, file, siteId); + } +} diff --git a/src/core/h5p/providers/utils.ts b/src/core/h5p/providers/utils.ts new file mode 100644 index 000000000..026b2b9b7 --- /dev/null +++ b/src/core/h5p/providers/utils.ts @@ -0,0 +1,429 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { TranslateService } from '@ngx-translate/core'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreH5PContentDependencyData, CoreH5PDependencyAsset } from './h5p'; +import { Md5 } from 'ts-md5/dist/md5'; + +/** + * Utils service with helper functions for H5P. + */ +@Injectable() +export class CoreH5PUtilsProvider { + + // Map to slugify characters. + protected SLUGIFY_MAP = { + æ: 'ae', + ø: 'oe', + ö: 'o', + ó: 'o', + ô: 'o', + Ò: 'oe', + Õ: 'o', + Ý: 'o', + ý: 'y', + ÿ: 'y', + ā: 'y', + ă: 'a', + ą: 'a', + œ: 'a', + å: 'a', + ä: 'a', + á: 'a', + à: 'a', + â: 'a', + ã: 'a', + ç: 'c', + ć: 'c', + ĉ: 'c', + ċ: 'c', + č: 'c', + é: 'e', + è: 'e', + ê: 'e', + ë: 'e', + í: 'i', + ì: 'i', + î: 'i', + ï: 'i', + ú: 'u', + ñ: 'n', + ü: 'u', + ù: 'u', + û: 'u', + ß: 'es', + ď: 'd', + đ: 'd', + ē: 'e', + ĕ: 'e', + ė: 'e', + ę: 'e', + ě: 'e', + ĝ: 'g', + ğ: 'g', + ġ: 'g', + ģ: 'g', + ĥ: 'h', + ħ: 'h', + ĩ: 'i', + ī: 'i', + ĭ: 'i', + į: 'i', + ı: 'i', + ij: 'ij', + ĵ: 'j', + ķ: 'k', + ĺ: 'l', + ļ: 'l', + ľ: 'l', + ŀ: 'l', + ł: 'l', + ń: 'n', + ņ: 'n', + ň: 'n', + ʼn: 'n', + ō: 'o', + ŏ: 'o', + ő: 'o', + ŕ: 'r', + ŗ: 'r', + ř: 'r', + ś: 's', + ŝ: 's', + ş: 's', + š: 's', + ţ: 't', + ť: 't', + ŧ: 't', + ũ: 'u', + ū: 'u', + ŭ: 'u', + ů: 'u', + ű: 'u', + ų: 'u', + ŵ: 'w', + ŷ: 'y', + ź: 'z', + ż: 'z', + ž: 'z', + ſ: 's', + ƒ: 'f', + ơ: 'o', + ư: 'u', + ǎ: 'a', + ǐ: 'i', + ǒ: 'o', + ǔ: 'u', + ǖ: 'u', + ǘ: 'u', + ǚ: 'u', + ǜ: 'u', + ǻ: 'a', + ǽ: 'ae', + ǿ: 'oe' + }; + + constructor(private translate: TranslateService, + private textUtils: CoreTextUtilsProvider) { } + + /** + * The metadataSettings field in libraryJson uses 1 for true and 0 for false. + * Here we are converting these to booleans, and also doing JSON encoding. + * + * @param metadataSettings Settings. + * @return Stringified settings. + */ + boolifyAndEncodeMetadataSettings(metadataSettings: any): string { + // Convert metadataSettings values to boolean. + if (typeof metadataSettings.disable != 'undefined') { + metadataSettings.disable = metadataSettings.disable === 1; + } + if (typeof metadataSettings.disableExtraTitleField != 'undefined') { + metadataSettings.disableExtraTitleField = metadataSettings.disableExtraTitleField === 1; + } + + return JSON.stringify(metadataSettings); + } + + /** + * Determine the correct embed type to use. + * + * @param Embed type of the content. + * @param Embed type of the main library. + * @return Either 'div' or 'iframe'. + */ + determineEmbedType(contentEmbedType: string, libraryEmbedTypes: string): string { + // Detect content embed type. + let embedType = contentEmbedType.toLowerCase().indexOf('div') != -1 ? 'div' : 'iframe'; + + if (libraryEmbedTypes) { + // Check that embed type is available for library + const embedTypes = libraryEmbedTypes.toLowerCase(); + + if (embedTypes.indexOf(embedType) == -1) { + // Not available, pick default. + embedType = embedTypes.indexOf('div') != -1 ? 'div' : 'iframe'; + } + } + + return embedType; + } + + /** + * Combines path with version. + * + * @param assets List of assets to get their URLs. + * @param assetsFolderPath The path of the folder where the assets are. + * @return List of urls. + */ + getAssetsUrls(assets: CoreH5PDependencyAsset[], assetsFolderPath: string = ''): string[] { + const urls = []; + + assets.forEach((asset) => { + let url = asset.path; + + // Add URL prefix if not external. + if (asset.path.indexOf('://') == -1 && assetsFolderPath) { + url = this.textUtils.concatenatePaths(assetsFolderPath, url); + } + + // Add version if set. + if (asset.version) { + url += asset.version; + } + + urls.push(url); + }); + + return urls; + } + + /** + * Get the hash of a list of dependencies. + * + * @param dependencies Dependencies. + * @return Hash. + */ + getDependenciesHash(dependencies: {[machineName: string]: CoreH5PContentDependencyData}): string { + // Build hash of dependencies. + const toHash = []; + + // Use unique identifier for each library version. + for (const name in dependencies) { + const dep = dependencies[name]; + toHash.push(dep.machineName + '-' + dep.majorVersion + '.' + dep.minorVersion + '.' + dep.patchVersion); + } + + // Sort in case the same dependencies comes in a different order. + toHash.sort((a, b) => { + return a.localeCompare(b); + }); + + // Calculate hash. + return Md5.hashAsciiStr(toHash.join('')); + } + + /** + * Provide localization for the Core JS. + * + * @return Object with the translations. + */ + getLocalization(): any { + return { + fullscreen: this.translate.instant('core.h5p.fullscreen'), + disableFullscreen: this.translate.instant('core.h5p.disablefullscreen'), + download: this.translate.instant('core.h5p.download'), + copyrights: this.translate.instant('core.h5p.copyright'), + embed: this.translate.instant('core.h5p.embed'), + size: this.translate.instant('core.h5p.size'), + showAdvanced: this.translate.instant('core.h5p.showadvanced'), + hideAdvanced: this.translate.instant('core.h5p.hideadvanced'), + advancedHelp: this.translate.instant('core.h5p.resizescript'), + copyrightInformation: this.translate.instant('core.h5p.copyright'), + close: this.translate.instant('core.h5p.close'), + title: this.translate.instant('core.h5p.title'), + author: this.translate.instant('core.h5p.author'), + year: this.translate.instant('core.h5p.year'), + source: this.translate.instant('core.h5p.source'), + license: this.translate.instant('core.h5p.license'), + thumbnail: this.translate.instant('core.h5p.thumbnail'), + noCopyrights: this.translate.instant('core.h5p.nocopyright'), + reuse: this.translate.instant('core.h5p.reuse'), + reuseContent: this.translate.instant('core.h5p.reuseContent'), + reuseDescription: this.translate.instant('core.h5p.reuseDescription'), + downloadDescription: this.translate.instant('core.h5p.downloadtitle'), + copyrightsDescription: this.translate.instant('core.h5p.copyrighttitle'), + embedDescription: this.translate.instant('core.h5p.embedtitle'), + h5pDescription: this.translate.instant('core.h5p.h5ptitle'), + contentChanged: this.translate.instant('core.h5p.contentchanged'), + startingOver: this.translate.instant('core.h5p.startingover'), + by: this.translate.instant('core.h5p.by'), + showMore: this.translate.instant('core.h5p.showmore'), + showLess: this.translate.instant('core.h5p.showless'), + subLevel: this.translate.instant('core.h5p.sublevel'), + confirmDialogHeader: this.translate.instant('core.h5p.confirmdialogheader'), + confirmDialogBody: this.translate.instant('core.h5p.confirmdialogbody'), + cancelLabel: this.translate.instant('core.h5p.cancellabel'), + confirmLabel: this.translate.instant('core.h5p.confirmlabel'), + licenseU: this.translate.instant('core.h5p.undisclosed'), + licenseCCBY: this.translate.instant('core.h5p.ccattribution'), + licenseCCBYSA: this.translate.instant('core.h5p.ccattributionsa'), + licenseCCBYND: this.translate.instant('core.h5p.ccattributionnd'), + licenseCCBYNC: this.translate.instant('core.h5p.ccattributionnc'), + licenseCCBYNCSA: this.translate.instant('core.h5p.ccattributionncsa'), + licenseCCBYNCND: this.translate.instant('core.h5p.ccattributionncnd'), + licenseCC40: this.translate.instant('core.h5p.licenseCC40'), + licenseCC30: this.translate.instant('core.h5p.licenseCC30'), + licenseCC25: this.translate.instant('core.h5p.licenseCC25'), + licenseCC20: this.translate.instant('core.h5p.licenseCC20'), + licenseCC10: this.translate.instant('core.h5p.licenseCC10'), + licenseGPL: this.translate.instant('core.h5p.licenseGPL'), + licenseV3: this.translate.instant('core.h5p.licenseV3'), + licenseV2: this.translate.instant('core.h5p.licenseV2'), + licenseV1: this.translate.instant('core.h5p.licenseV1'), + licensePD: this.translate.instant('core.h5p.pd'), + licenseCC010: this.translate.instant('core.h5p.licenseCC010'), + licensePDM: this.translate.instant('core.h5p.pdm'), + licenseC: this.translate.instant('core.h5p.copyrightstring'), + contentType: this.translate.instant('core.h5p.contenttype'), + licenseExtras: this.translate.instant('core.h5p.licenseextras'), + changes: this.translate.instant('core.h5p.changelog'), + contentCopied: this.translate.instant('core.h5p.contentCopied'), + connectionLost: this.translate.instant('core.h5p.connectionLost'), + connectionReestablished: this.translate.instant('core.h5p.connectionReestablished'), + resubmitScores: this.translate.instant('core.h5p.resubmitScores'), + offlineDialogHeader: this.translate.instant('core.h5p.offlineDialogHeader'), + offlineDialogBody: this.translate.instant('core.h5p.offlineDialogBody'), + offlineDialogRetryMessage: this.translate.instant('core.h5p.offlineDialogRetryMessage'), + offlineDialogRetryButtonLabel: this.translate.instant('core.h5p.offlineDialogRetryButtonLabel'), + offlineSuccessfulSubmit: this.translate.instant('core.h5p.offlineSuccessfulSubmit'), + }; + } + + /** + * Parses library data from a string on the form {machineName} {majorVersion}.{minorVersion}. + * + * @param libraryString On the form {machineName} {majorVersion}.{minorVersion} + * @return Object with keys machineName, majorVersion and minorVersion. Null if string is not parsable. + */ + libraryFromString(libraryString: string): {machineName: string, majorVersion: number, minorVersion: number} { + + const matches = libraryString.match(/^([\w0-9\-\.]{1,255})[\-\ ]([0-9]{1,5})\.([0-9]{1,5})$/i); + + if (matches && matches.length >= 4) { + return { + machineName: matches[1], + majorVersion: Number(matches[2]), + minorVersion: Number(matches[3]) + }; + } + + return null; + } + + /** + * Convert list of library parameter values to csv. + * + * @param libraryData Library data as found in library.json files. + * @param key Key that should be found in libraryData. + * @param searchParam The library parameter (Default: 'path'). + * @return Library parameter values separated by ', ' + */ + libraryParameterValuesToCsv(libraryData: any, key: string, searchParam: string = 'path'): string { + if (typeof libraryData[key] != 'undefined') { + const parameterValues = []; + + libraryData[key].forEach((file) => { + for (const index in file) { + if (index === searchParam) { + parameterValues.push(file[index]); + } + } + }); + + return parameterValues.join(','); + } + + return ''; + } + + /** + * Convert strings of text into simple kebab case slugs. Based on H5PCore::slugify. + * + * @param input The string to slugify. + * @return Slugified text. + */ + slugify(input: string): string { + input = input || ''; + + input = input.toLowerCase(); + + // Replace common chars. + let newInput = ''; + for (let i = 0; i < input.length; i++) { + const char = input[i]; + + newInput += this.SLUGIFY_MAP[char] || char; + } + + // Replace everything else. + newInput = newInput.replace(/[^a-z0-9]/g, '-'); + + // Prevent double hyphen + newInput = newInput.replace(/-{2,}/g, '-'); + + // Prevent hyphen in beginning or end. + newInput = newInput.replace(/(^-+|-+$)/g, ''); + + // Prevent too long slug. + if (newInput.length > 91) { + newInput = newInput.substr(0, 92); + } + + // Prevent empty slug + if (newInput === '') { + newInput = 'interactive'; + } + + return newInput; + } + + /** + * Determine if params contain any match. + * + * @param params Parameters. + * @param pattern Regular expression to identify pattern. + * @return True if params matches pattern. + */ + textAddonMatches(params: any, pattern: string): boolean { + + if (typeof params == 'string') { + if (params.match(pattern)) { + return true; + } + } else if (typeof params == 'object') { + for (const key in params) { + const value = params[key]; + + if (this.textAddonMatches(value, pattern)) { + return true; + } + } + } + + return false; + } +} diff --git a/src/core/question/providers/helper.ts b/src/core/question/providers/helper.ts index 4b7da5ea3..0cb77a0b0 100644 --- a/src/core/question/providers/helper.ts +++ b/src/core/question/providers/helper.ts @@ -516,7 +516,7 @@ export class CoreQuestionHelperProvider { */ prefetchQuestionFiles(question: any, component?: string, componentId?: string | number, siteId?: string, usageId?: number) : Promise { - const urls = this.domUtils.extractDownloadableFilesFromHtml(question.html); + const urls = this.filepoolProvider.extractDownloadableFilesFromHtml(question.html); if (!component) { component = CoreQuestionProvider.COMPONENT; diff --git a/src/core/siteplugins/classes/handlers/module-prefetch-handler.ts b/src/core/siteplugins/classes/handlers/module-prefetch-handler.ts index e2cc1d795..d4a22f79d 100644 --- a/src/core/siteplugins/classes/handlers/module-prefetch-handler.ts +++ b/src/core/siteplugins/classes/handlers/module-prefetch-handler.ts @@ -22,6 +22,7 @@ import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreSitePluginsProvider } from '../../providers/siteplugins'; import { CoreCourseActivityPrefetchHandlerBase } from '@core/course/classes/activity-prefetch-handler'; import { CoreFilterHelperProvider } from '@core/filter/providers/helper'; +import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; /** * Handler to prefetch a module site plugin. @@ -39,13 +40,15 @@ export class CoreSitePluginsModulePrefetchHandler extends CoreCourseActivityPref sitesProvider: CoreSitesProvider, domUtils: CoreDomUtilsProvider, filterHelper: CoreFilterHelperProvider, + pluginFileDelegate: CorePluginFileDelegate, protected sitePluginsProvider: CoreSitePluginsProvider, component: string, name: string, modName: string, protected handlerSchema: any) { - super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper); + super(translate, appProvider, utils, courseProvider, filepoolProvider, sitesProvider, domUtils, filterHelper, + pluginFileDelegate); this.component = component; this.name = name; diff --git a/src/core/siteplugins/providers/helper.ts b/src/core/siteplugins/providers/helper.ts index 3ec5d444f..170ccf2aa 100644 --- a/src/core/siteplugins/providers/helper.ts +++ b/src/core/siteplugins/providers/helper.ts @@ -32,6 +32,7 @@ import { CoreQuestionProvider } from '@core/question/providers/question'; import { CoreCourseProvider } from '@core/course/providers/course'; import { CoreCoursesProvider } from '@core/courses/providers/courses'; import { CoreFilterHelperProvider } from '@core/filter/providers/helper'; +import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; // Delegates import { CoreMainMenuDelegate } from '@core/mainmenu/providers/delegate'; @@ -117,7 +118,8 @@ export class CoreSitePluginsHelperProvider { private workshopAssessmentStrategyDelegate: AddonWorkshopAssessmentStrategyDelegate, private courseProvider: CoreCourseProvider, private blockDelegate: CoreBlockDelegate, - private filterHelper: CoreFilterHelperProvider) { + private filterHelper: CoreFilterHelperProvider, + private pluginFileDelegate: CorePluginFileDelegate) { this.logger = loggerProvider.getInstance('CoreSitePluginsHelperProvider'); @@ -841,7 +843,7 @@ export class CoreSitePluginsHelperProvider { // Register the prefetch handler. this.prefetchDelegate.registerHandler(new CoreSitePluginsModulePrefetchHandler(this.translate, this.appProvider, this.utils, this.courseProvider, this.filepoolProvider, this.sitesProvider, this.domUtils, this.filterHelper, - this.sitePluginsProvider, plugin.component, uniqueName, modName, handlerSchema)); + this.pluginFileDelegate, this.sitePluginsProvider, plugin.component, uniqueName, modName, handlerSchema)); } return uniqueName; diff --git a/src/directives/format-text.ts b/src/directives/format-text.ts index 68ef57895..9c4a8c314 100644 --- a/src/directives/format-text.ts +++ b/src/directives/format-text.ts @@ -12,7 +12,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Directive, ElementRef, Input, Output, EventEmitter, OnChanges, SimpleChange, Optional } from '@angular/core'; +import { + Directive, ElementRef, Input, Output, EventEmitter, OnChanges, SimpleChange, Optional, ViewContainerRef +} from '@angular/core'; import { Platform, NavController, Content } from 'ionic-angular'; import { TranslateService } from '@ngx-translate/core'; import { CoreAppProvider } from '@providers/app'; @@ -90,7 +92,8 @@ export class CoreFormatTextDirective implements OnChanges { private eventsProvider: CoreEventsProvider, private filterProvider: CoreFilterProvider, private filterHelper: CoreFilterHelperProvider, - private filterDelegate: CoreFilterDelegate) { + private filterDelegate: CoreFilterDelegate, + private viewContainerRef: ViewContainerRef) { this.element = element.nativeElement; this.element.classList.add('opacity-hide'); // Hide contents until they're treated. @@ -371,7 +374,8 @@ export class CoreFormatTextDirective implements OnChanges { if (result.options.filter) { // Let filters hnadle HTML. We do it here because we don't want them to block the render of the text. - this.filterDelegate.handleHtml(this.element, result.filters, result.options, [], result.siteId); + this.filterDelegate.handleHtml(this.element, result.filters, this.viewContainerRef, result.options, [], + this.component, this.componentId, result.siteId); } this.element.classList.remove('core-disable-media-adapt'); @@ -400,7 +404,10 @@ export class CoreFormatTextDirective implements OnChanges { // Error getting the site. This probably means that there is no current site and no siteId was supplied. }).then((siteInstance: CoreSite) => { site = siteInstance; - result.siteId = site.getId(); + + if (site) { + result.siteId = site.getId(); + } if (this.contextLevel == 'course' && this.contextInstanceId <= 0) { this.contextInstanceId = site.getSiteHomeId(); @@ -418,14 +425,14 @@ export class CoreFormatTextDirective implements OnChanges { if (this.filter) { return this.filterHelper.getFiltersAndFormatText(this.text, this.contextLevel, this.contextInstanceId, - result.options, site.getId()).then((res) => { + result.options, result.siteId).then((res) => { result.filters = res.filters; return res.text; }); } else { - return this.filterProvider.formatText(this.text, result.options, [], site.getId()); + return this.filterProvider.formatText(this.text, result.options, [], result.siteId); } }).then((formatted) => { diff --git a/src/providers/file.ts b/src/providers/file.ts index 32dc56bc4..9dedaedbf 100644 --- a/src/providers/file.ts +++ b/src/providers/file.ts @@ -52,6 +52,7 @@ export class CoreFileProvider { static FORMATDATAURL = 1; static FORMATBINARYSTRING = 2; static FORMATARRAYBUFFER = 3; + static FORMATJSON = 4; // Folders. static SITESFOLDER = 'sites'; @@ -491,6 +492,7 @@ export class CoreFileProvider { * FORMATDATAURL * FORMATBINARYSTRING * FORMATARRAYBUFFER + * FORMATJSON * @return Promise to be resolved when the file is read. */ readFile(path: string, format: number = CoreFileProvider.FORMATTEXT): Promise { @@ -505,6 +507,16 @@ export class CoreFileProvider { return this.file.readAsBinaryString(this.basePath, path); case CoreFileProvider.FORMATARRAYBUFFER: return this.file.readAsArrayBuffer(this.basePath, path); + case CoreFileProvider.FORMATJSON: + return this.file.readAsText(this.basePath, path).then((text) => { + const parsed = this.textUtils.parseJSON(text, null); + + if (parsed == null && text != null) { + return Promise.reject('Error parsing JSON file: ' + path); + } + + return parsed; + }); default: return this.file.readAsText(this.basePath, path); } @@ -519,6 +531,7 @@ export class CoreFileProvider { * FORMATDATAURL * FORMATBINARYSTRING * FORMATARRAYBUFFER + * FORMATJSON * @return Promise to be resolved when the file is read. */ readFileData(fileData: any, format: number = CoreFileProvider.FORMATTEXT): Promise { @@ -531,7 +544,18 @@ export class CoreFileProvider { reader.onloadend = (evt): void => { const target = evt.target; // Convert to to be able to use non-standard properties. if (target.result !== undefined || target.result !== null) { - resolve(target.result); + if (format == CoreFileProvider.FORMATJSON) { + // Convert to object. + const parsed = this.textUtils.parseJSON(target.result, null); + + if (parsed == null) { + reject('Error parsing JSON file.'); + } + + resolve(parsed); + } else { + resolve(target.result); + } } else if (target.error !== undefined || target.error !== null) { reject(target.error); } else { @@ -728,19 +752,58 @@ export class CoreFileProvider { } } + /** + * Move a dir. + * + * @param originalPath Path to the dir to move. + * @param newPath New path of the dir. + * @param destDirExists Set it to true if you know the directory where to put the dir exists. If false, the function will + * try to create it (slower). + * @return Promise resolved when the entry is moved. + */ + moveDir(originalPath: string, newPath: string, destDirExists?: boolean): Promise { + return this.moveFileOrDir(originalPath, newPath, true, destDirExists); + } + /** * Move a file. * * @param originalPath Path to the file to move. * @param newPath New path of the file. + * @param destDirExists Set it to true if you know the directory where to put the file exists. If false, the function will + * try to create it (slower). * @return Promise resolved when the entry is moved. */ - moveFile(originalPath: string, newPath: string): Promise { + moveFile(originalPath: string, newPath: string, destDirExists?: boolean): Promise { + return this.moveFileOrDir(originalPath, newPath, false, destDirExists); + } + + /** + * Move a file/dir. + * + * @param originalPath Path to the file/dir to move. + * @param newPath New path of the file/dir. + * @param isDir Whether it's a dir or a file. + * @param destDirExists Set it to true if you know the directory where to put the file/dir exists. If false, the function will + * try to create it (slower). + * @return Promise resolved when the entry is moved. + */ + protected moveFileOrDir(originalPath: string, newPath: string, isDir?: boolean, destDirExists?: boolean): Promise { + const moveFn = isDir ? this.file.moveDir.bind(this.file) : this.file.moveFile.bind(this.file); + return this.init().then(() => { // Remove basePath if it's in the paths. originalPath = this.removeStartingSlash(originalPath.replace(this.basePath, '')); newPath = this.removeStartingSlash(newPath.replace(this.basePath, '')); + const newPathFileAndDir = this.getFileAndDirectoryFromPath(newPath); + + if (newPathFileAndDir.directory && !destDirExists) { + // Create the target directory if it doesn't exist. + return this.createDir(newPathFileAndDir.directory); + } + }).then(() => { + if (this.isHTMLAPI) { // In Cordova API we need to calculate the longest matching path to make it work. // The function this.file.moveFile('a/', 'b/c.ext', 'a/', 'b/d.ext') doesn't work. @@ -763,15 +826,15 @@ export class CoreFileProvider { } } - return this.file.moveFile(commonPath, originalPath, commonPath, newPath); + return moveFn(commonPath, originalPath, commonPath, newPath); } else { - return this.file.moveFile(this.basePath, originalPath, this.basePath, newPath).catch((error) => { + return moveFn(this.basePath, originalPath, this.basePath, newPath).catch((error) => { // The move can fail if the path has encoded characters. Try again if that's the case. const decodedOriginal = decodeURI(originalPath), decodedNew = decodeURI(newPath); if (decodedOriginal != originalPath || decodedNew != newPath) { - return this.file.moveFile(this.basePath, decodedOriginal, this.basePath, decodedNew); + return moveFn(this.basePath, decodedOriginal, this.basePath, decodedNew); } else { return Promise.reject(error); } @@ -780,16 +843,46 @@ export class CoreFileProvider { }); } + /** + * Copy a directory. + * + * @param from Path to the directory to move. + * @param to New path of the directory. + * @param destDirExists Set it to true if you know the directory where to put the dir exists. If false, the function will + * try to create it (slower). + * @return Promise resolved when the entry is copied. + */ + copyDir(from: string, to: string, destDirExists?: boolean): Promise { + return this.copyFileOrDir(from, to, true, destDirExists); + } + /** * Copy a file. * * @param from Path to the file to move. * @param to New path of the file. + * @param destDirExists Set it to true if you know the directory where to put the file exists. If false, the function will + * try to create it (slower). * @return Promise resolved when the entry is copied. */ - copyFile(from: string, to: string): Promise { + copyFile(from: string, to: string, destDirExists?: boolean): Promise { + return this.copyFileOrDir(from, to, false, destDirExists); + } + + /** + * Copy a file or a directory. + * + * @param from Path to the file/dir to move. + * @param to New path of the file/dir. + * @param isDir Whether it's a dir or a file. + * @param destDirExists Set it to true if you know the directory where to put the file/dir exists. If false, the function will + * try to create it (slower). + * @return Promise resolved when the entry is copied. + */ + protected copyFileOrDir(from: string, to: string, isDir?: boolean, destDirExists?: boolean): Promise { let fromFileAndDir, toFileAndDir; + const copyFn = isDir ? this.file.copyDir.bind(this.file) : this.file.copyFile.bind(this.file); return this.init().then(() => { // Paths cannot start with "/". Remove basePath if present. @@ -799,7 +892,7 @@ export class CoreFileProvider { fromFileAndDir = this.getFileAndDirectoryFromPath(from); toFileAndDir = this.getFileAndDirectoryFromPath(to); - if (toFileAndDir.directory) { + if (toFileAndDir.directory && !destDirExists) { // Create the target directory if it doesn't exist. return this.createDir(toFileAndDir.directory); } @@ -809,15 +902,15 @@ export class CoreFileProvider { const fromDir = this.textUtils.concatenatePaths(this.basePath, fromFileAndDir.directory), toDir = this.textUtils.concatenatePaths(this.basePath, toFileAndDir.directory); - return this.file.copyFile(fromDir, fromFileAndDir.name, toDir, toFileAndDir.name); + return copyFn(fromDir, fromFileAndDir.name, toDir, toFileAndDir.name); } else { - return this.file.copyFile(this.basePath, from, this.basePath, to).catch((error) => { + return copyFn(this.basePath, from, this.basePath, to).catch((error) => { // The copy can fail if the path has encoded characters. Try again if that's the case. const decodedFrom = decodeURI(from), decodedTo = decodeURI(to); if (from != decodedFrom || to != decodedTo) { - return this.file.copyFile(this.basePath, decodedFrom, this.basePath, decodedTo); + return copyFn(this.basePath, decodedFrom, this.basePath, decodedTo); } else { return Promise.reject(error); } @@ -898,11 +991,26 @@ export class CoreFileProvider { * @param destFolder Path to the destination folder. If not defined, a new folder will be created with the * same location and name as the ZIP file (without extension). * @param onProgress Function to call on progress. + * @param recreateDir Delete the dest directory before unzipping. Defaults to true. * @return Promise resolved when the file is unzipped. */ - unzipFile(path: string, destFolder?: string, onProgress?: Function): Promise { + unzipFile(path: string, destFolder?: string, onProgress?: Function, recreateDir: boolean = true): Promise { // Get the source file. - return this.getFile(path).then((fileEntry) => { + let fileEntry: FileEntry; + + return this.getFile(path).then((fe) => { + fileEntry = fe; + + if (destFolder && recreateDir) { + // Make sure the dest dir doesn't exist already. + return this.removeDir(destFolder).catch(() => { + // Ignore errors. + }).then(() => { + // Now create the dir, otherwise if any of the ancestor dirs doesn't exist the unzip would fail. + return this.createDir(destFolder); + }); + } + }).then(() => { // If destFolder is not set, use same location as ZIP file. We need to use absolute paths (including basePath). destFolder = this.addBasePathIfNeeded(destFolder || this.mimeUtils.removeExtension(path)); @@ -1146,4 +1254,19 @@ export class CoreFileProvider { isFileInAppFolder(path: string): boolean { return path.indexOf(this.basePath) != -1; } + + /** + * Get the full path to the www folder at runtime. + * + * @return Path. + */ + getWWWPath(): string { + const position = window.location.href.indexOf('index.html'); + + if (position != -1) { + return window.location.href.substr(0, position); + } + + return window.location.href; + } } diff --git a/src/providers/filepool.ts b/src/providers/filepool.ts index b025fbbc0..9e8fa5790 100644 --- a/src/providers/filepool.ts +++ b/src/providers/filepool.ts @@ -21,7 +21,7 @@ import { CoreInitDelegate } from './init'; import { CoreLoggerProvider } from './logger'; import { CorePluginFileDelegate } from './plugin-file-delegate'; import { CoreSitesProvider, CoreSiteSchema } from './sites'; -import { CoreWSProvider } from './ws'; +import { CoreWSProvider, CoreWSExternalFile } from './ws'; import { CoreDomUtilsProvider } from './utils/dom'; import { CoreMimetypeUtilsProvider } from './utils/mimetype'; import { CoreTextUtilsProvider } from './utils/text'; @@ -473,8 +473,8 @@ export class CoreFilepoolProvider { * downloading a file automatically does this. Note that this method does not check if the file exists in the pool. */ addFileLinkByUrl(siteId: string, fileUrl: string, component: string, componentId?: string | number): Promise { - return this.fixPluginfileURL(siteId, fileUrl).then((fileUrl) => { - const fileId = this.getFileIdByUrl(fileUrl); + return this.fixPluginfileURL(siteId, fileUrl).then((file) => { + const fileId = this.getFileIdByUrl(file.fileurl); return this.addFileLink(siteId, fileId, component, componentId); }); @@ -605,11 +605,12 @@ export class CoreFilepoolProvider { * @param priority The priority this file should get in the queue (range 0-999). * @param options Extra options (isexternalfile, repositorytype). * @param revision File revision. If not defined, it will be calculated using the URL. + * @param alreadyFixed Whether the URL has already been fixed. * @return Resolved on success. */ addToQueueByUrl(siteId: string, fileUrl: string, component?: string, componentId?: string | number, timemodified: number = 0, - filePath?: string, onProgress?: (event: any) => any, priority: number = 0, options: any = {}, revision?: number) - : Promise { + filePath?: string, onProgress?: (event: any) => any, priority: number = 0, options: any = {}, revision?: number, + alreadyFixed?: boolean): Promise { let fileId, link, queueDeferred; @@ -623,94 +624,102 @@ export class CoreFilepoolProvider { return Promise.reject(null); } - return this.fixPluginfileURL(siteId, fileUrl).then((fileUrl) => { - const primaryKey = { siteId: siteId, fileId: fileId }; + if (alreadyFixed) { + // Already fixed, if we reached here it means it can be downloaded. + return {fileurl: fileUrl}; + } else { + return this.fixPluginfileURL(siteId, fileUrl); + } + }).then((file) => { - revision = revision || this.getRevisionFromUrl(fileUrl); - fileId = this.getFileIdByUrl(fileUrl); + fileUrl = file.fileurl; + timemodified = file.timemodified || timemodified; + revision = revision || this.getRevisionFromUrl(fileUrl); + fileId = this.getFileIdByUrl(fileUrl); - // Set up the component. - if (typeof component != 'undefined') { - link = { - component: component, - componentId: this.fixComponentId(componentId) - }; - } + const primaryKey = { siteId: siteId, fileId: fileId }; - // Retrieve the queue deferred now if it exists. - // This is to prevent errors if file is removed from queue while we're checking if the file is in queue. - queueDeferred = this.getQueueDeferred(siteId, fileId, false, onProgress); + // Set up the component. + if (typeof component != 'undefined') { + link = { + component: component, + componentId: this.fixComponentId(componentId) + }; + } - return this.hasFileInQueue(siteId, fileId).then((entry: CoreFilepoolQueueEntry) => { - const newData: any = {}; - let foundLink = false; + // Retrieve the queue deferred now if it exists. + // This is to prevent errors if file is removed from queue while we're checking if the file is in queue. + queueDeferred = this.getQueueDeferred(siteId, fileId, false, onProgress); - if (entry) { - // We already have the file in queue, we update the priority and links. - if (entry.priority < priority) { - newData.priority = priority; - } - if (revision && entry.revision !== revision) { - newData.revision = revision; - } - if (timemodified && entry.timemodified !== timemodified) { - newData.timemodified = timemodified; - } - if (filePath && entry.path !== filePath) { - newData.path = filePath; - } - if (entry.isexternalfile !== options.isexternalfile && (entry.isexternalfile || options.isexternalfile)) { - newData.isexternalfile = options.isexternalfile; - } - if (entry.repositorytype !== options.repositorytype && (entry.repositorytype || options.repositorytype)) { - newData.repositorytype = options.repositorytype; - } + return this.hasFileInQueue(siteId, fileId).then((entry: CoreFilepoolQueueEntry) => { + const newData: any = {}; + let foundLink = false; - if (link) { - // We need to add the new link if it does not exist yet. - if (entry.links && entry.links.length) { - for (const i in entry.links) { - const fileLink = entry.links[i]; - if (fileLink.component == link.component && fileLink.componentId == link.componentId) { - foundLink = true; - break; - } + if (entry) { + // We already have the file in queue, we update the priority and links. + if (entry.priority < priority) { + newData.priority = priority; + } + if (revision && entry.revision !== revision) { + newData.revision = revision; + } + if (timemodified && entry.timemodified !== timemodified) { + newData.timemodified = timemodified; + } + if (filePath && entry.path !== filePath) { + newData.path = filePath; + } + if (entry.isexternalfile !== options.isexternalfile && (entry.isexternalfile || options.isexternalfile)) { + newData.isexternalfile = options.isexternalfile; + } + if (entry.repositorytype !== options.repositorytype && (entry.repositorytype || options.repositorytype)) { + newData.repositorytype = options.repositorytype; + } + + if (link) { + // We need to add the new link if it does not exist yet. + if (entry.links && entry.links.length) { + for (const i in entry.links) { + const fileLink = entry.links[i]; + if (fileLink.component == link.component && fileLink.componentId == link.componentId) { + foundLink = true; + break; } } - - if (!foundLink) { - newData.links = entry.links || []; - newData.links.push(link); - newData.links = JSON.stringify(entry.links); - } } - if (Object.keys(newData).length) { - // Update only when required. - this.logger.debug(`Updating file ${fileId} which is already in queue`); - - return this.appDB.updateRecords(this.QUEUE_TABLE, newData, primaryKey).then(() => { - return this.getQueuePromise(siteId, fileId, true, onProgress); - }); + if (!foundLink) { + newData.links = entry.links || []; + newData.links.push(link); + newData.links = JSON.stringify(entry.links); } - - this.logger.debug(`File ${fileId} already in queue and does not require update`); - if (queueDeferred) { - // If we were able to retrieve the queue deferred before, we use that one. - return queueDeferred.promise; - } else { - // Create a new deferred and return its promise. - return this.getQueuePromise(siteId, fileId, true, onProgress); - } - } else { - return this.addToQueue( - siteId, fileId, fileUrl, priority, revision, timemodified, filePath, onProgress, options, link); } - }, () => { - // Unsure why we could not get the record, let's add to the queue anyway. + + if (Object.keys(newData).length) { + // Update only when required. + this.logger.debug(`Updating file ${fileId} which is already in queue`); + + return this.appDB.updateRecords(this.QUEUE_TABLE, newData, primaryKey).then(() => { + return this.getQueuePromise(siteId, fileId, true, onProgress); + }); + } + + this.logger.debug(`File ${fileId} already in queue and does not require update`); + if (queueDeferred) { + // If we were able to retrieve the queue deferred before, we use that one. + return queueDeferred.promise; + } else { + // Create a new deferred and return its promise. + return this.getQueuePromise(siteId, fileId, true, onProgress); + } + } else { return this.addToQueue( siteId, fileId, fileUrl, priority, revision, timemodified, filePath, onProgress, options, link); - }); + } + }, () => { + // Unsure why we could not get the record, let's add to the queue anyway. + return this.addToQueue( + siteId, fileId, fileUrl, priority, revision, timemodified, filePath, onProgress, options, link); }); }); } @@ -719,7 +728,7 @@ export class CoreFilepoolProvider { * Adds a file to the queue if the size is allowed to be downloaded. * * @param siteId The site ID. - * @param fileUrl The absolute URL to the file. + * @param fileUrl The absolute URL to the file, already fixed. * @param component The component to link the file to. * @param componentId An ID to use in conjunction with the component. * @param timemodified The time this file was modified. @@ -760,18 +769,18 @@ export class CoreFilepoolProvider { // Check if the file should be downloaded. if (sizeUnknown) { if (downloadUnknown && isWifi) { - return this.addToQueueByUrl( - siteId, fileUrl, component, componentId, timemodified, undefined, undefined, 0, options, revision); + return this.addToQueueByUrl(siteId, fileUrl, component, componentId, timemodified, undefined, undefined, + 0, options, revision, true); } - } else if (size <= this.DOWNLOAD_THRESHOLD || (isWifi && size <= this.WIFI_DOWNLOAD_THRESHOLD)) { - return this.addToQueueByUrl( - siteId, fileUrl, component, componentId, timemodified, undefined, undefined, 0, options, revision); + } else if (this.shouldDownload(size)) { + return this.addToQueueByUrl(siteId, fileUrl, component, componentId, timemodified, undefined, undefined, 0, + options, revision, true); } }); } else { // No need to check size, just add it to the queue. return this.addToQueueByUrl(siteId, fileUrl, component, componentId, timemodified, undefined, undefined, 0, options, - revision); + revision, true); } } @@ -938,7 +947,13 @@ export class CoreFilepoolProvider { return Promise.reject(null); } - return this.wsProvider.downloadFile(fileUrl, filePath, addExtension, onProgress).then((fileEntry) => { + let fileEntry; + + return this.wsProvider.downloadFile(fileUrl, filePath, addExtension, onProgress).then((entry) => { + fileEntry = entry; + + return this.pluginFileDelegate.treatDownloadedFile(fileUrl, fileEntry, siteId); + }).then(() => { const data: CoreFilepoolFileEntry = poolFileObject || {}; data.downloadTime = Date.now(); @@ -1157,8 +1172,10 @@ export class CoreFilepoolProvider { promise; if (this.fileProvider.isAvailable()) { - return this.fixPluginfileURL(siteId, fileUrl).then((fixedUrl) => { - fileUrl = fixedUrl; + return this.fixPluginfileURL(siteId, fileUrl).then((file) => { + + fileUrl = file.fileurl; + timemodified = file.timemodified || timemodified; options = Object.assign({}, options); // Create a copy to prevent modifying the original object. options.timemodified = timemodified || 0; @@ -1222,6 +1239,59 @@ export class CoreFilepoolProvider { } } + /** + * Extract the downloadable URLs from an HTML code. + * + * @param html HTML code. + * @return List of file urls. + */ + extractDownloadableFilesFromHtml(html: string): string[] { + let urls = [], + elements; + + const element = this.domUtils.convertToElement(html); + elements = element.querySelectorAll('a, img, audio, video, source, track'); + + for (let i = 0; i < elements.length; i++) { + const element = elements[i]; + let url = element.tagName === 'A' ? element.href : element.src; + + if (url && this.urlUtils.isDownloadableUrl(url) && urls.indexOf(url) == -1) { + urls.push(url); + } + + // Treat video poster. + if (element.tagName == 'VIDEO' && element.getAttribute('poster')) { + url = element.getAttribute('poster'); + if (url && this.urlUtils.isDownloadableUrl(url) && urls.indexOf(url) == -1) { + urls.push(url); + } + } + } + + // Now get other files from plugin file handlers. + urls = urls.concat(this.pluginFileDelegate.getDownloadableFilesFromHTML(element)); + + return urls; + } + + /** + * Extract the downloadable URLs from an HTML code and returns them in fake file objects. + * + * @param html HTML code. + * @return List of fake file objects with file URLs. + */ + extractDownloadableFilesFromHtmlAsFakeFileObjects(html: string): any[] { + const urls = this.extractDownloadableFilesFromHtml(html); + + // Convert them to fake file objects. + return urls.map((url) => { + return { + fileurl: url + }; + }); + } + /** * Fill Missing Extension In the File Object if needed. * This is to migrate from old versions. @@ -1313,15 +1383,24 @@ export class CoreFilepoolProvider { } /** - * Add the wstoken url and points to the correct script. + * Check whether the file can be downloaded, add the wstoken url and points to the correct script. * * @param siteId The site ID. * @param fileUrl The file URL. - * @return Resolved with fixed URL on success, rejected otherwise. + * @param timemodified The timemodified of the file. + * @return Promise resolved with the file data to use. */ - protected fixPluginfileURL(siteId: string, fileUrl: string): Promise { - return this.sitesProvider.getSite(siteId).then((site) => { - return site.checkAndFixPluginfileURL(fileUrl); + protected fixPluginfileURL(siteId: string, fileUrl: string, timemodified: number = 0): Promise { + + return this.pluginFileDelegate.getDownloadableFile({fileurl: fileUrl, timemodified: timemodified}).then((file) => { + + return this.sitesProvider.getSite(siteId).then((site) => { + return site.checkAndFixPluginfileURL(file.fileurl); + }).then((fixedUrl) => { + file.fileurl = fixedUrl; + + return file; + }); }); } @@ -1351,8 +1430,8 @@ export class CoreFilepoolProvider { */ getDirectoryUrlByUrl(siteId: string, fileUrl: string): Promise { if (this.fileProvider.isAvailable()) { - return this.fixPluginfileURL(siteId, fileUrl).then((fileUrl) => { - const fileId = this.getFileIdByUrl(fileUrl), + return this.fixPluginfileURL(siteId, fileUrl).then((file) => { + const fileId = this.getFileIdByUrl(file.fileurl), filePath = this.getFilePath(siteId, fileId, ''); // No extension, the function will return a string. return this.fileProvider.getDir(filePath).then((dirEntry) => { @@ -1394,8 +1473,8 @@ export class CoreFilepoolProvider { * @return Promise resolved with event name. */ getFileEventNameByUrl(siteId: string, fileUrl: string): Promise { - return this.fixPluginfileURL(siteId, fileUrl).then((fileUrl) => { - const fileId = this.getFileIdByUrl(fileUrl); + return this.fixPluginfileURL(siteId, fileUrl).then((file) => { + const fileId = this.getFileIdByUrl(file.fileurl); return this.getFileEventName(siteId, fileId); }); @@ -1490,8 +1569,8 @@ export class CoreFilepoolProvider { * @return Promise resolved with the path to the file relative to storage root. */ getFilePathByUrl(siteId: string, fileUrl: string): Promise { - return this.fixPluginfileURL(siteId, fileUrl).then((fileUrl) => { - const fileId = this.getFileIdByUrl(fileUrl); + return this.fixPluginfileURL(siteId, fileUrl).then((file) => { + const fileId = this.getFileIdByUrl(file.fileurl); return this.getFilePath(siteId, fileId); }); @@ -1587,8 +1666,10 @@ export class CoreFilepoolProvider { : Promise { let fileId; - return this.fixPluginfileURL(siteId, fileUrl).then((fixedUrl) => { - fileUrl = fixedUrl; + return this.fixPluginfileURL(siteId, fileUrl, timemodified).then((file) => { + + fileUrl = file.fileurl; + timemodified = file.timemodified || timemodified; revision = revision || this.getRevisionFromUrl(fileUrl); fileId = this.getFileIdByUrl(fileUrl); @@ -1618,6 +1699,8 @@ export class CoreFilepoolProvider { }); }); }); + }, () => { + return CoreConstants.NOT_DOWNLOADABLE; }); } @@ -1655,8 +1738,10 @@ export class CoreFilepoolProvider { }); }; - return this.fixPluginfileURL(siteId, fileUrl).then((fixedUrl) => { - fileUrl = fixedUrl; + return this.fixPluginfileURL(siteId, fileUrl, timemodified).then((file) => { + + fileUrl = file.fileurl; + timemodified = file.timemodified || timemodified; revision = revision || this.getRevisionFromUrl(fileUrl); fileId = this.getFileIdByUrl(fileUrl); @@ -1779,8 +1864,8 @@ export class CoreFilepoolProvider { */ getInternalUrlByUrl(siteId: string, fileUrl: string): Promise { if (this.fileProvider.isAvailable()) { - return this.fixPluginfileURL(siteId, fileUrl).then((fileUrl) => { - const fileId = this.getFileIdByUrl(fileUrl); + return this.fixPluginfileURL(siteId, fileUrl).then((file) => { + const fileId = this.getFileIdByUrl(file.fileurl); return this.getInternalUrlById(siteId, fileId); }); @@ -1843,8 +1928,8 @@ export class CoreFilepoolProvider { * @return Promise resolved with the path of the package. */ getPackageDirPathByUrl(siteId: string, url: string): Promise { - return this.fixPluginfileURL(siteId, url).then((fixedUrl) => { - const dirName = this.getPackageDirNameByUrl(fixedUrl); + return this.fixPluginfileURL(siteId, url).then((file) => { + const dirName = this.getPackageDirNameByUrl(file.fileurl); return this.getFilePath(siteId, dirName, ''); }); @@ -1859,8 +1944,8 @@ export class CoreFilepoolProvider { */ getPackageDirUrlByUrl(siteId: string, url: string): Promise { if (this.fileProvider.isAvailable()) { - return this.fixPluginfileURL(siteId, url).then((fixedUrl) => { - const dirName = this.getPackageDirNameByUrl(fixedUrl), + return this.fixPluginfileURL(siteId, url).then((file) => { + const dirName = this.getPackageDirNameByUrl(file.fileurl), dirPath = this.getFilePath(siteId, dirName, ''); // No extension, the function will return a string. return this.fileProvider.getDir(dirPath).then((dirEntry) => { @@ -2270,8 +2355,8 @@ export class CoreFilepoolProvider { * Please note that, if a file is stale, the user will be presented the stale file if there is no network access. */ invalidateFileByUrl(siteId: string, fileUrl: string): Promise { - return this.fixPluginfileURL(siteId, fileUrl).then((fileUrl) => { - const fileId = this.getFileIdByUrl(fileUrl); + return this.fixPluginfileURL(siteId, fileUrl).then((file) => { + const fileId = this.getFileIdByUrl(file.fileurl); return this.sitesProvider.getSiteDb(siteId).then((db) => { return db.updateRecords(this.FILES_TABLE, { stale: 1 }, { fileId: fileId }); @@ -2318,8 +2403,8 @@ export class CoreFilepoolProvider { * @param Promise resolved if file is downloading, rejected otherwise. */ isFileDownloadingByUrl(siteId: string, fileUrl: string): Promise { - return this.fixPluginfileURL(siteId, fileUrl).then((fileUrl) => { - const fileId = this.getFileIdByUrl(fileUrl); + return this.fixPluginfileURL(siteId, fileUrl).then((file) => { + const fileId = this.getFileIdByUrl(file.fileurl); return this.hasFileInQueue(siteId, fileId); }); @@ -2614,7 +2699,22 @@ export class CoreFilepoolProvider { protected removeFileById(siteId: string, fileId: string): Promise { return this.sitesProvider.getSiteDb(siteId).then((db) => { // Get the path to the file first since it relies on the file object stored in the pool. - return Promise.resolve(this.getFilePath(siteId, fileId)).then((path) => { + // Don't use getFilePath to prevent performing 2 DB requests. + let path = this.getFilepoolFolderPath(siteId) + '/' + fileId, + fileUrl; + + return this.hasFileInPool(siteId, fileId).then((entry) => { + fileUrl = entry.url; + + if (entry.extension) { + path += '.' + entry.extension; + } + + return path; + }).catch(() => { + // If file not found, use the path without extension. + return path; + }).then((path) => { const promises = []; // Remove entry from filepool store. @@ -2636,6 +2736,10 @@ export class CoreFilepoolProvider { return Promise.all(promises).then(() => { this.notifyFileDeleted(siteId, fileId); + + return this.pluginFileDelegate.fileDeleted(fileUrl, path, siteId).catch((error) => { + // Ignore errors. + }); }); }); }); @@ -2667,8 +2771,8 @@ export class CoreFilepoolProvider { * @return Resolved on success, rejected on failure. */ removeFileByUrl(siteId: string, fileUrl: string): Promise { - return this.fixPluginfileURL(siteId, fileUrl).then((fileUrl) => { - const fileId = this.getFileIdByUrl(fileUrl); + return this.fixPluginfileURL(siteId, fileUrl).then((file) => { + const fileId = this.getFileIdByUrl(file.fileurl); return this.removeFileById(siteId, fileId); }); @@ -2728,6 +2832,16 @@ export class CoreFilepoolProvider { }); } + /** + * Check if a file should be downloaded based on its size. + * + * @param size File size. + * @return Whether file should be downloaded. + */ + shouldDownload(size: number): boolean { + return size <= this.DOWNLOAD_THRESHOLD || (this.appProvider.isWifi() && size <= this.WIFI_DOWNLOAD_THRESHOLD); + } + /** * Convenience function to check if a file should be downloaded before opening it. * diff --git a/src/providers/plugin-file-delegate.ts b/src/providers/plugin-file-delegate.ts index b6b9851b0..17354d4b5 100644 --- a/src/providers/plugin-file-delegate.ts +++ b/src/providers/plugin-file-delegate.ts @@ -13,21 +13,23 @@ // limitations under the License. import { Injectable } from '@angular/core'; +import { CoreEventsProvider } from './events'; import { CoreLoggerProvider } from './logger'; +import { CoreSitesProvider } from './sites'; +import { CoreWSExternalFile } from '@providers/ws'; +import { FileEntry } from '@ionic-native/file'; +import { CoreDelegate, CoreDelegateHandler } from '@classes/delegate'; /** * Interface that all plugin file handlers must implement. */ -export interface CorePluginFileHandler { - /** - * A name to identify the handler. - */ - name: string; +export interface CorePluginFileHandler extends CoreDelegateHandler { /** * The "component" of the handler. It should match the "component" of pluginfile URLs. + * It is used to treat revision from URLs. */ - component: string; + component?: string; /** * Return the RegExp to match the revision on pluginfile URLs. @@ -44,30 +46,125 @@ export interface CorePluginFileHandler { * @return String to remove the revision on pluginfile url. */ getComponentRevisionReplace?(args: string[]): string; + + /** + * React to a file being deleted. + * + * @param fileUrl The file URL used to download the file. + * @param path The path of the deleted file. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + fileDeleted?(fileUrl: string, path: string, siteId?: string): Promise; + + /** + * Check whether a file can be downloaded. If so, return the file to download. + * + * @param file The file data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the file to use. Rejected if cannot download. + */ + getDownloadableFile?(file: CoreWSExternalFile, siteId?: string): Promise; + + /** + * Given an HTML element, get the URLs of the files that should be downloaded and weren't treated by + * CoreFilepoolProvider.extractDownloadableFilesFromHtml. + * + * @param container Container where to get the URLs from. + * @return {string[]} List of URLs. + */ + getDownloadableFilesFromHTML?(container: HTMLElement): string[]; + + /** + * Get a file size. + * + * @param file The file data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the size. + */ + getFileSize?(file: CoreWSExternalFile, siteId?: string): Promise; + + /** + * Check whether the file should be treated by this handler. It is used in functions where the component isn't used. + * + * @param file The file data. + * @return Whether the file should be treated by this handler. + */ + shouldHandleFile?(file: CoreWSExternalFile): boolean; + + /** + * Treat a downloaded file. + * + * @param fileUrl The file URL used to download the file. + * @param file The file entry of the downloaded file. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + treatDownloadedFile?(fileUrl: string, file: FileEntry, siteId?: string): Promise; } /** * Delegate to register pluginfile information handlers. */ @Injectable() -export class CorePluginFileDelegate { - protected logger; - protected handlers: { [s: string]: CorePluginFileHandler } = {}; +export class CorePluginFileDelegate extends CoreDelegate { + protected handlerNameProperty = 'component'; - constructor(logger: CoreLoggerProvider) { - this.logger = logger.getInstance('CorePluginFileDelegate'); + constructor(loggerProvider: CoreLoggerProvider, + sitesProvider: CoreSitesProvider, + eventsProvider: CoreEventsProvider) { + super('CorePluginFileDelegate', loggerProvider, sitesProvider, eventsProvider); } /** - * Get the handler for a certain pluginfile url. + * React to a file being deleted. * - * @param component Component of the plugin. - * @return Handler. Undefined if no handler found for the plugin. + * @param fileUrl The file URL used to download the file. + * @param path The path of the deleted file. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. */ - protected getPluginHandler(component: string): CorePluginFileHandler { - if (typeof this.handlers[component] != 'undefined') { - return this.handlers[component]; + fileDeleted(fileUrl: string, path: string, siteId?: string): Promise { + const handler = this.getHandlerForFile({fileurl: fileUrl}); + + if (handler && handler.fileDeleted) { + return handler.fileDeleted(fileUrl, path, siteId); } + + return Promise.resolve(); + } + + /** + * Check whether a file can be downloaded. If so, return the file to download. + * + * @param file The file data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the file to use. Rejected if cannot download. + */ + getDownloadableFile(file: CoreWSExternalFile, siteId?: string): Promise { + const handler = this.getHandlerForFile(file); + + return this.getHandlerDownloadableFile(file, handler, siteId); + } + + /** + * Check whether a file can be downloaded. If so, return the file to download. + * + * @param file The file data. + * @param handler The handler to use. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the file to use. Rejected if cannot download. + */ + protected getHandlerDownloadableFile(file: CoreWSExternalFile, handler: CorePluginFileHandler, siteId?: string) + : Promise { + + if (handler && handler.getDownloadableFile) { + return handler.getDownloadableFile(file, siteId).then((newFile) => { + return newFile || file; + }); + } + + return Promise.resolve(file); } /** @@ -78,7 +175,7 @@ export class CorePluginFileDelegate { */ getComponentRevisionRegExp(args: string[]): RegExp { // Get handler based on component (args[1]). - const handler = this.getPluginHandler(args[1]); + const handler = this.getHandler(args[1], true); if (handler && handler.getComponentRevisionRegExp) { return handler.getComponentRevisionRegExp(args); @@ -86,22 +183,96 @@ export class CorePluginFileDelegate { } /** - * Register a handler. + * Given an HTML element, get the URLs of the files that should be downloaded and weren't treated by + * CoreFilepoolProvider.extractDownloadableFilesFromHtml. * - * @param handler The handler to register. - * @return True if registered successfully, false otherwise. + * @param container Container where to get the URLs from. + * @return List of URLs. */ - registerHandler(handler: CorePluginFileHandler): boolean { - if (typeof this.handlers[handler.component] !== 'undefined') { - this.logger.log(`Handler '${handler.component}' already registered`); + getDownloadableFilesFromHTML(container: HTMLElement): string[] { + let files = []; - return false; + for (const component in this.enabledHandlers) { + const handler = this.enabledHandlers[component]; + + if (handler && handler.getDownloadableFilesFromHTML) { + files = files.concat(handler.getDownloadableFilesFromHTML(container)); + } } - this.logger.log(`Registered handler '${handler.component}'`); - this.handlers[handler.component] = handler; + return files; + } - return true; + /** + * Sum the filesizes from a list of files checking if the size will be partial or totally calculated. + * + * @param files List of files to sum its filesize. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with file size and a boolean to indicate if it is the total size or only partial. + */ + getFilesSize(files: CoreWSExternalFile[], siteId?: string): Promise<{ size: number, total: boolean }> { + const promises = [], + result = { + size: 0, + total: true + }; + + files.forEach((file) => { + promises.push(this.getFileSize(file, siteId).then((size) => { + if (typeof size == 'undefined') { + // We don't have the file size, cannot calculate its total size. + result.total = false; + } else { + result.size += size; + } + })); + }); + + return Promise.all(promises).then(() => { + return result; + }); + } + + /** + * Get a file size. + * + * @param file The file data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the size. + */ + getFileSize(file: CoreWSExternalFile, siteId?: string): Promise { + const handler = this.getHandlerForFile(file); + + // First of all check if file can be downloaded. + return this.getHandlerDownloadableFile(file, handler, siteId).then((file) => { + if (!file) { + return 0; + } + + if (handler && handler.getFileSize) { + return handler.getFileSize(file, siteId).catch(() => { + return file.filesize; + }); + } + + return Promise.resolve(file.filesize); + }); + } + + /** + * Get a handler to treat a certain file. + * + * @param file File data. + * @return Handler. + */ + protected getHandlerForFile(file: CoreWSExternalFile): CorePluginFileHandler { + for (const component in this.enabledHandlers) { + const handler = this.enabledHandlers[component]; + + if (handler && handler.shouldHandleFile && handler.shouldHandleFile(file)) { + return handler; + } + } } /** @@ -113,7 +284,7 @@ export class CorePluginFileDelegate { */ removeRevisionFromUrl(url: string, args: string[]): string { // Get handler based on component (args[1]). - const handler = this.getPluginHandler(args[1]); + const handler = this.getHandler(args[1], true); if (handler && handler.getComponentRevisionRegExp && handler.getComponentRevisionReplace) { const revisionRegex = handler.getComponentRevisionRegExp(args); @@ -124,4 +295,22 @@ export class CorePluginFileDelegate { return url; } + + /** + * Treat a downloaded file. + * + * @param fileUrl The file URL used to download the file. + * @param file The file entry of the downloaded file. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + treatDownloadedFile(fileUrl: string, file: FileEntry, siteId?: string): Promise { + const handler = this.getHandlerForFile({fileurl: fileUrl}); + + if (handler && handler.treatDownloadedFile) { + return handler.treatDownloadedFile(fileUrl, file, siteId); + } + + return Promise.resolve(); + } } diff --git a/src/providers/utils/dom.ts b/src/providers/utils/dom.ts index c8246dd1d..6bc976dd6 100644 --- a/src/providers/utils/dom.ts +++ b/src/providers/utils/dom.ts @@ -22,6 +22,7 @@ import { TranslateService } from '@ngx-translate/core'; import { CoreTextUtilsProvider } from './text'; import { CoreAppProvider } from '../app'; import { CoreConfigProvider } from '../config'; +import { CoreLoggerProvider } from '../logger'; import { CoreUrlUtilsProvider } from './url'; import { CoreFileProvider } from '@providers/file'; import { CoreConstants } from '@core/constants'; @@ -61,12 +62,24 @@ export class CoreDomUtilsProvider { protected lastInstanceId = 0; protected debugDisplay = false; // Whether to display debug messages. Store it in a variable to make it synchronous. protected displayedAlerts = {}; // To prevent duplicated alerts. + protected logger; - constructor(private translate: TranslateService, private loadingCtrl: LoadingController, private toastCtrl: ToastController, - private alertCtrl: AlertController, private textUtils: CoreTextUtilsProvider, private appProvider: CoreAppProvider, - private platform: Platform, private configProvider: CoreConfigProvider, private urlUtils: CoreUrlUtilsProvider, - private modalCtrl: ModalController, private sanitizer: DomSanitizer, private popoverCtrl: PopoverController, - private fileProvider: CoreFileProvider) { + constructor(private translate: TranslateService, + private loadingCtrl: LoadingController, + private toastCtrl: ToastController, + private alertCtrl: AlertController, + private textUtils: CoreTextUtilsProvider, + private appProvider: CoreAppProvider, + private platform: Platform, + private configProvider: CoreConfigProvider, + private urlUtils: CoreUrlUtilsProvider, + private modalCtrl: ModalController, + private sanitizer: DomSanitizer, + private popoverCtrl: PopoverController, + private fileProvider: CoreFileProvider, + loggerProvider: CoreLoggerProvider) { + + this.logger = loggerProvider.getInstance('CoreDomUtilsProvider'); // Check if debug messages should be displayed. configProvider.get(CoreConstants.SETTINGS_DEBUG_DISPLAY, false).then((debugDisplay) => { @@ -250,8 +263,12 @@ export class CoreDomUtilsProvider { * * @param html HTML code. * @return List of file urls. + * @deprecated since 3.8. Use CoreFilepoolProvider.extractDownloadableFilesFromHtml instead. */ extractDownloadableFilesFromHtml(html: string): string[] { + this.logger.error('The function extractDownloadableFilesFromHtml has been moved to CoreFilepoolProvider.' + + ' Please use that function instead of this one.'); + const urls = []; let elements; @@ -283,6 +300,7 @@ export class CoreDomUtilsProvider { * * @param html HTML code. * @return List of fake file objects with file URLs. + * @deprecated since 3.8. Use CoreFilepoolProvider.extractDownloadableFilesFromHtmlAsFakeFileObjects instead. */ extractDownloadableFilesFromHtmlAsFakeFileObjects(html: string): any[] { const urls = this.extractDownloadableFilesFromHtml(html); @@ -372,7 +390,7 @@ export class CoreDomUtilsProvider { * @return Formatted size. If size is not valid, returns an empty string. */ formatPixelsSize(size: any): string { - if (typeof size == 'string' && (size.indexOf('px') > -1 || size.indexOf('%') > -1)) { + if (typeof size == 'string' && (size.indexOf('px') > -1 || size.indexOf('%') > -1 || size == 'auto' || size == 'initial')) { // It seems to be a valid size. return size; } diff --git a/src/providers/utils/iframe.ts b/src/providers/utils/iframe.ts index f02aea2b6..e60c19b58 100644 --- a/src/providers/utils/iframe.ts +++ b/src/providers/utils/iframe.ts @@ -319,58 +319,86 @@ export class CoreIframeUtilsProvider { while (el && el.tagName !== 'A') { el = el.parentElement; } - if (!el || el.tagName !== 'A') { - return; - } - const link = el; - const scheme = this.urlUtils.getUrlScheme(link.href); - if (!link.href || (scheme && scheme == 'javascript')) { - // Links with no URL and Javascript links are ignored. + const link = el; + if (!link || link.treated) { return; } - if (scheme && scheme != 'file' && scheme != 'filesystem') { - // Scheme suggests it's an external resource. - event.preventDefault(); + // Add click listener to the link, this way if the iframe has added a listener to the link it will be executed first. + link.treated = true; + link.addEventListener('click', this.linkClicked.bind(this, element, link)); + }, { + capture: true // Use capture to fix this listener not called if the element clicked is too deep in the DOM. + }); + } - const frameSrc = element.src || element.data, - frameScheme = this.urlUtils.getUrlScheme(frameSrc); + /** + * A link inside a frame was clicked. + * + * @param element Frame element. + * @param link Link clicked. + * @param event Click event. + */ + protected linkClicked(element: HTMLFrameElement | HTMLObjectElement, link: HTMLAnchorElement, event: Event): void { + if (event.defaultPrevented) { + // Event already prevented by some other code. + return; + } - // If the frame is not local, check the target to identify how to treat the link. - if (frameScheme && frameScheme != 'file' && frameScheme != 'filesystem' && - (!link.target || link.target == '_self')) { - // Load the link inside the frame itself. - if (element.tagName.toLowerCase() == 'object') { - element.setAttribute('data', link.href); - } else { - element.setAttribute('src', link.href); - } + const scheme = this.urlUtils.getUrlScheme(link.href); + if (!link.href || (scheme && scheme == 'javascript')) { + // Links with no URL and Javascript links are ignored. + return; + } - return; - } + if (scheme && scheme != 'file' && scheme != 'filesystem') { + // Scheme suggests it's an external resource. + event.preventDefault(); - // The frame is local or the link needs to be opened in a new window. Open in browser. - if (!this.sitesProvider.isLoggedIn()) { - this.utils.openInBrowser(link.href); - } else { - this.sitesProvider.getCurrentSite().openInBrowserWithAutoLoginIfSameSite(link.href); - } - } else if (link.target == '_parent' || link.target == '_top' || link.target == '_blank') { - // Opening links with _parent, _top or _blank can break the app. We'll open it in InAppBrowser. - event.preventDefault(); - this.utils.openFile(link.href).catch((error) => { - this.domUtils.showErrorModal(error); - }); - } else if (this.platform.is('ios') && (!link.target || link.target == '_self')) { - // In cordova ios 4.1.0 links inside iframes stopped working. We'll manually treat them. - event.preventDefault(); + const frameSrc = ( element).src || ( element).data, + frameScheme = this.urlUtils.getUrlScheme(frameSrc); + + // If the frame is not local, check the target to identify how to treat the link. + if (frameScheme && frameScheme != 'file' && frameScheme != 'filesystem' && + (!link.target || link.target == '_self')) { + // Load the link inside the frame itself. if (element.tagName.toLowerCase() == 'object') { element.setAttribute('data', link.href); } else { element.setAttribute('src', link.href); } + + return; } - }); + + // The frame is local or the link needs to be opened in a new window. Open in browser. + if (!this.sitesProvider.isLoggedIn()) { + this.utils.openInBrowser(link.href); + } else { + this.sitesProvider.getCurrentSite().openInBrowserWithAutoLoginIfSameSite(link.href); + } + } else if (link.target == '_parent' || link.target == '_top' || link.target == '_blank') { + // Opening links with _parent, _top or _blank can break the app. We'll open it in InAppBrowser. + event.preventDefault(); + this.utils.openFile(link.href).catch((error) => { + this.domUtils.showErrorModal(error); + }); + } else if (this.platform.is('ios') && (!link.target || link.target == '_self')) { + // In cordova ios 4.1.0 links inside iframes stopped working. We'll manually treat them. + event.preventDefault(); + if (element.tagName.toLowerCase() == 'object') { + element.setAttribute('data', link.href); + } else { + element.setAttribute('src', link.href); + } + } } } + +/** + * Subtype of HTMLAnchorElement, with some calculated data. + */ +type CoreIframeHTMLAnchorElement = HTMLAnchorElement & { + treated?: boolean; // Whether the element has been treated already. +}; diff --git a/src/providers/utils/text.ts b/src/providers/utils/text.ts index d08b82fe9..0a858127e 100644 --- a/src/providers/utils/text.ts +++ b/src/providers/utils/text.ts @@ -514,10 +514,9 @@ export class CoreTextUtilsProvider { return true; } - const div = document.createElement('div'); - div.innerHTML = content; + this.template.innerHTML = content; - return div.textContent === '' && div.querySelector('img, object, hr') === null; + return this.template.textContent === '' && this.template.content.querySelector('img, object, hr') === null; } /** diff --git a/src/providers/utils/url.ts b/src/providers/utils/url.ts index d2e9e0d47..c04240a6c 100644 --- a/src/providers/utils/url.ts +++ b/src/providers/utils/url.ts @@ -50,13 +50,19 @@ export class CoreUrlUtilsProvider { * @param url URL to add the params to. * @param params Object with the params to add. * @param anchor Anchor text if needed. + * @param boolToNumber Whether to convert bools to 1 or 0. * @return URL with params. */ - addParamsToUrl(url: string, params?: {[key: string]: any}, anchor?: string): string { + addParamsToUrl(url: string, params?: {[key: string]: any}, anchor?: string, boolToNumber?: boolean): string { let separator = url.indexOf('?') != -1 ? '&' : '?'; for (const key in params) { - const value = params[key]; + let value = params[key]; + + if (boolToNumber && typeof value == 'boolean') { + // Convert booleans to 1 or 0. + value = value ? 1 : 0; + } // Ignore objects. if (typeof value != 'object') { diff --git a/src/providers/utils/utils.ts b/src/providers/utils/utils.ts index 4f71eede7..624371ac1 100644 --- a/src/providers/utils/utils.ts +++ b/src/providers/utils/utils.ts @@ -135,17 +135,19 @@ export class CoreUtilsProvider { /** * Converts an array of objects to an object, using a property of each entry as the key. + * It can also be used to convert an array of strings to an object where the keys are the elements of the array. * E.g. [{id: 10, name: 'A'}, {id: 11, name: 'B'}] => {10: {id: 10, name: 'A'}, 11: {id: 11, name: 'B'}} * * @param array The array to convert. - * @param propertyName The name of the property to use as the key. + * @param propertyName The name of the property to use as the key. If not provided, the whole item will be used. * @param result Object where to put the properties. If not defined, a new object will be created. * @return The object. */ - arrayToObject(array: any[], propertyName: string, result?: any): any { + arrayToObject(array: any[], propertyName?: string, result?: any): any { result = result || {}; array.forEach((entry) => { - result[entry[propertyName]] = entry; + const key = propertyName ? entry[propertyName] : entry; + result[key] = entry; }); return result; @@ -1287,6 +1289,7 @@ export class CoreUtilsProvider { * * @param files List of files to sum its filesize. * @return File size and a boolean to indicate if it is the total size or only partial. + * @deprecated since 3.8.0. Use CorePluginFileDelegate.getFilesSize instead. */ sumFileSizes(files: any[]): { size: number, total: boolean } { const result = { diff --git a/upgrade.txt b/upgrade.txt index 6599608e6..75333c0c4 100644 --- a/upgrade.txt +++ b/upgrade.txt @@ -1,6 +1,10 @@ This files describes API changes in the Moodle Mobile app, information provided here is intended especially for developers. +=== 3.8.0 === + +- CoreDomUtilsProvider.extractDownloadableFilesFromHtml and CoreDomUtilsProvider.extractDownloadableFilesFromHtmlAsFakeFileObjects have been deprecated. Please use CoreFilepoolProvider.extractDownloadableFilesFromHtml and CoreFilepoolProvider.extractDownloadableFilesFromHtmlAsFakeFileObjects. We had to move them to prevent a circular dependency. + === 3.7.1 === - CoreGroupsProvider.getActivityAllowedGroups and CoreGroupsProvider.getActivityAllowedGroupsIfEnabled now return the full response of core_group_get_activity_allowed_groups instead of just the groups.