From 5e2e1d1a241eff30e5026044641d40a45afb0e56 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 28 Oct 2019 15:09:13 +0100 Subject: [PATCH 01/19] MOBILE-2235 h5p: Add h5p extension to list of extensions --- src/assets/exttomime.json | 1 + 1 file changed, 1 insertion(+) 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"}, From 690544afbf61106e3d9be63299da651c68f218f3 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 29 Oct 2019 16:07:22 +0100 Subject: [PATCH 02/19] MOBILE-2235 h5p: Show placeholder instead of H5P directly --- .../filter/displayh5p/displayh5p.module.ts | 34 +++++++ .../filter/displayh5p/providers/handler.ts | 93 +++++++++++++++++++ src/addon/filter/filter.module.ts | 2 + .../filter/mathjaxloader/providers/handler.ts | 7 +- .../filter/mediaplugin/providers/handler.ts | 9 +- src/app/app.module.ts | 2 + src/assets/img/icons/h5p.svg | 14 +++ src/assets/lang/en.json | 1 + src/core/filter/providers/default-filter.ts | 7 +- src/core/filter/providers/delegate.ts | 14 +-- src/core/h5p/components/components.module.ts | 43 +++++++++ .../h5p-player/core-h5p-player.html | 9 ++ .../h5p/components/h5p-player/h5p-player.scss | 24 +++++ .../h5p/components/h5p-player/h5p-player.ts | 56 +++++++++++ src/core/h5p/h5p.module.ts | 27 ++++++ src/core/h5p/lang/en.json | 3 + src/directives/format-text.ts | 10 +- src/providers/utils/text.ts | 5 +- src/theme/variables.scss | 3 + 19 files changed, 341 insertions(+), 22 deletions(-) create mode 100644 src/addon/filter/displayh5p/displayh5p.module.ts create mode 100644 src/addon/filter/displayh5p/providers/handler.ts create mode 100644 src/assets/img/icons/h5p.svg create mode 100644 src/core/h5p/components/components.module.ts create mode 100644 src/core/h5p/components/h5p-player/core-h5p-player.html create mode 100644 src/core/h5p/components/h5p-player/h5p-player.scss create mode 100644 src/core/h5p/components/h5p-player/h5p-player.ts create mode 100644 src/core/h5p/h5p.module.ts create mode 100644 src/core/h5p/lang/en.json 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..3956d3299 --- /dev/null +++ b/src/addon/filter/displayh5p/providers/handler.ts @@ -0,0 +1,93 @@ + +// (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 siteId Site ID. If not defined, current site. + * @return If async, promise resolved when done. + */ + handleHtml(container: HTMLElement, filter: CoreFilterFilter, options: CoreFilterFormatTextOptions, + viewContainerRef: ViewContainerRef, 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; + + // 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..e1d514e05 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,11 +161,12 @@ 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 siteId Site ID. If not defined, current site. * @return If async, promise resolved when done. */ - handleHtml(container: HTMLElement, filter: CoreFilterFilter, options: CoreFilterFormatTextOptions, siteId?: string) - : void | Promise { + handleHtml(container: HTMLElement, filter: CoreFilterFilter, options: CoreFilterFormatTextOptions, + viewContainerRef: ViewContainerRef, siteId?: string): void | Promise { return this.waitForReady().then(() => { this.window.M.filter_mathjaxloader.typeset(container); 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/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/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..e3d599345 100644 --- a/src/assets/lang/en.json +++ b/src/assets/lang/en.json @@ -1550,6 +1550,7 @@ "core.group": "Group", "core.groupsseparate": "Separate groups", "core.groupsvisible": "Visible groups", + "core.h5p.play": "Play H5P", "core.hasdatatosync": "This {{$a}} has offline data to be synchronised.", "core.help": "Help", "core.hide": "Hide", diff --git a/src/core/filter/providers/default-filter.ts b/src/core/filter/providers/default-filter.ts index bb9ff9d32..8b86c3028 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,11 +50,12 @@ 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 siteId Site ID. If not defined, current site. * @return If async, promise resolved when done. */ - handleHtml(container: HTMLElement, filter: CoreFilterFilter, options: CoreFilterFormatTextOptions, siteId?: string) - : void | Promise { + handleHtml(container: HTMLElement, filter: CoreFilterFilter, options: CoreFilterFormatTextOptions, + viewContainerRef: ViewContainerRef, 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..9b6184536 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,11 +48,12 @@ 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 siteId Site ID. If not defined, current site. * @return If async, promise resolved when done. */ - handleHtml?(container: HTMLElement, filter: CoreFilterFilter, options: CoreFilterFormatTextOptions, siteId?: string) - : void | Promise; + handleHtml?(container: HTMLElement, filter: CoreFilterFilter, options: CoreFilterFormatTextOptions, + viewContainerRef: ViewContainerRef, siteId?: string): void | Promise; /** * Check if the filter should be applied in a certain site based on some filter options. @@ -156,13 +157,14 @@ 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 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[], siteId?: string): Promise { // Wait for filters to be initialized. return this.handlersInitPromise.then(() => { @@ -182,7 +184,7 @@ 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, siteId])).catch((error) => { this.logger.error('Error handling HTML' + filter.filter, error); }); }); diff --git a/src/core/h5p/components/components.module.ts b/src/core/h5p/components/components.module.ts new file mode 100644 index 000000000..09480a6bb --- /dev/null +++ b/src/core/h5p/components/components.module.ts @@ -0,0 +1,43 @@ +// (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 { CommonModule } from '@angular/common'; +import { IonicModule } from 'ionic-angular'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreDirectivesModule } from '@directives/directives.module'; +import { CoreH5PPlayerComponent } from './h5p-player/h5p-player'; +import { CoreComponentsModule } from '@components/components.module'; + +@NgModule({ + declarations: [ + CoreH5PPlayerComponent + ], + imports: [ + CommonModule, + IonicModule, + CoreDirectivesModule, + TranslateModule.forChild(), + CoreComponentsModule + ], + providers: [ + ], + exports: [ + CoreH5PPlayerComponent + ], + entryComponents: [ + CoreH5PPlayerComponent + ] +}) +export class CoreH5PComponentsModule {} diff --git a/src/core/h5p/components/h5p-player/core-h5p-player.html b/src/core/h5p/components/h5p-player/core-h5p-player.html new file mode 100644 index 000000000..3d9e67912 --- /dev/null +++ b/src/core/h5p/components/h5p-player/core-h5p-player.html @@ -0,0 +1,9 @@ +
+ + + + +
+ diff --git a/src/core/h5p/components/h5p-player/h5p-player.scss b/src/core/h5p/components/h5p-player/h5p-player.scss new file mode 100644 index 000000000..0c963aa79 --- /dev/null +++ b/src/core/h5p/components/h5p-player/h5p-player.scss @@ -0,0 +1,24 @@ +ion-app.app-root core-h5p-player { + .core-h5p-placeholder { + position: relative; + width: 100%; + height: 230px; + background: url('../assets/img/icons/h5p.svg') center top 25px / 100px auto no-repeat $core-h5p-placeholder-bg-color; + + .core-h5p-placeholder-play-button { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 30px; + min-height: 50px; + } + + .core-h5p-placeholder-download-container { + position: absolute; + right: 10px; + font-size: 1.8em; + } + } + +} diff --git a/src/core/h5p/components/h5p-player/h5p-player.ts b/src/core/h5p/components/h5p-player/h5p-player.ts new file mode 100644 index 000000000..e6a73b2a4 --- /dev/null +++ b/src/core/h5p/components/h5p-player/h5p-player.ts @@ -0,0 +1,56 @@ +// (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 { Component, Input, ElementRef } from '@angular/core'; +import { CoreSitesProvider } from '@providers/sites'; + +/** + * Component to render an H5P package. + */ +@Component({ + selector: 'core-h5p-player', + templateUrl: 'core-h5p-player.html' +}) +export class CoreH5PPlayerComponent { + @Input() src: string; // The URL of the player to display the H5P package. + + showPackage = false; + loading = false; + status: string; + canDownload: boolean; + calculating = true; + + constructor(public elementRef: ElementRef, + protected sitesProvider: CoreSitesProvider) { + } + + /** + * Play the H5P. + * + * @param e Event. + */ + play(e: MouseEvent): void { + e.preventDefault(); + e.stopPropagation(); + + this.loading = true; + + // Get auto-login URL so the user is automatically authenticated. + this.sitesProvider.getCurrentSite().getAutoLoginUrl(this.src, false).then((url) => { + this.src = url; + this.loading = false; + this.showPackage = true; + }); + } +} diff --git a/src/core/h5p/h5p.module.ts b/src/core/h5p/h5p.module.ts new file mode 100644 index 000000000..4fb4f6ba4 --- /dev/null +++ b/src/core/h5p/h5p.module.ts @@ -0,0 +1,27 @@ +// (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 { CoreH5PComponentsModule } from './components/components.module'; + +@NgModule({ + declarations: [], + imports: [ + CoreH5PComponentsModule + ], + providers: [ + ], + exports: [] +}) +export class CoreH5PModule { } diff --git a/src/core/h5p/lang/en.json b/src/core/h5p/lang/en.json new file mode 100644 index 000000000..954fa438a --- /dev/null +++ b/src/core/h5p/lang/en.json @@ -0,0 +1,3 @@ +{ + "play": "Play H5P" +} \ No newline at end of file diff --git a/src/directives/format-text.ts b/src/directives/format-text.ts index 68ef57895..1ae718161 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, [], + result.siteId); } this.element.classList.remove('core-disable-media-adapt'); 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/theme/variables.scss b/src/theme/variables.scss index ebab93935..326cc4671 100644 --- a/src/theme/variables.scss +++ b/src/theme/variables.scss @@ -369,6 +369,9 @@ $core-question-state-incorrect-color: $red-light !default; $core-dd-question-selected-shadow: 2px 2px 4px $gray-dark !default; $core-dd-question-colors: $white, $blue-light, #DCDCDC, #D8BFD8, #87CEFA, #DAA520, #FFD700, #F0E68C !default; +// H5P variables. +$core-h5p-placeholder-bg-color: $gray-dark !default; + // Mixins // ------------------------- @mixin core-transition($where: all, $time: 500ms) { From 9b637fc496da5884155903a06a95811b297b188e Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 31 Oct 2019 12:30:45 +0100 Subject: [PATCH 03/19] MOBILE-2235 h5p: Display download button in H5P placeholder --- src/components/iframe/core-iframe.html | 2 +- src/components/iframe/iframe.scss | 3 - src/core/compile/providers/compile.ts | 3 +- src/core/filter/providers/filter.ts | 2 +- .../h5p-player/core-h5p-player.html | 11 +- .../h5p/components/h5p-player/h5p-player.scss | 39 +++- .../h5p/components/h5p-player/h5p-player.ts | 80 ++++++++- src/core/h5p/h5p.module.ts | 7 + src/core/h5p/providers/h5p.ts | 169 ++++++++++++++++++ src/theme/variables.scss | 3 - 10 files changed, 300 insertions(+), 19 deletions(-) create mode 100644 src/core/h5p/providers/h5p.ts 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/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/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/components/h5p-player/core-h5p-player.html b/src/core/h5p/components/h5p-player/core-h5p-player.html index 3d9e67912..af48001df 100644 --- a/src/core/h5p/components/h5p-player/core-h5p-player.html +++ b/src/core/h5p/components/h5p-player/core-h5p-player.html @@ -1,9 +1,14 @@
- - + +
{{ errorMessage }}
+ +
+ +
- + diff --git a/src/core/h5p/components/h5p-player/h5p-player.scss b/src/core/h5p/components/h5p-player/h5p-player.scss index 0c963aa79..b5ba7f82c 100644 --- a/src/core/h5p/components/h5p-player/h5p-player.scss +++ b/src/core/h5p/components/h5p-player/h5p-player.scss @@ -1,23 +1,56 @@ +// H5P variables. +$core-h5p-placeholder-bg-color: $gray !default; +$core-h5p-placeholder-text-color: $text-color !default; + ion-app.app-root core-h5p-player { .core-h5p-placeholder { position: relative; width: 100%; height: 230px; background: url('../assets/img/icons/h5p.svg') center top 25px / 100px auto no-repeat $core-h5p-placeholder-bg-color; + color: $core-h5p-placeholder-text-color; - .core-h5p-placeholder-play-button { + .icon { + color: $core-h5p-placeholder-text-color; + } + + .core-h5p-placeholder-play-button, .core-h5p-placeholder-spinner { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); + } + + .core-h5p-placeholder-play-button { font-size: 30px; min-height: 50px; } .core-h5p-placeholder-download-container { position: absolute; - right: 10px; - font-size: 1.8em; + top: 0; + right: 0; + + ion-spinner { + margin-right: 0.75em; + } + + core-download-refresh > ion-icon { + margin: 0.4rem 0.2rem; + padding: 0 0.5em; + line-height: .67; + } + } + + .core-h5p-placeholder-error { + position: absolute; + width: 100%; + text-align: center; + top: 50%; + } + + ion-spinner circle { + stroke: $core-h5p-placeholder-text-color; } } diff --git a/src/core/h5p/components/h5p-player/h5p-player.ts b/src/core/h5p/components/h5p-player/h5p-player.ts index e6a73b2a4..0c4f3e937 100644 --- a/src/core/h5p/components/h5p-player/h5p-player.ts +++ b/src/core/h5p/components/h5p-player/h5p-player.ts @@ -12,8 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, ElementRef } from '@angular/core'; +import { Component, Input, ElementRef, OnInit, SimpleChange } from '@angular/core'; import { CoreSitesProvider } from '@providers/sites'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreUrlUtilsProvider } from '@providers/utils/url'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreH5PProvider } from '@core/h5p/providers/h5p'; /** * Component to render an H5P package. @@ -22,17 +26,39 @@ import { CoreSitesProvider } from '@providers/sites'; selector: 'core-h5p-player', templateUrl: 'core-h5p-player.html' }) -export class CoreH5PPlayerComponent { +export class CoreH5PPlayerComponent implements OnInit { @Input() src: string; // The URL of the player to display the H5P package. + playerSrc: string; showPackage = false; loading = false; status: string; canDownload: boolean; calculating = true; + errorMessage: string; constructor(public elementRef: ElementRef, - protected sitesProvider: CoreSitesProvider) { + protected sitesProvider: CoreSitesProvider, + protected urlUtils: CoreUrlUtilsProvider, + protected utils: CoreUtilsProvider, + protected textUtils: CoreTextUtilsProvider, + protected h5pProvider: CoreH5PProvider) { } + + /** + * Component being initialized. + */ + ngOnInit(): void { + this.checkCanDownload(); + } + + /** + * Detect changes on input properties. + */ + ngOnChanges(changes: {[name: string]: SimpleChange}): void { + // If it's already playing and the src changes, don't change the player src, the user could lose data. + if (changes.src && !this.showPackage) { + this.checkCanDownload(); + } } /** @@ -46,11 +72,57 @@ export class CoreH5PPlayerComponent { this.loading = true; + // @TODO: Check if package is downloaded and use the local player if so. + // Get auto-login URL so the user is automatically authenticated. this.sitesProvider.getCurrentSite().getAutoLoginUrl(this.src, false).then((url) => { - this.src = url; + this.playerSrc = url; this.loading = false; this.showPackage = true; }); } + + /** + * Download the package. + */ + download(): void { + // @TODO: Implement package download. + } + + /** + * Check if the package can be downloaded. + */ + protected checkCanDownload(): void { + if (this.src && this.h5pProvider.canGetTrustedH5PFileInSite()) { + const params = this.urlUtils.extractUrlParams(this.src); + + // @todo: Check if H5P offline is disabled in the site. + + // Now check if the package can be played. + this.calculating = true; + + const options = { + frame: this.utils.isTrueOrOne(params.frame), + export: this.utils.isTrueOrOne(params.export), + embed: this.utils.isTrueOrOne(params.embed), + copyright: this.utils.isTrueOrOne(params.copyright), + }; + + this.h5pProvider.getTrustedH5PFile(params.url, options).then((file) => { + this.canDownload = true; + this.errorMessage = undefined; + }).catch((error) => { + this.canDownload = false; + this.errorMessage = this.textUtils.getErrorMessageFromError(error); + }).finally(() => { + this.calculating = false; + }); + + return; + } + + this.calculating = false; + this.canDownload = false; + this.errorMessage = undefined; + } } diff --git a/src/core/h5p/h5p.module.ts b/src/core/h5p/h5p.module.ts index 4fb4f6ba4..ddc625a0b 100644 --- a/src/core/h5p/h5p.module.ts +++ b/src/core/h5p/h5p.module.ts @@ -14,6 +14,12 @@ import { NgModule } from '@angular/core'; import { CoreH5PComponentsModule } from './components/components.module'; +import { CoreH5PProvider } from './providers/h5p'; + +// List of providers (without handlers). +export const CORE_H5P_PROVIDERS: any[] = [ + CoreH5PProvider +]; @NgModule({ declarations: [], @@ -21,6 +27,7 @@ import { CoreH5PComponentsModule } from './components/components.module'; CoreH5PComponentsModule ], providers: [ + CoreH5PProvider ], exports: [] }) diff --git a/src/core/h5p/providers/h5p.ts b/src/core/h5p/providers/h5p.ts new file mode 100644 index 000000000..335e5025f --- /dev/null +++ b/src/core/h5p/providers/h5p.ts @@ -0,0 +1,169 @@ +// (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 { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; +import { CoreWSExternalWarning, CoreWSExternalFile } from '@providers/ws'; + +/** + * Service to provide H5P functionalities. + */ +@Injectable() +export class CoreH5PProvider { + + protected ROOT_CACHE_KEY = 'mmH5P:'; + + protected logger; + + constructor(logger: CoreLoggerProvider, + private sitesProvider: CoreSitesProvider) { + + this.logger = logger.getInstance('CoreFilterProvider'); + } + + /** + * Returns whether or not WS to get trusted H5P file is available. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with true if ws is available, false otherwise. + * @since 3.8 + */ + canGetTrustedH5PFile(siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return this.canGetTrustedH5PFileInSite(site); + }); + } + + /** + * Returns whether or not WS to get trusted H5P file is available in a certain site. + * + * @param site Site. If not defined, current site. + * @return Promise resolved with true if ws is available, false otherwise. + * @since 3.4 + */ + canGetTrustedH5PFileInSite(site?: CoreSite): boolean { + site = site || this.sitesProvider.getCurrentSite(); + + return site.wsAvailable('core_h5p_get_trusted_h5p_file'); + } + + /** + * 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 { + + return this.sitesProvider.getSite(siteId).then((site) => { + + const data = { + url: url, + 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:'; + } + + /** + * 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)); + }); + } +} + +/** + * 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. +}; diff --git a/src/theme/variables.scss b/src/theme/variables.scss index 326cc4671..ebab93935 100644 --- a/src/theme/variables.scss +++ b/src/theme/variables.scss @@ -369,9 +369,6 @@ $core-question-state-incorrect-color: $red-light !default; $core-dd-question-selected-shadow: 2px 2px 4px $gray-dark !default; $core-dd-question-colors: $white, $blue-light, #DCDCDC, #D8BFD8, #87CEFA, #DAA520, #FFD700, #F0E68C !default; -// H5P variables. -$core-h5p-placeholder-bg-color: $gray-dark !default; - // Mixins // ------------------------- @mixin core-transition($where: all, $time: 500ms) { From c4a58d9ee8fc65d6ae224a31a77979e3f75152a4 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 5 Nov 2019 13:00:05 +0100 Subject: [PATCH 04/19] MOBILE-2235 h5p: Implement H5P download and unzip --- .../filter/displayh5p/providers/handler.ts | 7 +- .../filter/mathjaxloader/providers/handler.ts | 5 +- .../mod/assign/providers/prefetch-handler.ts | 5 +- .../mod/book/providers/prefetch-handler.ts | 5 +- .../mod/chat/providers/prefetch-handler.ts | 5 +- .../mod/choice/providers/prefetch-handler.ts | 5 +- .../mod/data/providers/prefetch-handler.ts | 5 +- .../feedback/providers/prefetch-handler.ts | 5 +- .../mod/folder/providers/prefetch-handler.ts | 5 +- .../mod/forum/providers/prefetch-handler.ts | 5 +- .../glossary/providers/prefetch-handler.ts | 5 +- .../mod/imscp/providers/prefetch-handler.ts | 5 +- .../mod/label/providers/prefetch-handler.ts | 5 +- .../mod/lesson/providers/prefetch-handler.ts | 9 +- .../mod/lti/providers/prefetch-handler.ts | 5 +- .../mod/page/providers/prefetch-handler.ts | 5 +- .../mod/quiz/providers/prefetch-handler.ts | 5 +- .../resource/providers/prefetch-handler.ts | 5 +- .../mod/scorm/providers/prefetch-handler.ts | 5 +- .../mod/survey/providers/prefetch-handler.ts | 5 +- .../mod/url/providers/prefetch-handler.ts | 7 +- .../mod/wiki/providers/prefetch-handler.ts | 7 +- .../workshop/providers/prefetch-handler.ts | 5 +- src/components/file/file.ts | 47 ++-- .../course/classes/module-prefetch-handler.ts | 6 +- src/core/filter/providers/default-filter.ts | 5 +- src/core/filter/providers/delegate.ts | 11 +- .../h5p-player/core-h5p-player.html | 6 +- .../h5p/components/h5p-player/h5p-player.scss | 7 - .../h5p/components/h5p-player/h5p-player.ts | 107 +++++-- src/core/h5p/h5p.module.ts | 13 +- src/core/h5p/providers/h5p.ts | 25 +- src/core/h5p/providers/pluginfile-handler.ts | 121 ++++++++ .../handlers/module-prefetch-handler.ts | 5 +- src/core/siteplugins/providers/helper.ts | 6 +- src/directives/format-text.ts | 2 +- src/providers/filepool.ts | 262 ++++++++++-------- src/providers/plugin-file-delegate.ts | 197 ++++++++++++- src/providers/utils/dom.ts | 25 +- src/providers/utils/utils.ts | 1 + 40 files changed, 751 insertions(+), 220 deletions(-) create mode 100644 src/core/h5p/providers/pluginfile-handler.ts diff --git a/src/addon/filter/displayh5p/providers/handler.ts b/src/addon/filter/displayh5p/providers/handler.ts index 3956d3299..abbfdb957 100644 --- a/src/addon/filter/displayh5p/providers/handler.ts +++ b/src/addon/filter/displayh5p/providers/handler.ts @@ -69,11 +69,14 @@ export class AddonFilterDisplayH5PHandler extends CoreFilterDefaultHandler { * @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, siteId?: string): void | Promise { + viewContainerRef: ViewContainerRef, component?: string, componentId?: string | number, siteId?: string) + : void | Promise { const placeholders = Array.from(container.querySelectorAll('div.core-h5p-tmp-placeholder')); @@ -85,6 +88,8 @@ export class AddonFilterDisplayH5PHandler extends CoreFilterDefaultHandler { 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/mathjaxloader/providers/handler.ts b/src/addon/filter/mathjaxloader/providers/handler.ts index e1d514e05..0ef005bc7 100644 --- a/src/addon/filter/mathjaxloader/providers/handler.ts +++ b/src/addon/filter/mathjaxloader/providers/handler.ts @@ -162,11 +162,14 @@ export class AddonFilterMathJaxLoaderHandler extends CoreFilterDefaultHandler { * @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, siteId?: string): void | Promise { + viewContainerRef: ViewContainerRef, component?: string, componentId?: string | number, siteId?: string) + : void | Promise { return this.waitForReady().then(() => { this.window.M.filter_mathjaxloader.typeset(container); 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/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..2f1e05b5d 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); } /** diff --git a/src/addon/mod/glossary/providers/prefetch-handler.ts b/src/addon/mod/glossary/providers/prefetch-handler.ts index 3051f1467..34a94b142 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); } /** 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..f0732535e 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); 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/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..fdd66c7e5 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); } /** 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/prefetch-handler.ts b/src/addon/mod/scorm/providers/prefetch-handler.ts index fd2d34b6e..7c6dcccde 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); } /** 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/components/file/file.ts b/src/components/file/file.ts index a1c97cf3e..b8e7a7965 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; } @@ -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/core/course/classes/module-prefetch-handler.ts b/src/core/course/classes/module-prefetch-handler.ts index 2e344104a..0448dcd33 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 }; }); diff --git a/src/core/filter/providers/default-filter.ts b/src/core/filter/providers/default-filter.ts index 8b86c3028..f5f307500 100644 --- a/src/core/filter/providers/default-filter.ts +++ b/src/core/filter/providers/default-filter.ts @@ -51,11 +51,14 @@ export class CoreFilterDefaultHandler implements CoreFilterHandler { * @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, siteId?: string): void | Promise { + 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 9b6184536..d712ffb31 100644 --- a/src/core/filter/providers/delegate.ts +++ b/src/core/filter/providers/delegate.ts @@ -49,11 +49,14 @@ export interface CoreFilterHandler extends CoreDelegateHandler { * @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, siteId?: string): void | Promise; + viewContainerRef: ViewContainerRef, component?: string, componentId?: string | number, siteId?: string) + : void | Promise; /** * Check if the filter should be applied in a certain site based on some filter options. @@ -160,11 +163,13 @@ export class CoreFilterDelegate extends CoreDelegate { * @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[], viewContainerRef?: ViewContainerRef, options?: any, - skipFilters?: string[], siteId?: string): Promise { + skipFilters?: string[], component?: string, componentId?: string | number, siteId?: string): Promise { // Wait for filters to be initialized. return this.handlersInitPromise.then(() => { @@ -184,7 +189,7 @@ export class CoreFilterDelegate extends CoreDelegate { promise = promise.then(() => { return Promise.resolve(this.executeFunctionOnEnabled(filter.filter, 'handleHtml', - [container, filter, options, viewContainerRef, 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/h5p/components/h5p-player/core-h5p-player.html b/src/core/h5p/components/h5p-player/core-h5p-player.html index af48001df..2fb1e9989 100644 --- a/src/core/h5p/components/h5p-player/core-h5p-player.html +++ b/src/core/h5p/components/h5p-player/core-h5p-player.html @@ -1,11 +1,9 @@
- - - -
{{ errorMessage }}
+
diff --git a/src/core/h5p/components/h5p-player/h5p-player.scss b/src/core/h5p/components/h5p-player/h5p-player.scss index b5ba7f82c..8a4875ac9 100644 --- a/src/core/h5p/components/h5p-player/h5p-player.scss +++ b/src/core/h5p/components/h5p-player/h5p-player.scss @@ -42,13 +42,6 @@ ion-app.app-root core-h5p-player { } } - .core-h5p-placeholder-error { - position: absolute; - width: 100%; - text-align: center; - top: 50%; - } - ion-spinner circle { stroke: $core-h5p-placeholder-text-color; } diff --git a/src/core/h5p/components/h5p-player/h5p-player.ts b/src/core/h5p/components/h5p-player/h5p-player.ts index 0c4f3e937..e3eac156f 100644 --- a/src/core/h5p/components/h5p-player/h5p-player.ts +++ b/src/core/h5p/components/h5p-player/h5p-player.ts @@ -12,12 +12,17 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { Component, Input, ElementRef, OnInit, SimpleChange } from '@angular/core'; +import { Component, Input, ElementRef, OnInit, OnDestroy, OnChanges, SimpleChange } from '@angular/core'; +import { CoreAppProvider } from '@providers/app'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreFilepoolProvider } from '@providers/filepool'; import { CoreSitesProvider } from '@providers/sites'; +import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTextUtilsProvider } from '@providers/utils/text'; import { CoreUrlUtilsProvider } from '@providers/utils/url'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreH5PProvider } from '@core/h5p/providers/h5p'; +import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; /** * Component to render an H5P package. @@ -26,23 +31,38 @@ import { CoreH5PProvider } from '@core/h5p/providers/h5p'; selector: 'core-h5p-player', templateUrl: 'core-h5p-player.html' }) -export class CoreH5PPlayerComponent implements OnInit { +export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy { @Input() src: string; // The URL of the player to display the H5P package. + @Input() component?: string; // Component. + @Input() componentId?: string | number; // Component ID to use in conjunction with the component. playerSrc: string; showPackage = false; loading = false; - status: string; + state: string; canDownload: boolean; calculating = true; - errorMessage: string; + + protected siteId: string; + protected siteCanDownload: boolean; + protected observer; + protected urlParams; constructor(public elementRef: ElementRef, protected sitesProvider: CoreSitesProvider, protected urlUtils: CoreUrlUtilsProvider, protected utils: CoreUtilsProvider, protected textUtils: CoreTextUtilsProvider, - protected h5pProvider: CoreH5PProvider) { } + protected h5pProvider: CoreH5PProvider, + protected filepoolProvider: CoreFilepoolProvider, + protected eventsProvider: CoreEventsProvider, + protected appProvider: CoreAppProvider, + protected domUtils: CoreDomUtilsProvider, + protected pluginFileDelegate: CorePluginFileDelegate) { + + this.siteId = sitesProvider.getCurrentSiteId(); + this.siteCanDownload = this.sitesProvider.getCurrentSite().canDownloadFiles(); + } /** * Component being initialized. @@ -85,37 +105,49 @@ export class CoreH5PPlayerComponent implements OnInit { /** * Download the package. */ - download(): void { - // @TODO: Implement package download. + download(e: Event): void { + e && e.preventDefault(); + e && e.stopPropagation(); + + if (!this.appProvider.isOnline()) { + this.domUtils.showErrorModal('core.networkerrormsg', true); + + return; + } + + // Get the file size and ask the user to confirm. + this.pluginFileDelegate.getFileSize({fileurl: this.urlParams.url}, this.siteId).then((size) => { + return this.domUtils.confirmDownloadSize({ size: size, total: true }).then(() => { + + // User confirmed, add to the queue. + return this.filepoolProvider.addToQueueByUrl(this.siteId, this.urlParams.url, this.component, this.componentId); + }, () => { + // User cancelled. + }); + }).catch((error) => { + this.domUtils.showErrorModalDefault(error, 'core.errordownloading', true); + this.calculateState(); + }); } /** * Check if the package can be downloaded. */ protected checkCanDownload(): void { - if (this.src && this.h5pProvider.canGetTrustedH5PFileInSite()) { - const params = this.urlUtils.extractUrlParams(this.src); + this.observer && this.observer.off(); + this.urlParams = this.urlUtils.extractUrlParams(this.src); - // @todo: Check if H5P offline is disabled in the site. + if (this.src && this.siteCanDownload && this.h5pProvider.canGetTrustedH5PFileInSite()) { - // Now check if the package can be played. this.calculating = true; - const options = { - frame: this.utils.isTrueOrOne(params.frame), - export: this.utils.isTrueOrOne(params.export), - embed: this.utils.isTrueOrOne(params.embed), - copyright: this.utils.isTrueOrOne(params.copyright), - }; + this.calculateState(); - this.h5pProvider.getTrustedH5PFile(params.url, options).then((file) => { - this.canDownload = true; - this.errorMessage = undefined; - }).catch((error) => { - this.canDownload = false; - this.errorMessage = this.textUtils.getErrorMessageFromError(error); - }).finally(() => { - this.calculating = false; + // Listen for changes in the state. + this.filepoolProvider.getFileEventNameByUrl(this.siteId, this.urlParams.url).then((eventName) => { + this.observer = this.eventsProvider.on(eventName, () => { + this.calculateState(); + }); }); return; @@ -123,6 +155,29 @@ export class CoreH5PPlayerComponent implements OnInit { this.calculating = false; this.canDownload = false; - this.errorMessage = undefined; + } + + /** + * Calcuñate state of the file. + * + * @param fileUrl The H5P file URL. + */ + protected calculateState(): void { + // Get the status of the file. + this.filepoolProvider.getFileStateByUrl(this.siteId, this.urlParams.url).then((state) => { + this.canDownload = true; + this.state = state; + }).catch((error) => { + this.canDownload = false; + }).finally(() => { + this.calculating = false; + }); + } + + /** + * Component destroyed. + */ + ngOnDestroy(): void { + this.observer && this.observer.off(); } } diff --git a/src/core/h5p/h5p.module.ts b/src/core/h5p/h5p.module.ts index ddc625a0b..f05f16c29 100644 --- a/src/core/h5p/h5p.module.ts +++ b/src/core/h5p/h5p.module.ts @@ -15,6 +15,8 @@ import { NgModule } from '@angular/core'; import { CoreH5PComponentsModule } from './components/components.module'; import { CoreH5PProvider } from './providers/h5p'; +import { CoreH5PPluginFileHandler } from './providers/pluginfile-handler'; +import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; // List of providers (without handlers). export const CORE_H5P_PROVIDERS: any[] = [ @@ -27,8 +29,15 @@ export const CORE_H5P_PROVIDERS: any[] = [ CoreH5PComponentsModule ], providers: [ - CoreH5PProvider + CoreH5PProvider, + CoreH5PPluginFileHandler ], exports: [] }) -export class CoreH5PModule { } +export class CoreH5PModule { + constructor(pluginfileDelegate: CorePluginFileDelegate, + pluginfileHandler: CoreH5PPluginFileHandler) { + + pluginfileDelegate.registerHandler(pluginfileHandler); + } +} diff --git a/src/core/h5p/providers/h5p.ts b/src/core/h5p/providers/h5p.ts index 335e5025f..557d2c3c1 100644 --- a/src/core/h5p/providers/h5p.ts +++ b/src/core/h5p/providers/h5p.ts @@ -17,6 +17,7 @@ import { CoreLoggerProvider } from '@providers/logger'; import { CoreSitesProvider } from '@providers/sites'; import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; import { CoreWSExternalWarning, CoreWSExternalFile } from '@providers/ws'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; /** * Service to provide H5P functionalities. @@ -29,7 +30,8 @@ export class CoreH5PProvider { protected logger; constructor(logger: CoreLoggerProvider, - private sitesProvider: CoreSitesProvider) { + private sitesProvider: CoreSitesProvider, + private textUtils: CoreTextUtilsProvider) { this.logger = logger.getInstance('CoreFilterProvider'); } @@ -69,13 +71,15 @@ export class CoreH5PProvider { * @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) + getTrustedH5PFile(url: string, options?: CoreH5PGetTrustedFileOptions, ignoreCache?: boolean, siteId?: string) : Promise { + options = options || {}; + return this.sitesProvider.getSite(siteId).then((site) => { const data = { - url: url, + url: this.treatH5PUrl(url, site.getURL()), frame: options.frame ? 1 : 0, export: options.export ? 1 : 0, embed: options.embed ? 1 : 0, @@ -124,6 +128,21 @@ export class CoreH5PProvider { return this.ROOT_CACHE_KEY + 'trustedH5PFile:'; } + /** + * 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; + } + /** * Invalidates all trusted H5P file WS calls. * diff --git a/src/core/h5p/providers/pluginfile-handler.ts b/src/core/h5p/providers/pluginfile-handler.ts new file mode 100644 index 000000000..b61de5b00 --- /dev/null +++ b/src/core/h5p/providers/pluginfile-handler.ts @@ -0,0 +1,121 @@ +// (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'; + +/** + * 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) { } + + /** + * 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. + */ + canDownloadFile(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 + * CoreDomUtilsProvider.extractDownloadableFilesFromHtml. + * + * @param container Container where to get the URLs from. + * @return {string[]} List of URLs. + */ + getDownloadableFiles(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); + }); + } + + /** + * 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 { + // Unzip the file. + const destFolder = this.textUtils.concatenatePaths(CoreFileProvider.TMPFOLDER, + 'h5p/' + this.mimeUtils.removeExtension(file.name)); + + return this.fileProvider.createDir(destFolder).then(() => { + return this.fileProvider.unzipFile(file.toURL(), destFolder); + }).then(() => { + // @todo: Deploy the package. + }); + } +} 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 1ae718161..54e627c9f 100644 --- a/src/directives/format-text.ts +++ b/src/directives/format-text.ts @@ -375,7 +375,7 @@ 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, this.viewContainerRef, result.options, [], - result.siteId); + this.component, this.componentId, result.siteId); } this.element.classList.remove('core-disable-media-adapt'); diff --git a/src/providers/filepool.ts b/src/providers/filepool.ts index b025fbbc0..ddea2ddbb 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); + 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; @@ -1313,15 +1330,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.canDownloadFile({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 +1377,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 +1420,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 +1516,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 +1613,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 +1646,8 @@ export class CoreFilepoolProvider { }); }); }); + }, () => { + return CoreConstants.NOT_DOWNLOADABLE; }); } @@ -1655,8 +1685,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 +1811,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 +1875,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 +1891,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 +2302,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 +2350,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); }); @@ -2667,8 +2699,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); }); diff --git a/src/providers/plugin-file-delegate.ts b/src/providers/plugin-file-delegate.ts index b6b9851b0..b1c33e1f0 100644 --- a/src/providers/plugin-file-delegate.ts +++ b/src/providers/plugin-file-delegate.ts @@ -14,6 +14,7 @@ import { Injectable } from '@angular/core'; import { CoreLoggerProvider } from './logger'; +import { CoreWSExternalFile } from '@providers/ws'; /** * Interface that all plugin file handlers must implement. @@ -26,8 +27,9 @@ export interface CorePluginFileHandler { /** * 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,6 +46,51 @@ export interface CorePluginFileHandler { * @return String to remove the revision on pluginfile url. */ getComponentRevisionReplace?(args: string[]): string; + + /** + * 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. + */ + canDownloadFile?(file: CoreWSExternalFile, siteId?: string): Promise; + + /** + * Given an HTML element, get the URLs of the files that should be downloaded and weren't treated by + * CoreDomUtilsProvider.extractDownloadableFilesFromHtml. + * + * @param container Container where to get the URLs from. + * @return {string[]} List of URLs. + */ + getDownloadableFiles?(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; } /** @@ -58,6 +105,39 @@ export class CorePluginFileDelegate { this.logger = logger.getInstance('CorePluginFileDelegate'); } + /** + * 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. + */ + canDownloadFile(file: CoreWSExternalFile, siteId?: string): Promise { + const handler = this.getHandlerForFile(file); + + return this.canHandlerDownloadFile(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 canHandlerDownloadFile(file: CoreWSExternalFile, handler: CorePluginFileHandler, siteId?: string) + : Promise { + + if (handler && handler.canDownloadFile) { + return handler.canDownloadFile(file, siteId).then((newFile) => { + return newFile || file; + }); + } + + return Promise.resolve(file); + } + /** * Get the handler for a certain pluginfile url. * @@ -85,6 +165,99 @@ export class CorePluginFileDelegate { } } + /** + * Given an HTML element, get the URLs of the files that should be downloaded and weren't treated by + * CoreDomUtilsProvider.extractDownloadableFilesFromHtml. + * + * @param container Container where to get the URLs from. + * @return List of URLs. + */ + getDownloadableFiles(container: HTMLElement): string[] { + let files = []; + + for (const component in this.handlers) { + const handler = this.handlers[component]; + + if (handler && handler.getDownloadableFiles) { + files = files.concat(handler.getDownloadableFiles(container)); + } + } + + return files; + } + + /** + * 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.canHandlerDownloadFile(file, handler, siteId).then((canDownload) => { + if (!canDownload) { + 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.handlers) { + const handler = this.handlers[component]; + + if (handler && handler.shouldHandleFile && handler.shouldHandleFile(file)) { + return handler; + } + } + } + /** * Register a handler. * @@ -92,14 +265,14 @@ export class CorePluginFileDelegate { * @return True if registered successfully, false otherwise. */ registerHandler(handler: CorePluginFileHandler): boolean { - if (typeof this.handlers[handler.component] !== 'undefined') { + if (typeof this.handlers[handler.component || handler.name] !== 'undefined') { this.logger.log(`Handler '${handler.component}' already registered`); return false; } this.logger.log(`Registered handler '${handler.component}'`); - this.handlers[handler.component] = handler; + this.handlers[handler.component || handler.name] = handler; return true; } @@ -124,4 +297,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.getFileSize) { + 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..37bb85907 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 { CorePluginFileDelegate } from '../plugin-file-delegate'; import { CoreUrlUtilsProvider } from './url'; import { CoreFileProvider } from '@providers/file'; import { CoreConstants } from '@core/constants'; @@ -62,11 +63,20 @@ export class CoreDomUtilsProvider { protected debugDisplay = false; // Whether to display debug messages. Store it in a variable to make it synchronous. protected displayedAlerts = {}; // To prevent duplicated alerts. - 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, + private pluginFileDelegate: CorePluginFileDelegate) { // Check if debug messages should be displayed. configProvider.get(CoreConstants.SETTINGS_DEBUG_DISPLAY, false).then((debugDisplay) => { @@ -252,7 +262,7 @@ export class CoreDomUtilsProvider { * @return List of file urls. */ extractDownloadableFilesFromHtml(html: string): string[] { - const urls = []; + let urls = []; let elements; const element = this.convertToElement(html); @@ -275,6 +285,9 @@ export class CoreDomUtilsProvider { } } + // Now get other files from plugin file handlers. + urls = urls.concat(this.pluginFileDelegate.getDownloadableFiles(element)); + return urls; } diff --git a/src/providers/utils/utils.ts b/src/providers/utils/utils.ts index 4f71eede7..c4f051645 100644 --- a/src/providers/utils/utils.ts +++ b/src/providers/utils/utils.ts @@ -1287,6 +1287,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 = { From b9850b08dc66d2c5704493adc38c950f8a2fc362 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 7 Nov 2019 11:41:19 +0100 Subject: [PATCH 05/19] MOBILE-2235 core: Fix site.getid() exceptions due to format text --- src/components/site-picker/site-picker.ts | 2 +- src/directives/format-text.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) 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/directives/format-text.ts b/src/directives/format-text.ts index 54e627c9f..9c4a8c314 100644 --- a/src/directives/format-text.ts +++ b/src/directives/format-text.ts @@ -404,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(); @@ -422,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) => { From 93259097d6104210717f7f3adb1bd126405e49dd Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 7 Nov 2019 17:30:37 +0100 Subject: [PATCH 06/19] MOBILE-2235 h5p: Install H5P libraries and save content --- .../h5p/components/h5p-player/h5p-player.ts | 2 + src/core/h5p/h5p.module.ts | 5 +- src/core/h5p/providers/h5p.ts | 931 +++++++++++++++++- src/core/h5p/providers/pluginfile-handler.ts | 10 +- src/core/h5p/providers/utils.ts | 71 ++ src/providers/file.ts | 113 ++- 6 files changed, 1093 insertions(+), 39 deletions(-) create mode 100644 src/core/h5p/providers/utils.ts diff --git a/src/core/h5p/components/h5p-player/h5p-player.ts b/src/core/h5p/components/h5p-player/h5p-player.ts index e3eac156f..e7ad45ce6 100644 --- a/src/core/h5p/components/h5p-player/h5p-player.ts +++ b/src/core/h5p/components/h5p-player/h5p-player.ts @@ -148,6 +148,8 @@ export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy { this.observer = this.eventsProvider.on(eventName, () => { this.calculateState(); }); + }).catch(() => { + // An error probably means the file cannot be downloaded or we cannot check it (offline). }); return; diff --git a/src/core/h5p/h5p.module.ts b/src/core/h5p/h5p.module.ts index f05f16c29..95874d33d 100644 --- a/src/core/h5p/h5p.module.ts +++ b/src/core/h5p/h5p.module.ts @@ -15,12 +15,14 @@ import { NgModule } from '@angular/core'; import { CoreH5PComponentsModule } from './components/components.module'; import { CoreH5PProvider } from './providers/h5p'; +import { CoreH5PUtilsProvider } from './providers/utils'; import { CoreH5PPluginFileHandler } from './providers/pluginfile-handler'; import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; // List of providers (without handlers). export const CORE_H5P_PROVIDERS: any[] = [ - CoreH5PProvider + CoreH5PProvider, + CoreH5PUtilsProvider ]; @NgModule({ @@ -30,6 +32,7 @@ export const CORE_H5P_PROVIDERS: any[] = [ ], providers: [ CoreH5PProvider, + CoreH5PUtilsProvider, CoreH5PPluginFileHandler ], exports: [] diff --git a/src/core/h5p/providers/h5p.ts b/src/core/h5p/providers/h5p.ts index 557d2c3c1..012689ff8 100644 --- a/src/core/h5p/providers/h5p.ts +++ b/src/core/h5p/providers/h5p.ts @@ -13,11 +13,15 @@ // limitations under the License. import { Injectable } from '@angular/core'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreFileProvider } from '@providers/file'; import { CoreLoggerProvider } from '@providers/logger'; -import { CoreSitesProvider } from '@providers/sites'; +import { CoreSitesProvider, CoreSiteSchema } from '@providers/sites'; import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; import { CoreWSExternalWarning, CoreWSExternalFile } from '@providers/ws'; import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreMimetypeUtilsProvider } from '@providers/utils/mimetype'; +import { CoreH5PUtilsProvider } from './utils'; /** * Service to provide H5P functionalities. @@ -25,15 +29,179 @@ import { CoreTextUtilsProvider } from '@providers/utils/text'; @Injectable() export class CoreH5PProvider { + protected CONTENT_TABLE = 'h5p_content'; // H5P content. + protected LIBRARIES_TABLE = 'h5p_libraries'; // Installed libraries. + protected LIBRARY_DEPENDENCIES_TABLE = 'h5p_library_dependencies'; // Library dependencies. + + protected siteSchema: CoreSiteSchema = { + name: 'CoreH5PProvider', + version: 1, + tables: [ + { + name: this.CONTENT_TABLE, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + autoIncrement: true + }, + { + name: 'jsoncontent', + type: 'TEXT', + notNull: true + }, + { + name: 'mainlibraryid', + type: 'INTEGER', + notNull: true + }, + { + name: 'displayoptions', + type: 'INTEGER' + }, + { + name: 'foldername', + type: 'TEXT', + notNull: true + }, + { + name: 'filtered', + type: 'TEXT' + }, + { + name: 'timecreated', + type: 'INTEGER', + notNull: true + }, + { + name: 'timemodified', + type: 'INTEGER', + notNull: true + } + ] + }, + { + name: this.LIBRARIES_TABLE, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + autoIncrement: true + }, + { + name: 'machinename', + type: 'TEXT', + notNull: true + }, + { + name: 'title', + type: 'TEXT', + notNull: true + }, + { + name: 'majorversion', + type: 'INTEGER', + notNull: true + }, + { + name: 'minorversion', + type: 'INTEGER', + notNull: true + }, + { + name: 'patchversion', + type: 'INTEGER', + notNull: true + }, + { + name: 'runnable', + type: 'INTEGER', + notNull: true + }, + { + name: 'fullscreen', + type: 'INTEGER', + notNull: true + }, + { + name: 'embedtypes', + type: 'TEXT', + notNull: true + }, + { + name: 'preloadedjs', + type: 'TEXT' + }, + { + name: 'preloadedcss', + type: 'TEXT' + }, + { + name: 'droplibrarycss', + type: 'TEXT' + }, + { + name: 'semantics', + type: 'TEXT' + }, + { + name: 'addto', + type: 'TEXT' + } + ] + }, + { + name: this.LIBRARY_DEPENDENCIES_TABLE, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + autoIncrement: true + }, + { + name: 'libraryid', + type: 'INTEGER', + notNull: true + }, + { + name: 'requiredlibraryid', + type: 'INTEGER', + notNull: true + }, + { + name: 'dependencytype', + type: 'TEXT', + notNull: true + } + ] + } + ] + }; + protected ROOT_CACHE_KEY = 'mmH5P:'; protected logger; constructor(logger: CoreLoggerProvider, + eventsProvider: CoreEventsProvider, private sitesProvider: CoreSitesProvider, - private textUtils: CoreTextUtilsProvider) { + private textUtils: CoreTextUtilsProvider, + private fileProvider: CoreFileProvider, + private mimeUtils: CoreMimetypeUtilsProvider, + private h5pUtils: CoreH5PUtilsProvider) { - this.logger = logger.getInstance('CoreFilterProvider'); + this.logger = logger.getInstance('CoreH5PProvider'); + + this.sitesProvider.registerSiteSchema(this.siteSchema); + + eventsProvider.on(CoreEventsProvider.SITE_STORAGE_DELETED, (data) => { + this.deleteAllData(data.siteId).catch((error) => { + this.logger.error('Error deleting all H5P data from site.', error); + }); + }); } /** @@ -54,7 +222,7 @@ export class CoreH5PProvider { * * @param site Site. If not defined, current site. * @return Promise resolved with true if ws is available, false otherwise. - * @since 3.4 + * @since 3.8 */ canGetTrustedH5PFileInSite(site?: CoreSite): boolean { site = site || this.sitesProvider.getCurrentSite(); @@ -62,6 +230,346 @@ export class CoreH5PProvider { return site.wsAvailable('core_h5p_get_trusted_h5p_file'); } + /** + * Will clear filtered params for all the content that uses the specified libraries. + * This means that the content dependencies will have to be rebuilt and the parameters re-filtered. + * + * @param libraryIds Array of library ids. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + protected clearFilteredParameters(libraryIds: number[], siteId?: string): Promise { + + if (!libraryIds || !libraryIds.length) { + return Promise.resolve(); + } + + return this.sitesProvider.getSiteDb(siteId).then((db) => { + const whereAndParams = db.getInOrEqual(libraryIds); + whereAndParams[0] = 'mainlibraryid ' + whereAndParams[0]; + + return db.updateRecordsWhere(this.CONTENT_TABLE, { filtered: null }, whereAndParams[0], whereAndParams[1]); + }); + } + + /** + * Delete all the H5P data from the DB of a certain site. + * + * @param siteId Site ID. + * @return Promise resolved when done. + */ + protected deleteAllData(siteId: string): Promise { + return this.sitesProvider.getSiteDb(siteId).then((db) => { + return Promise.all([ + db.deleteRecords(this.CONTENT_TABLE), + db.deleteRecords(this.LIBRARIES_TABLE), + db.deleteRecords(this.LIBRARY_DEPENDENCIES_TABLE) + ]); + }); + } + + /** + * 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 { + return this.sitesProvider.getSiteDb(siteId).then((db) => { + return db.deleteRecords(this.CONTENT_TABLE, {id: id}); + }); + } + + /** + * 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)); + } + + /** + * 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); + + // Make sure the dest dir doesn't exist already. + return this.fileProvider.removeDir(destFolder).catch(() => { + // Ignore errors. + }).then(() => { + return this.fileProvider.createDir(destFolder); + }).then(() => { + 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, 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, 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(() => { + // Remove tmp folder. + return this.fileProvider.removeDir(destFolder).catch(() => { + // Ignore errors, it will be deleted eventually. + }); + }); + + // @todo: Load content? It's done in the player construct. + }); + }); + } + + /** + * 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.fileProvider.getSiteFolder(siteId), 'h5p/packages/' + folderName + '/content'); + } + + /** + * 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) => { + if (!libraries.length) { + return Promise.reject(null); + } + + return 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 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.fileProvider.getSiteFolder(siteId), 'h5p/lib'); + } + + /** + * 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 a trusted H5P file. * @@ -128,21 +636,6 @@ export class CoreH5PProvider { return this.ROOT_CACHE_KEY + 'trustedH5PFile:'; } - /** - * 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; - } - /** * Invalidates all trusted H5P file WS calls. * @@ -167,6 +660,362 @@ export class CoreH5PProvider { return site.invalidateWsCacheForKey(this.getTrustedH5PFileCacheKey(url)); }); } + + /** + * 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; + } + + /** + * 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. + * @param siteId Site ID. If not defined, current site. + * @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 + }; + }); + } + + /** + * Save content data in DB and clear cache. + * + * @param content Content to save. + * @param folderName The name of the folder that contains the H5P. + * @return Promise resolved with content ID. + */ + protected saveContentData(content: any, folderName: string, siteId?: string): Promise { + // Save in DB. + return this.sitesProvider.getSiteDb(siteId).then((db) => { + + const data: any = { + jsoncontent: content.params, + displayoptions: content.disable, + mainlibraryid: content.library.libraryId, + timemodified: Date.now(), + filtered: null, + foldername: folderName + }; + + 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 siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + protected saveLibraries(librariesJsonData: any, siteId?: string): Promise { + 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(() => { + // @todo: Remove cached asses that use this library. + }); + })); + } + + 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: libraryData.semantics, + 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); + }); + } + + /** + * 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; + } } /** @@ -186,3 +1035,47 @@ export type CoreH5PGetTrustedH5PFileResult = { files: CoreWSExternalFile[]; // Files. warnings: CoreWSExternalWarning[]; // List of warnings. }; + +/** + * 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. + displayoptions: number; // H5P Button display options. + foldername: string; // Name of the folder that contains the contents. + filtered: string; // Filtered version of json_content. + timecreated: number; // Time created. + timemodified: number; // Time modified. +}; + +/** + * 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?: string; // The semantics definition in json format. + addto?: string; // 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 index b61de5b00..a7787a3f0 100644 --- a/src/core/h5p/providers/pluginfile-handler.ts +++ b/src/core/h5p/providers/pluginfile-handler.ts @@ -108,14 +108,6 @@ export class CoreH5PPluginFileHandler implements CorePluginFileHandler { * @return Promise resolved when done. */ treatDownloadedFile(fileUrl: string, file: FileEntry, siteId?: string): Promise { - // Unzip the file. - const destFolder = this.textUtils.concatenatePaths(CoreFileProvider.TMPFOLDER, - 'h5p/' + this.mimeUtils.removeExtension(file.name)); - - return this.fileProvider.createDir(destFolder).then(() => { - return this.fileProvider.unzipFile(file.toURL(), destFolder); - }).then(() => { - // @todo: Deploy the package. - }); + 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..3a36071cb --- /dev/null +++ b/src/core/h5p/providers/utils.ts @@ -0,0 +1,71 @@ +// (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'; + +/** + * Utils service with helper functions for H5P. + */ +@Injectable() +export class CoreH5PUtilsProvider { + + constructor() { + // Nothing to do. + } + + /** + * 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); + } + + /** + * 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 ''; + } +} diff --git a/src/providers/file.ts b/src/providers/file.ts index 32dc56bc4..dd48837c3 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); + } + /** * 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); + } + + /** + * 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); } From ef5f96a6438c10e07a655a7fce5ad0ff41c74621 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Fri, 22 Nov 2019 11:27:56 +0100 Subject: [PATCH 07/19] MOBILE-2235 h5p: Include core H5P libraries and assets --- config/copy.config.js | 4 + src/core/h5p/assets/fonts/h5p-core-23.eot | Bin 0 -> 9224 bytes src/core/h5p/assets/fonts/h5p-core-23.svg | 62 + src/core/h5p/assets/fonts/h5p-core-23.ttf | Bin 0 -> 9044 bytes src/core/h5p/assets/fonts/h5p-core-23.woff | Bin 0 -> 9120 bytes src/core/h5p/assets/js/h5p-action-bar.js | 100 + .../h5p/assets/js/h5p-confirmation-dialog.js | 410 +++ src/core/h5p/assets/js/h5p-content-type.js | 41 + .../assets/js/h5p-content-upgrade-process.js | 313 ++ .../assets/js/h5p-content-upgrade-worker.js | 63 + src/core/h5p/assets/js/h5p-content-upgrade.js | 445 +++ src/core/h5p/assets/js/h5p-data-view.js | 442 +++ src/core/h5p/assets/js/h5p-display-options.js | 54 + src/core/h5p/assets/js/h5p-embed.js | 75 + .../h5p/assets/js/h5p-event-dispatcher.js | 258 ++ src/core/h5p/assets/js/h5p-library-details.js | 297 ++ src/core/h5p/assets/js/h5p-library-list.js | 140 + src/core/h5p/assets/js/h5p-resizer.js | 131 + src/core/h5p/assets/js/h5p-utils.js | 506 +++ src/core/h5p/assets/js/h5p-version.js | 40 + src/core/h5p/assets/js/h5p-x-api-event.js | 331 ++ src/core/h5p/assets/js/h5p-x-api.js | 119 + src/core/h5p/assets/js/h5p.js | 2847 +++++++++++++++++ src/core/h5p/assets/js/jquery.js | 20 + src/core/h5p/assets/js/request-queue.js | 436 +++ .../h5p/assets/js/settings/h5p-disable-hub.js | 68 + src/core/h5p/assets/styles/h5p-admin.css | 358 +++ .../assets/styles/h5p-confirmation-dialog.css | 183 ++ .../h5p/assets/styles/h5p-core-button.css | 60 + src/core/h5p/assets/styles/h5p.css | 566 ++++ 30 files changed, 8369 insertions(+) create mode 100644 src/core/h5p/assets/fonts/h5p-core-23.eot create mode 100644 src/core/h5p/assets/fonts/h5p-core-23.svg create mode 100644 src/core/h5p/assets/fonts/h5p-core-23.ttf create mode 100644 src/core/h5p/assets/fonts/h5p-core-23.woff create mode 100644 src/core/h5p/assets/js/h5p-action-bar.js create mode 100644 src/core/h5p/assets/js/h5p-confirmation-dialog.js create mode 100644 src/core/h5p/assets/js/h5p-content-type.js create mode 100644 src/core/h5p/assets/js/h5p-content-upgrade-process.js create mode 100644 src/core/h5p/assets/js/h5p-content-upgrade-worker.js create mode 100644 src/core/h5p/assets/js/h5p-content-upgrade.js create mode 100644 src/core/h5p/assets/js/h5p-data-view.js create mode 100644 src/core/h5p/assets/js/h5p-display-options.js create mode 100644 src/core/h5p/assets/js/h5p-embed.js create mode 100644 src/core/h5p/assets/js/h5p-event-dispatcher.js create mode 100644 src/core/h5p/assets/js/h5p-library-details.js create mode 100644 src/core/h5p/assets/js/h5p-library-list.js create mode 100644 src/core/h5p/assets/js/h5p-resizer.js create mode 100644 src/core/h5p/assets/js/h5p-utils.js create mode 100644 src/core/h5p/assets/js/h5p-version.js create mode 100644 src/core/h5p/assets/js/h5p-x-api-event.js create mode 100644 src/core/h5p/assets/js/h5p-x-api.js create mode 100644 src/core/h5p/assets/js/h5p.js create mode 100644 src/core/h5p/assets/js/jquery.js create mode 100644 src/core/h5p/assets/js/request-queue.js create mode 100644 src/core/h5p/assets/js/settings/h5p-disable-hub.js create mode 100644 src/core/h5p/assets/styles/h5p-admin.css create mode 100644 src/core/h5p/assets/styles/h5p-confirmation-dialog.css create mode 100644 src/core/h5p/assets/styles/h5p-core-button.css create mode 100644 src/core/h5p/assets/styles/h5p.css 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/src/core/h5p/assets/fonts/h5p-core-23.eot b/src/core/h5p/assets/fonts/h5p-core-23.eot new file mode 100644 index 0000000000000000000000000000000000000000..f86828cffdda12bed59b2ad866f8df857a15fca1 GIT binary patch literal 9224 zcmcgyd2n0DdEb4z?*i}uJX{aNK>~Orz?%d>0Hj2bl*B`}J7HrExN8+SpNM;!f^Kd`)VPJ89A;g8JL{ zK#6kYZ^N!P#)!dpH-DToH>124^?s(g%o4 zi1Z2RJHUL@&FO2TOm#uG?4cO;rOy~4uY}m?^QXxeLqg@!W(c~Bcsad^9#g1ubLLQ` z2*=ilMGpe60GIjY4Zr;8ujtG7ynOG=54{pPfA0Kw!2tL@zr6of$A-~w>-Egmx11v_53&9L$>8nzA z{Z)Fr{wn&Q^p5ktk&a1@(8?x?RPjo_`u{j}gdD@ro)K%TQ?;NLBn)Ku%$Z^O%y}nA zdqQpm%@cx#S~{&+(wX7&L{!r!00?^U^3h}Gk4kq)XE9e55*Hw0Vv!_iAvP(HZXu|& z9JOmm>U+p5D{0iO&|_0m|L|@<>Fm_h)HKr7ja+pyrrsN#iz!?3p7=K$qwP=C< z^O3FJqN}$4*OBRO(~+%$<_~1ERF`%_p$N^XeyS=~#A>Ei%&OSA{3sovR)4H$#gr}@ zt=2|pty;(zdMREK8SL+$3P+@1U@%|k+2LXQ1ox@(303jzsCIQ$J-Sb~;%hrPd!(Jw z=oO`6t*@(dJZ|Yf@F`EJl*vyiz90BnTBZle)t-F576@V!g=At(j7u{lO|n?OrEx{w zag|HXC@RGPcNk(#!*-=yL5h}DiB$XgC!>)_bh5v%x*YD@w(ZU@mCGmkWN9WEnd{?1So2P9`yq_1MLVSix$on#a8Hr>M?f4676?)kGSkMGUKe ztSy(X3{=adSTv&8MaCDC9*;j}wdK3J{w!ZeCUuWtS#2vGSh2`8p3^9=|NGjVJJ+t+ zxpU2j7V=%)R~JB}>4CVFZ|g=WpX}^hM(6xTM5G9wWQh5ortYLDp>S-U>27 zHj`_}wd8uNST%2Blu;`ZE7nwuZqoiHEmK*0W0(!JP$7Ok*tEQ40l{MMoe5}15;uNAX&uROp43r>yN_wIF+%f46`WouM z8a(!n2_~|}SJ>?iM)f*O+`j5BIepE^H0P4ZqaBUDTr$zuXb1gp-Jt(juh72~vh^T& zlszkXNe5PJ7gnm5La=RhgqBxOY#Kx#`{w9DUM*M3B_Z>bfqbC|To(mmiKHm!6tz&Z zp{%wE%4iu{;r?pPU{ysc?Eh!YuJpa9yqcwHrk3b4gQliyO(ysABWrV|plJq-U0NiV zE0|%OtD0Va-LUjsJ-dz^IB?`%Bd%#d&NX=#kDJkSI!a$L0&53l1?@VUf;ltTq;skr z4;W@gQ*)p}$l_aRn_}ZB?-%D&8?g z#Tp9R;VdAU`j=Lu7i;KRkC>HHqD2T^wdSstsDQGdne!994AY{skf?(3ajS}0&}I1I zvL>LZK=;Z}z7&n3+kORa8~vdFT>j!5zx2OJ6Sy1LgPo1;~ITu?G6oy(;s#cNR z6>5v=Azkz7N^!2JgzS*n88Uoa*7TO~7Twah)-uwf#7Z%l-gxfQM$*tF*@#2Lb;z?G zlp(QPmjTPRKbDXAu~#aU!OtidTzV+&Z`0&Zt0_Y-xvDq$wS>VT9VQ2RE(LT&bLiV&iNTUU!a58XT)$WTtW}C7`w|j2nsmjU1g{M$ zmnlg6!R@kNqgTo_6qXf<_ssN*;c1%gH+V$!FDXXU-~s-9Syv5=3FKnjKEnt(Gtn_6 z15*w)o9K)OoaxAZRsL?Xp%`M3FiOri{c1lGKB+6TBrP`{h_lr*f=?%88J!h~-rMx>pYcbVbLV8uH&%7u?P%Z4rWoj@&ho0;#Dod^of_Kwj@&P@0)}-iA?qPRVsb` zQvf5Aef_ojWH~Db^o<*NP|wQp){UI!pm|p|${RPr%S7R2(5P=Jif5`14EhCJe+q;- z&e7IsvvCs-=qOKL;352=hkTyQutV@aKAgsK@PtEToNOZRb8UQhxzVm&u-cb5+F{`k zpb+oTA%vsZN>)bt4z^@JOt(BYJ_0iwf<2Y6iLmhdWqQHFuNRJh!#2-NIF0~R?e#2| zyef=1?P=KX(e{J%n)+YSzWRStmHvxUJ;$ZF(IrQ5rQ4aX-H=LQyX(($YO12Ii&X77 zS@;I@*1km6PD5|J@!TU=Gl`rf53!QefphH;e0DQU(}+@prCCJ>;QOjEQI0yUuT6L~ zx1tE$Z&c)6Li#LJ0scjK3Hf8P{1|PP@H&8s5t*JsT?ybRtkEd)-LSo{kq@$rH0Aj2Rpd$`r;6)}u}xVEj-v~hG%dqc z2B>4ua2A85661BN;FhzcD#i+DE-ycE&^cYvKOsltPjIT&zxrZ8pVR{%+yIrn3Q6O# z&~5?V&i%SBg}9;Vch>*f2nG$2=ofYUYnt{oUHAQk9ti07-Kp|jpjLc%?UYqryI-`x zOIdVV7$W?jG*0$PZ%S)$79mid##!NlR9-l1G|nOgY5#Fe8&MM)yi=1lqKO$!TN;KJ45+*Yl* zKF0Ct7^OhNDNwAYknxgpkSRxE(F<<46oJbYUfW%k0vKT8&JRBIA}5DgrO^u*xGmro;a1&45o8fR z6st}z4F29lId4Jdwn2(iBfRVwl)DN&|-KSM&fPL?(F>p5~!cVNC+f zL>)D|65X?9B_M-)YGtxElXGhlWZG!>IB%{d(X~3y1R7_iaO&BRW zW2)L@SN)1bD198wji{!@f{I5m5=OMeW3BC?j0T$hO$EQ!6k;0l zr~KiRNAW6pR9r$h@#z>N(wL)j5fPw?R}K~o)Q5Rb7#=m!dgpC2n_~*A|L7Y`rfjBpJD5w14t!)-0VCC? z?o-tT)!{yTx8n7A#2QI7k60`%O_KyJv0#Tvj-ELNNW_Uw#5f={L3ZsTR0z&SgpQ+> z-aY)Wu}oh$sVIp|OF!nlr>i?{+re^%`aGV~imoWCs$dSMrRmnza9;)oiap(3=|+Q9 z4~f>lR}#u1#_Cgk0ZPVh7UNRL)7ZN!q#xrZCrd`i1~MaVcASGTc1Y}C#l)V3U8w== z$V${~Ye0~ihAWCX^c7plSK1(34XF|~;T=lS^S>)jP8NSxnwTgJW#hlIvRMnSbFXFN zzl~={CQ82*HQ{hPOTYNI-!%P?`$G}mWgw|IS*-s=@S@q`WPN`;n~l?SJd=snfAP08 zEBJ=PqFNA*NZEvK`UO9~xhv!Xp`A+r)`gR!WSAY5#)w8tQbx>7usN35OI4O-i0w^y zk}%8^;=5jI!?Lmdn4o|Vi=anDI@Ka$sDq6|HAsbBc7mQT2ue7)f?h;Ig3a0Sd)Age zG*pnT?y7!xVCuwpG{3DUzrnUQ5g6@tb6@M`3 zXU(ztN;MreRF-UNf`2k1DV2V!zAqSJk#IP|{6XDd^izO*hA#UI7Kzk%;-HNsf^R*F zyF;-?1GrZpRJIF#VcUiEx3KN~IbmpUUr?#Jp&f!aSOEw|v8^ma<3Xdw6d@foO9a+X zbc@|ozni}p3T1Hgu`w<&ZhBO&CluVLN}S%z=}jUGc#Qw~%*<{4H1jh2PILPC z#qn?{t=mE9=Sj4p5Oa1?33-gLk{)VmW}3pJn{Ib3!I{=lsde8N_1pA?Z8&NZW8E-a+#$F$u3Z)JmZJu)fm~)|Ygkc-1_!dJ zN$*)-oGj&iM%KEEO098P%jkMfOLM30X>qJ)aZ*}Wyfqk)2hDiwxlH``R@U(KWFO7; z>W1|7iBkQiT_fHJ)iP-&o@G|+d{ZvhGzqjYt~6b`!WTE)*7fa<;mr6GYPP2*t0w&Q zpNjd4nCU3%!u@pg}X47LE~}XRTH;cj&fA&hFVBJ={U4@q*~i- z#n*?-RUI9@072jN~j`VhP42MEptyAAzU!Un}MUy_KrZ&>o z(Y`8Vit%wWcK$3o&8DHrEzsam#C2uU4!?sYvUkv7hMQvg!puz1j?G(cwej4#W%G{Z z@Rh6A%%iwLEnBYHv1KzD{o%z_>bliv zvS#&cI+aS#u3m%YtFKF?=&xaP2)+$;VIF7MQG6p1ceZ_Gn5-kySaDnwFNJUnLWI+k zb9;!R)dg5~4o_}T+@LMd*x6-;%7!A3AKG0Twwyh$Xv<=*UfrfQ#9ID@+ z9Sl!tVGljnR#D_c%ekNXyk4K-@%nBNfJn4c&^hc54sEP^98L@3U4Pu96H8=}>x*mC zN!)vYw_6wMJ3k#|Fh2;FgWoLTZJIyMcoS#oF zalpQFa*7PG-7MwY!%X1Diiqzb&cRX~jl>bjy-HV2NPm{$bOCt~cN(>_*xSB~!Him4 zCgLJo$4~6?GWl7F&r~(~$l&J9gR8l(w2M^S{yT&UIEj2}XmfKy=9ToNcTu_DU_;*YmHv?H~+=>C!mi+}XRs(*ip_d;@U5yk@Nw`n%b#MgX63;0^AJ6~fd zn;x&fUVnX@2J3H(QAWD1_>@a%(<4tbpZM9X!0>N~ z*YWh^hhIrujhaS&{-|S}@%PZ+rSZfA3xAJ?#tn4*UMYxZQZwe=raXl)zD}XFLA!z`2OHuv$)6jhCN% zuCZ2B>{|nt;02#=zzp~Y8ZZa^QUjI&FE(HW@E01e2k~_lX8?R!049bS7nblZBR_1w z4ERqrU=H}r1}p=oeEglDT5{;#G@_3%zh?;y+RjlMLM29zf#%RAy1$gU_%Taviw^?<<`Dak&bVg0}bS sJqHiHe{msG>@8mYA6wpaJnu@dWzXD^{j&!z{ZE>AZG~NelD*UOzoJKLo&W#< literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..3a3348adf7f30f859ea69837ae25932cbd9d4c89 GIT binary patch literal 9044 zcmcgyd5~O3dGCJR@0yu6GjFapJIC%EyEA*w&b=$Gc309KvL#1aMe9h5C3!8cSIF93 zp#$GUSS|&hSY?9?qkyp!oWw3D5{L@|SmhGIP=o{+Y|=qrZMjC?Uj6juS=(w*GOU|cn+_jPYAKyqw%7gaWepILru~$$Y zN4b6f;?WyFPFh=0-jDJl2bcHFoltJsNl02ldHdqrjfY5;22j2V<V=`L37mdHKOt0vFC- zxL{LAy*U5keZL?te+2dSy+Z7Idg14>PseVIY1Zx5hpppQ7&KOoZFg+GD50R_Th%TR zQXjI>FZFq^x>v~|7NI8z@PcXjs?^(fm7Z+8iZLj??ZU656A~3|vWX&9+>)p9-*y`z zCor|A1&?*A=GDA}i431TJ4~OxVCU#h$o0T^T;Nbkr*%s@JA8qNX8Je)fe%_9dg8)y z={D&c)~Z6{d?ZLL5+`jWOY)?b6w$jJ&eoCC-y^TCrQvLqo|u~YyZ8D@=ccBnW{|FI z<^rDCgh#NLlJjH>WAw*j{vCPMqIvp{$98;+uG{gS$7a4wM|KFD-;!ymA?+d14VqEC zR8_2y)k>|1Rn2B{qjZE?1CgQ?QF>^&Rv)4DT0WQWr+7(ZaA0667?S+H!Cby?w~O(+ zxkr`nRu$LoT2FV)rF(QMy0NRfPudd>UsfvC3q9TAQA_`UM|o1EOny@F{J_)JHd86r z`f|Ct&yS4~Qi(Y+F3pl8NrQhY^9nojDwph8RErg78iJ?6Y_(iP3YS%h)CvQW;ZP_% zIZ&vrhP!v}y!}h%^4$elnhl4h3IlIxt@altBVnmB(RjN2SowtlRF85piFs_sE>;Bx zYxP&Bm=+J6ynDN?G7G)Rk>RvuYLCReT0%B4s+q-2YXFUMUjZ^Y`z_4fQpE+3EU zF2k}q*4)2lnQJ_wQQr91jeGWN+^}cQh7T>}dU~(S14+|;Q7hNci&8G$-M!3tS~lb> z_H1xaiHkf+_OfHrcH)7oWwCo}$OM@tSCOm9wcuDSm&Gi@Rwz=etC-&?tyKzCl_Rxk zy^_nS8Kr2cN~n#BraV(3Wvf)H<}10Pn$Kl?R5aB)KtT;f%JovMn6C>0qRC=r+GJ@n zO+UaDz96d}dH?27u5(M4M?2{+W$MxRBA5C{Ik!+f+L__9yvS8k$&AXHY;iu;*$8k7 zQCZ})b5ur^KzmYpzVrME={EWr+CUmK_Kyi7vc}hBJM9|PZ4+_EYP@8RwII`sgC>u5 zHOF$$#8{(U^urB<{zs!q|6It{1LP6*jN~R=;MiVps-Hrzv+4*fuc6p9h(7ks)`Of{ zu9ize=Bt%lzKFUWii#DKqMT9Gd_4arm!TETuigq$HT1&%f7a|t-gDZmS(;{Q zv4ZJ0HC<~lxtAZ?m?`;9(`W3}LjFwN4C-9f^v3IkrSI+Ad+gAmWA_+QP4jcE$$NR! z3@4Le`ikM(I4CRV*WKdJnEn=>Q|+YBFuPh>yFJUYx`q>@Q2~sBq$xGA+P9$g%>Yq`v-84Ipt70hw8BwD+`d%wT z`N5`AqzLmcP%5Ihh8C@kn&60vcSKRaLt#5?2BNEXg(Lmop<_KFRz?XIA$YaA!!6MO zWkEOlCq@~jMVpamg7I;hieTuf`r@iCz^Ov_%22)p4P)2=1@A1z!T6cnr8R)`5M|Ct z1EsRS%!rY?X%TWRwjNcOYF(*WMRrG^Bcca%&7&*Dg`yJ32F&h&;o-8Tw~e>ymd>@d zkv1h#ipcc(^Pe{2hAzoQ6e6xeo^`(riRHQsSk8JQxri5grBWI6jJ(062b10oO%Al1 zGW3$GdW%$g!C3`h`xl98=S&?|(?0}e_rs-aThs5}j zVuTIuA((_&a>gk~XT8PH zne5WJ)Hd3VdQHMCuqc^E44G!H5Osnjz>insx*v9ew*_Rc97*U@j zx5CSW;bqXNFcro#RRDnj0XLomVuo|{wfk({#(g@ldx|I~(nwa0pO{-_s!k!|7^TM*0r6yZ9)VPRqJn0(e?hUnl;mqaLCl0!xEBYto zko*Zw^~P6U@adDf?}J;Q(pMm9To&3bz+1Uj*QEeAH2wC*UmAYDArk$fu76F_zNYJ* zKhu3a{odPEz8Bbv2d_P{s%!U&9(XCsP7gzbACyMPe(6nV1I{7@>eDOoMl7(MB|pBWn?POhuW_0MOPs4eJ1&JPGWJh%ps={e=rZWoxA+c=Ab% z>i8i=*m3cL6LLIPKNMWJ8l2l|b;rlpULCU()o=&oFdO6hRF!moXHN014+}LuJV>xOdT>_vJW!lP$YcO4VOZ2*}`i(Y{`QF7Hn3>YcqkuavJVGnr{2k2}C5O8vf6he}6p9hEf2l?)js zc8*e?p?VeFhX|2Lu9&NJD3RX~LpRYz&7N5ATv_qSpq^M8ug_+jme_bVw#}FI^}-<~ z>amVNA7zr!;(;{a)D|cvtwIZC3eT9RwPb5v(Hiq$7LqKPm9}_20W?Ys&|#XyTQD1%tH^ zW^Aq4+qJX=qbK4KMnb-a!rn=5_#t0t6-bkBudy4&7&19aGAt)Og{Za{QP3or$H3uG z1XOM7Bdk#e_OypuWxE=|LDqDvQ`JPgq*gJq5IiQ%!@{10t%x{FR%sQ06PT=o93mRA zsSMPr^myEV+#j=o$Nh17>N;22xHq!PH12K8beje}gA=aLb&9#JV~pix##A;tt8m8T z#+}1px1lIUR5w4un4x&suv>|l^fFMgLW1T)*Xy3{I}Nk(1l`nl!Z1PbxRKPmZ;{yo zQ&{6i-(WIjv#q;8Tw=8HkzoadRF8TCt?)jeHWH#$Br>Mu}I-}@{ zqN)nka7LPGZx0qyI8e;@_9UAf);%bC|Bn(=mN8e4@^fG^cB7b=LY~6jT_*h)H#uoC zLbi}uakFC|jIl#v2dgIbBHV*(oj138!Mf*@H+omI{NEqdSs&XYta%6M$`0*k9keg`}3IM)va-0ma zh)K$bnF%(>GW)5@(hRY^2~QG)nL>QmPqVOWY#<^qAjBf*5s^-<$QasS$20` zsdbUax>WZc<<^zPa;d2tg5}hjme^3r<#S~xQvQz6So`KYAj3#aWb>Qb*G96z&nDw} z8q2y;jc4g_Qp+`dmXF2gzDQj0`u$$k8fmOmlR-me@s<|&CnJ9-mO`~emU21Cs2 z*9}HL1;}IQvd3VdP-71c+F%iU>v7y23LaH(uRy4r&3lDy7uMgxwhv^4p}~DYweEy= z2;yJ`AQ;7tvJ8y}jv7&fbkr>oSVPe*c0=P%{(?W?S9GYW3G>FrxWu^WQr)hA|9~oS zdLySdh%n$${-?9Ex9~H}&G0+J>F1ZngQcXN^+P{Tp%;aivr|gIWdxPvP)jS*6eitp zt8EF+wwFrn2gWF0djM-;zi=P4#1VZ{*56#8~ghz zt6}NOvG^q#m*9Bp{NcAyS)sV2r9~?LUpfv=*r-!vOu8QXStR#^10q&-t~LfL#VQK8 z;eb2?5XlzhFaVM4z_^SXHo)(Qm82K30N^$b?{}ae+K`-40aP)vLoYLsrO)rgQOkZb zZi5`QT;sP#P^Zbai$X?029>`?DH!K%- z2o8>8S4F&KtAS%6SJ~J)IBMhIKo&OXU7L%OrJTn|TX#^YJxc2-UGHma?bcmww)HGd zN}Gx|`=e368I3%fivEw4HavalN7DVeA$@(K)c9%7h%=WaSOTlibj}*E(*9A;5KTgIjoMUI$3^ch78a#@)u54z5@1lw9 zU38e?rkFlIJKMK=di%{;JU4Hj-n|;WeEo*G1fIDK>n~pg-!MD-C2G(I&|!M}?yI(M zzbV_^p1o=N_N#Vpp9Z1dzK}>)aRL-ASsbZZ15>-QX3wqKIt#q_$qRXk3K~{1p zzJe^)tdP%;UbdHYL6>sSrb%ei0#+<$y-J@D%SJ5RpW-6lUXHCFFb;1i1)H79MMSFb9+4Q@993neG%#wc-sc) zhGnhddhEMecnOyI_fi0>ly!BQNJ#1YB4 zN>@!tf12TR0eKL28uhZ++rp(_My;7q)5{&=zdpR>i-^PxmC7)mDp2B88@BA*(XZjH&jnmdZ~ z#nAL1JvOw>oyug}+*UYLieW#erAbeh)wsK(+TCW-W05XTIFKzCvtIw+L>PoZp=2u} z1j^~Qp~hXAHGy$g$U{HaQH8%k9QAg7EdGCRWVJy2;n+jF63ffZFTS+=2WM8}`zy4U z;>*i07C67nuo))4<`bI7*IM2F8cW&Cc;ofP>*Lhlcw?NJ)EsZTA^pQd<8^9IG~Q^u zK0!_U1$o4$TtZtOe!TVWf7|OD{uOcCo}T>hD~T)7(#$U$x2-e2|HCIb5Bk~P(IL>{ zVdrd0PMh=YbXuH(=!WvEoY32l0Nd#V>dqtm)=5;HGaf~H0O@h3j(i^ZIi%OnCyM+F z$kR@Lau#_Gbx$E3Z>A?ucM9;cNTS~uQV-H+n&~uhk)E(;<{}}|iU@uUqQI-kljQ4E zqg&|1Qcd~_&+&WYoP4AFGbN$iqn=j3t6i-au;yS1d0t^~0ep zlO=MLq~O-(0nMY=9Ln?f44Wd?kel$n%%*k)Fa>V^mGg&>ynlHqRqQX4EA0+P$ouW4 r6i(;;r1+lmc~6Ax^9#og&K)M(L4JW8!zaEua`-)aVV9s}@AmvJS2tPu literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..ff03f42489c69ff862254a487b6de5afb4b28998 GIT binary patch literal 9120 zcmcgyd5~O3dGCJR@0yu6GjFapJIC%EyEA*w&b=$Gc309KvL#1aMe9h5C3!8@D`f4i z(1C9dmP^4WR@vafC}8XaC$S5P1mc1KR=Gql6d}PzRN)jypuks%T}}ujpizGP-mJ8C zjLjcZ-cG;%`s=U1zP_WsetZ4a$w@*9p6tWKZRa~swlSi#`q}!&g%TlD1MX^(d;z6@ z-{RaM;P#_@yGZ~1`t6m4xg!GiZzu~|k^Bn>Z@d<`|0JaK-6E|&{I#?D=jQ;YcA~x} zQe^)}H|_@#xcg9U7fF4Py|jPv=ncSq9_5Ec`Y35_J-ECNbm<)^Zx`u=a`Vo`xf{@L z`Y%yV*{MncOLL3!z&(S$|4yXC=e{|7X!*!d%=h?3ItGZb*XeykCPey}^evP>?Bw(n zQl>h}s~_rLT@vsZAuk2knG0vg7()VP^$Mm?Mi0%DK%IaScnhio;bcGZ2=N1>0GImN zwLkmt&*_VIzIgYG54;q(aQ?yt)Dz(6e|GQB$%`KX{@#~}ji(oW8vA7IhL~pEW_{2) zZiSm%?FMgpVPCpgMCWlyro+O|Prs>O4Z{uZpvhgw)QhMu! zUrHw=D(YktMXI@xed>apqdg(lf#xwmLoJ=w zE$Qs=1tO~HV*ms_czNiF3&*8frE{383aRyxAhAfCw2>^ylU`Cp>vA|-M^b-}ytl?WrDDC%(>)%w z^zV6;CsfMhClt^3JZ)_=m2#~wm#h2y*eD^H7!%{tEJ>0y)^BB8VP{|De!c+Jqnu1)9GkI=Rk4D#dM$@}<4;lTzX(<-QmTtINQ)R&9$7~wSFO~_rARoW zWQ&Y1$6YRO#OlcP_WVgMACK!U!?HTo+_z?#YdoV--uRb|d-iPHuxHPP_b=sodaulb zNYi~$E7#GBQZC-zz07%9HsmYzY;Z`4i#$U1vSZSA;(@GXv3qOC1eqpR!4|@b(ONEx zQHHHhq*zxmzEN7M6sRgkYSnrrmsK-L(NdLA8x>V~rbNnCsaDNbaz!jNzu>YSndy;pbc59ZVnOdx1`b|yOT1@Wc$2Mk4e$(_Bd$o{1lQ)ApS2exys$uDS z`}Q6?bm-XKMpV=MoNMx49yP&|Jes0Y<%S8Nlnpozpe!g)9@w3W`e-RKSui5?a~QSCkM$3)Q6Qw&?3}7 zqhz~jb|6>9R0c8vqd59rD?|CQOr=N>=3$^zL~#u*S{*g9A}ZbyMa3El+hH#tntE4O zq#tYOSdWO6QNl$CUajt|mZ*TTpqc#>y$sW$y^yGa@o}n(SkP7Y;;JT~sY3V4P`(5W zquT)m?=1R3|C!vSIe_&LWlm26rLv&Rh@QG>5ppiJ9w-d8uGFj|yFJhm(F3~X(Usyt zQ3+%NW_Q5wa9Pva#@lpD=UUrHn-VESWP08CPa1JUmt-Rf5!WHlx?hIGa$N>2XT6bJ z#EZRBsSJKb-r&*$NpFWH2ii>;ddXG2#jC{(4(TvC*mKFJD?C+?QMpV(;`Q&6y&An- zrh%ZWNW5=$KnzdQbg#ifqJK#-!Up&8@5;JrU`!wvk?IGMi#ySy>+gTb<)6V zuRZe+)=VPj$OEh-b>SR41fShXlQg6hVQJRU3VdHJBFbUg^>ql3=2R4+`^}1+Lr9;7 zD!{)eFCc$JmLH+5vixybQh(k|AAg0r_$Zf7%eq_wcv@EE5!uBL$xMFt0lW^OVnn8= zQC9+Z8f!F)d>?G@E98AFB~97BdmXu4$f@GEVr)~|g5&4`CP~Y%l|E`4G@Qj?sl<4l zD!AozsfMw_naitB9CSff^pD9Q`D2{wjW0j%(H(>|B9x4Mb|xls{4HUJ-4ZRFQ^p{UVCI!*X|W9@KTnY7KR8vD2;fbMd~ zs&O2&>HYCQAQnTCmP}uJ`|g^{qf9PMLUD5MGIU>9FkzDJa+PwqzED0GE5$Tb(REXI z<-sFCC3^nZV4MmTF~VpX@yidTtH~5OMG!N}8R42U*EyGABI(_IUS0(^n?0feHn?mbfYuDRfd^IGKLkB=t_;mWOsj#DV$Q8>htO@ zlpM=CK)XrNCQr7fnw4sv|yz0jEP!Hw&oSJF%L!| z$&y)Vi`Nq%qx4ZUH^Q10@hdLHh#BEFm$k8nGU{vfw&cB9OMq$2oA3q`F2$|rVUO?+ zB0e2sL>jYoE+hgp@yfu0!CDA2wpQ%zT3Uk96Y&TmA>V^x@1!^Upf9uvq{%m9>`Kvx zOwN)F%Slfns_jJ-G)d;saX1tKSKImsYt(@~?V(oLMk6@LnvQjq;BvAoQf%4TO3&Y0Y|V;IYAD9RDl&5tl}QKR$?Z-44kZx;Q8Qnx~KaN z!)!cGH#HtNOfWodB=zo_WwyW+*7(8Km`vGh>n<>t7_EG0SOFu|qaIMzCDrCWe23!p zxWpPsG>2F$Db0`==@WLSWb2u2fJB^VM~oGr39@4sp+ayrB6J+3^p4?=jHL>}xT3^T zZ3CG1d{1vOoAsAd)Z=oUQFKL7RRwc6Bh9q82MZ}2DCT>6lFbI|9uTd6qr{YDjMbz3 z6qJnNf{Nho=1J_`Wzr9ElanSRWDA)UH#_#h7&|0(uxesYf{xSxwq+%3b~GVKP16;H zZTf0f$yGZbTTQ7FHsNhb(zCxVPEHnoU7DCE4W*;Mw$f<}uk){@qrZx#MTk9q?k&s#uJakAL>k>Ews#mUCOXgVFG$!IDSZT$4FXjGW)5@(hRY^2~QG)nL>QmPqVOW zY#<^iAjBf*5s^-<$QbHi<4_G!VVCWoCkTQPPOhLAk&s|>Zv4)T<@XQer7L@CAFNE> zH6G6G?8|M*X1C<}cIL)P>$20`sdbUax>WZc<<^zPa;d2tg5}hjme^3r<#S~xQvQz6 zSo`KYAj3#aWb>Qb*G96zPbcGf8q2y;jc4d^Qp+`dl8?pczDQj0`u$$k8fmOmlR-me z@s<|&CnJ~HGgn46QTw>gGscu)me?XNuy@Au~MHui1|G@0*&HM~=GyKkQ`q}02U@56*{m{=- zXhk9B?35C489^mE)Y8f{g-O@nVq1c81}d7SvzjPym^C#@yBFdOHd z>rMAb&8VPgW-yUFWL#)0?s05o4u{0dai3$pZCi;%sfd-GtBrw5v5Eq2I3UjeM6yLW3_v71FfQYU4e(o1CFw;f0Jx3A`z<(# zIwYr809Ewt@XHKj>2o`A)UqFq+aQN6*Z6G_;57L*QR!eJ2zFQC&&Z+;95yaNDE$^- z061tz9Q*7(k|Tz62Y?zgM&7To2+C47P66v8>g1@_??vVjdsUnNmU|ClW?&~9M^ zEu5PiJ2_CQWAs^hAd|(qVY;|OaA+L6D&j3$4IBfx%Es2QqBacXqqLsV^}e>&Zr#;pThHR8w5fQLKN|I$(a1BY=x?mF;pt01obJ~R>8lf^#*ceO z+!Ly0(o{6ftoFs0Or~WLXklDwvUHgzYC5eOyKKXm^2XG3Utd~{c^f|#^A$1Ean^(T z>lRo;aU+&(3VC@&2O<{k!c+#0$9-2_+-}**QO!2hLi$nHk&Pp@`m`0@95B~)b@i8b zwoQJ0%a5Gw&6U!7`)gKox>g(M@9G*31bW)1zP`CJ+tZFF1-qs`Qt0Yj7cj;6I2pTe zj-6pM(Bw8~@F?QCvY8FOjV7|U(P4(0V*1?dY~Swb?Kfrd+_Zgq_iFg^^&93Ac;+^& zzkC&Z{p{=)s6p>XgX!(NuiC!-#%z0g_QvhouiCwR8jODXd?InpdNkRvelD3vBT{ks>139S!rjby}csRPA99-T>|JRm(LH`Sx0-tYGOL11n@vq&YxL9u}%RA z)R0|+mh}BA4Q-xia%!cMm0XIi5Q{l0P-itJ>YwJ+s-t=H_N(;K^;f|^z z$J);S#N&2*442z;qX0yr-Ga_$cX()P!)0??67R;NCY@LzgIr%)n{MLV1N^Rav3{?) z<13bGsaD;p=j(4d^+UgNngq)pWGf;B%IUVD#+{ipfpJ&JL*Lg?g}*`^_2%_h{Qp>y)dKN{ zTOQh#SYCF1@ulTI_+mA_yTW@ZzPt=$f%Dr8n_&cYlh8cA*6Q}xSjuL`8?QEA9jE@r zYva_U=6K^Z>F*~RuTpcO@mk~632NFe$Rk2!LR%hsto5#c-Rm3vC2`xHo_zmHi7Qdl z%r6|bt+V(a0sBLw)6V{m4uKXAJ7;sU;pVH;sc}l88OkqnLT^O^Y^M{zok#kulc+dn zJc4vT(qj&ed=~jRq*u@;iu`lP(@uMG7I_Z1Cy|af)8oLM0{je;Xg7w`gY>CpI*nYU z$L*21NQkr|f?tCu@M`h|`6|`u7W$A>lfJ}r{BAiX-yr`)Nho)#r`2z3S8Gq`gZejJ z1FpYuZ*rgU_&rBG|8Cr3yzD*f^ZW2$7xpT2;vWy}i-?P>_+$D1$*{CX3X z0pHYw6~JF^!Y=rvwfn1w`t~g!p6@Fa5g=E|A%yY!FoeUn?C2vUQk+~~I+|LTUz$HW zcXa;UscUXbUAAxeisj{{emJycvP6!O6x`Z8pn0^KLwO#bVN>K9awFcC+1#!GrJ(J< za{lm<_be}^iv2}$rQP5Nd5>L{!s)!96yI????|wHe&N``xx@HplzEKn7(VgMk;CuU M3cCa)d%Nd<0Xg+y%K!iX literal 0 HcmV?d00001 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 all the H5P data from the DB of a certain site. * @@ -263,11 +563,56 @@ export class CoreH5PProvider { return Promise.all([ db.deleteRecords(this.CONTENT_TABLE), db.deleteRecords(this.LIBRARIES_TABLE), - db.deleteRecords(this.LIBRARY_DEPENDENCIES_TABLE) + db.deleteRecords(this.LIBRARY_DEPENDENCIES_TABLE), + db.deleteRecords(this.CONTENTS_LIBRARIES_TABLE) ]); }); } + /** + * Delete cached assets from DB and filesystem. + * + * @param libraryId Library identifier. + * @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 deleteCachedAssets(libraryId: number, folderName: string, siteId?: string): Promise { + + return this.sitesProvider.getSite(siteId).then((site) => { + const db = site.getDb(), + cachedAssetsFolder = this.getCachedAssetsFolderPath(folderName, site.getId()); + + // 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); + + ['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. + })); + }); + }); + + // Also, delete the index.html file. + promises.push(this.fileProvider.removeFile(this.getContentIndexPath(folderName, site.getId())).catch(() => { + // Ignore errors. + })); + + return Promise.all(promises).then(() => { + return db.deleteRecordsList(this.LIBRARIES_CACHEDASSETS_TABLE, 'hash', hashes); + }); + }); + }); + } + /** * Delete content data from DB. * @@ -276,9 +621,17 @@ export class CoreH5PProvider { * @return Promise resolved when done. */ deleteContentData(id: number, siteId?: string): Promise { - return this.sitesProvider.getSiteDb(siteId).then((db) => { + 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); } /** @@ -319,6 +672,19 @@ export class CoreH5PProvider { 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. @@ -350,7 +716,7 @@ export class CoreH5PProvider { const content: any = {}; // Save the libraries that were processed. - return this.saveLibraries(data.librariesJsonData, siteId).then(() => { + return this.saveLibraries(data.librariesJsonData, folderName, siteId).then(() => { // Now treat contents. // Find main library version @@ -368,7 +734,7 @@ export class CoreH5PProvider { // Save the content data in DB. content.params = JSON.stringify(data.contentJsonData); - return this.saveContentData(content, folderName, siteId); + return this.saveContentData(content, folderName, fileUrl, siteId); }).then(() => { // Save the content files in their right place. const contentPath = this.textUtils.concatenatePaths(destFolder, 'content'); @@ -386,9 +752,374 @@ export class CoreH5PProvider { return this.fileProvider.removeDir(destFolder).catch(() => { // Ignore errors, it will be deleted eventually. }); + }).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); + }); + }); + }); + }); + } + + /** + * 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 dependencies = {}, // In web, dependencies are built by the validator. + params = { + library: this.libraryToString(content.library), + params: this.textUtils.parseJSON(content.params, false) + }; + + if (!params.params) { + return null; + } + + // Get the main library data. + return this.loadLibrary(content.library.name, content.library.majorVersion, content.library.minorVersion, siteId) + .then((library) => { + + library.semantics = this.textUtils.parseJSON(library.semantics, ''); + + const depKey = 'preloaded-' + library.machineName; + let nextWeight; + + if (!dependencies[depKey]) { + dependencies[depKey] = { + library: library, + type: 'preloaded' + }; + } + + // Get the whole library dependency tree. + return this.findLibraryDependencies(dependencies, library, 1, false, siteId).then((weight) => { + nextWeight = weight; + dependencies[depKey].weight = nextWeight++; + + // Handle addons. + return this.loadAddons(siteId); + }).then((addons) => { + // Get the dependencies of all the addons. Use a chain of promises to calculate the weight properly. + let promise = Promise.resolve(); + + addons.forEach((addon) => { + const addTo = this.textUtils.parseJSON(addon.addTo, null); + + 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)) { + + const addonDepKey = 'preloaded-' + addon.machineName; + dependencies[addonDepKey] = { + library: addon, + type: 'preloaded' + }; + + promise = promise.then(() => { + return this.findLibraryDependencies(dependencies, addon, nextWeight).then((weight) => { + nextWeight = weight; + dependencies[addonDepKey].weight = nextWeight++; + }); + }); + + break; + } + } + } }); - // @todo: Load content? It's done in the player construct. + return promise; + }).then(() => { + // Update content dependencies. + content.dependencies = dependencies; + + 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) => { + const dependencyKey = type + '-' + dependency.machineName; + if (dependencies[dependencyKey]) { + return; // Skip, already have this. + } + + promise = promise.then(() => { + // 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; + }); + } + + /** + * 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(); + + return this.getContentFolderNameByUrl(fileUrl, site.getId()).then((folderName) => { + + return db.getRecord(this.CONTENT_TABLE, {foldername: folderName}); }); }); } @@ -401,7 +1132,339 @@ export class CoreH5PProvider { * @return Folder path. */ getContentFolderPath(folderName: string, siteId: string): string { - return this.textUtils.concatenatePaths(this.fileProvider.getSiteFolder(siteId), 'h5p/packages/' + folderName + '/content'); + return this.textUtils.concatenatePaths(this.getExternalH5PFolderPath(siteId), 'packages/' + folderName + '/content'); + } + + /** + * Get the content index file. + * + * @param fileUrl URL of the H5P package. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved with the file URL if exists, rejected otherwise. + */ + getContentIndexFileUrl(fileUrl: 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(); + }); + } + + /** + * 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, 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 { + const displayOptions = this.getDisplayOptionsAsObject(disable); + + if (this.getOption(CoreH5PProvider.DISPLAY_OPTION_FRAME, true) == false) { + displayOptions[CoreH5PProvider.DISPLAY_OPTION_FRAME] = false; + } else { + displayOptions[CoreH5PProvider.DISPLAY_OPTION_DOWNLOAD] = this.setDisplayOptionOverrides( + CoreH5PProvider.DISPLAY_OPTION_DOWNLOAD, CoreH5PPermission.DOWNLOAD_H5P, id, + displayOptions[CoreH5PProvider.DISPLAY_OPTION_DOWNLOAD]); + + 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; + } + + /** + * 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'); } /** @@ -514,6 +1577,19 @@ export class CoreH5PProvider { 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}); + }); + } + /** * Get a library ID. If not found, return null. * @@ -551,7 +1627,7 @@ export class CoreH5PProvider { * @return Folder path. */ getLibrariesFolderPath(siteId: string): string { - return this.textUtils.concatenatePaths(this.fileProvider.getSiteFolder(siteId), 'h5p/lib'); + return this.textUtils.concatenatePaths(this.getExternalH5PFolderPath(siteId), 'libraries'); } /** @@ -570,6 +1646,44 @@ export class CoreH5PProvider { 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 displayoptions DB field. + return 2; // CONTROLLED_BY_AUTHOR_DEFAULT_OFF. + } + + /** + * Resizing script for settings. + * + * @return The HTML code with the resize script. + */ + protected getResizeCode(): string { + // @todo return ''; + return ''; + } + + /** + * 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. * @@ -636,6 +1750,17 @@ export class CoreH5PProvider { 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 { + return true; + } + /** * Invalidates all trusted H5P file WS calls. * @@ -673,6 +1798,196 @@ export class CoreH5PProvider { 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(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 { + 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) => { + + // 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, + // The embedtype will be always set to 'iframe' to prevent conflicts with JS and CSS. + embedType: 'iframe', + disable: contentData.displayoptions, + folderName: contentData.foldername, + title: libData.title, + slug: this.h5pUtils.slugify(libData.title) + '-' + contentData.id, + filtered: contentData.filtered, + libraryMajorVersion: libData.majorversion, + libraryMinorVersion: libData.minorversion, + metadata: { + license: 'U' // Stop "invalid selected option in select" for old content without license chosen. + }, + 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; + }); + }); + }); + } + /** * 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. @@ -727,14 +2042,44 @@ export class CoreH5PProvider { }); } + /** + * 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 siteId The site ID. + * @return Promise resolved when done. + */ + protected saveCachedAssets(hash: string, dependencies: {[machineName: string]: CoreH5PContentDependencyData}, + 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 + }; + + 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, siteId?: string): Promise { + protected saveContentData(content: any, folderName: string, fileUrl: string, siteId?: string): Promise { // Save in DB. return this.sitesProvider.getSiteDb(siteId).then((db) => { @@ -744,7 +2089,8 @@ export class CoreH5PProvider { mainlibraryid: content.library.libraryId, timemodified: Date.now(), filtered: null, - foldername: folderName + foldername: folderName, + fileurl: fileUrl }; if (typeof content.id != 'undefined') { @@ -791,10 +2137,13 @@ export class CoreH5PProvider { * 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, siteId?: string): Promise { + 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. @@ -839,7 +2188,10 @@ export class CoreH5PProvider { }); }); }).then(() => { - // @todo: Remove cached asses that use this library. + // Remove cached assets that use this library. + if (this.aggregateAssets && typeof libraryData.libraryId != 'undefined') { + return this.deleteCachedAssets(libraryData.libraryId, folderName, siteId); + } }); })); } @@ -1002,6 +2354,77 @@ export class CoreH5PProvider { }); } + /** + * 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. * @@ -1016,8 +2439,60 @@ export class CoreH5PProvider { 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. */ @@ -1036,6 +2511,14 @@ export type CoreH5PGetTrustedH5PFileResult = { 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. */ @@ -1045,11 +2528,109 @@ export type CoreH5PContentDBData = { mainlibraryid: number; // The library we first instantiate for this node. displayoptions: number; // H5P Button display options. 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; // 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?: string; // Plugin configuration data. +}; + /** * Library data stored in DB. */ diff --git a/src/core/h5p/providers/utils.ts b/src/core/h5p/providers/utils.ts index 3a36071cb..9c568acac 100644 --- a/src/core/h5p/providers/utils.ts +++ b/src/core/h5p/providers/utils.ts @@ -13,6 +13,10 @@ // 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. @@ -20,9 +24,120 @@ import { Injectable } from '@angular/core'; @Injectable() export class CoreH5PUtilsProvider { - constructor() { - // Nothing to do. - } + // 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. @@ -43,6 +158,161 @@ export class CoreH5PUtilsProvider { 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'), + }; + } + /** * Convert list of library parameter values to csv. * @@ -68,4 +338,71 @@ export class CoreH5PUtilsProvider { 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/providers/file.ts b/src/providers/file.ts index dd48837c3..915ac5a36 100644 --- a/src/providers/file.ts +++ b/src/providers/file.ts @@ -1239,4 +1239,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; + } } From ea2aa48d77202d5171f9cbb3742cf18ecc53e246 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 25 Nov 2019 09:19:26 +0100 Subject: [PATCH 09/19] MOBILE-2235 h5p: Include resizer scripts --- src/core/filter/providers/delegate.ts | 1 + src/core/h5p/assets/moodle/js/embed.js | 155 ++++++++++++++++++ .../h5p-player/core-h5p-player.html | 3 +- .../h5p/components/h5p-player/h5p-player.ts | 13 ++ src/core/h5p/providers/h5p.ts | 22 ++- src/providers/utils/dom.ts | 2 +- 6 files changed, 188 insertions(+), 8 deletions(-) create mode 100644 src/core/h5p/assets/moodle/js/embed.js diff --git a/src/core/filter/providers/delegate.ts b/src/core/filter/providers/delegate.ts index d712ffb31..642a454ca 100644 --- a/src/core/filter/providers/delegate.ts +++ b/src/core/filter/providers/delegate.ts @@ -188,6 +188,7 @@ export class CoreFilterDelegate extends CoreDelegate { } promise = promise.then(() => { + return Promise.resolve(this.executeFunctionOnEnabled(filter.filter, 'handleHtml', [container, filter, options, viewContainerRef, component, componentId, siteId])).catch((error) => { this.logger.error('Error handling HTML' + filter.filter, error); diff --git a/src/core/h5p/assets/moodle/js/embed.js b/src/core/h5p/assets/moodle/js/embed.js new file mode 100644 index 000000000..6135db2e5 --- /dev/null +++ b/src/core/h5p/assets/moodle/js/embed.js @@ -0,0 +1,155 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/* global H5PEmbedCommunicator:true */ +/** + * When embedded the communicator helps talk to the parent page. + * This is a copy of the H5P.communicator, which we need to communicate in this context + * + * @type {H5PEmbedCommunicator} + * @module core_h5p + * @copyright 2019 Joubel AS + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +H5PEmbedCommunicator = (function() { + /** + * @class + * @private + */ + function Communicator() { + var self = this; + + // Maps actions to functions. + var actionHandlers = {}; + + // Register message listener. + window.addEventListener('message', function receiveMessage(event) { + if (window.parent !== event.source || event.data.context !== 'h5p') { + return; // Only handle messages from parent and in the correct context. + } + + if (actionHandlers[event.data.action] !== undefined) { + actionHandlers[event.data.action](event.data); + } + }, false); + + /** + * Register action listener. + * + * @param {string} action What you are waiting for + * @param {function} handler What you want done + */ + self.on = function(action, handler) { + actionHandlers[action] = handler; + }; + + /** + * Send a message to the all mighty father. + * + * @param {string} action + * @param {Object} [data] payload + */ + self.send = function(action, data) { + if (data === undefined) { + data = {}; + } + data.context = 'h5p'; + data.action = action; + + // Parent origin can be anything. + window.parent.postMessage(data, '*'); + }; + } + + return (window.postMessage && window.addEventListener ? new Communicator() : undefined); +})(); + +document.onreadystatechange = function() { + // Wait for instances to be initialize. + if (document.readyState !== 'complete') { + return; + } + + // Check for H5P iFrame. + var iFrame = document.querySelector('.h5p-iframe'); + if (!iFrame || !iFrame.contentWindow) { + return; + } + var H5P = iFrame.contentWindow.H5P; + + // Check for H5P instances. + if (!H5P || !H5P.instances || !H5P.instances[0]) { + return; + } + + var resizeDelay; + var instance = H5P.instances[0]; + var parentIsFriendly = false; + + // Handle that the resizer is loaded after the iframe. + H5PEmbedCommunicator.on('ready', function() { + H5PEmbedCommunicator.send('hello'); + }); + + // Handle hello message from our parent window. + H5PEmbedCommunicator.on('hello', function() { + // Initial setup/handshake is done. + parentIsFriendly = true; + + // Hide scrollbars for correct size. + iFrame.contentDocument.body.style.overflow = 'hidden'; + + document.body.classList.add('h5p-resizing'); + + // Content need to be resized to fit the new iframe size. + H5P.trigger(instance, 'resize'); + }); + + // When resize has been prepared tell parent window to resize. + H5PEmbedCommunicator.on('resizePrepared', function() { + H5PEmbedCommunicator.send('resize', { + scrollHeight: iFrame.contentDocument.body.scrollHeight + }); + }); + + H5PEmbedCommunicator.on('resize', function() { + H5P.trigger(instance, 'resize'); + }); + + H5P.on(instance, 'resize', function() { + if (H5P.isFullscreen) { + return; // Skip iframe resize. + } + + // Use a delay to make sure iframe is resized to the correct size. + clearTimeout(resizeDelay); + resizeDelay = setTimeout(function() { + // Only resize if the iframe can be resized. + if (parentIsFriendly) { + H5PEmbedCommunicator.send('prepareResize', + { + scrollHeight: iFrame.contentDocument.body.scrollHeight, + clientHeight: iFrame.contentDocument.body.clientHeight + } + ); + } else { + H5PEmbedCommunicator.send('hello'); + } + }, 0); + }); + + // Trigger initial resize for instance. + H5P.trigger(instance, 'resize'); +}; diff --git a/src/core/h5p/components/h5p-player/core-h5p-player.html b/src/core/h5p/components/h5p-player/core-h5p-player.html index 2fb1e9989..3cfd58ccb 100644 --- a/src/core/h5p/components/h5p-player/core-h5p-player.html +++ b/src/core/h5p/components/h5p-player/core-h5p-player.html @@ -9,4 +9,5 @@ - + + diff --git a/src/core/h5p/components/h5p-player/h5p-player.ts b/src/core/h5p/components/h5p-player/h5p-player.ts index 0627e89d6..ccc5b5325 100644 --- a/src/core/h5p/components/h5p-player/h5p-player.ts +++ b/src/core/h5p/components/h5p-player/h5p-player.ts @@ -99,6 +99,8 @@ export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy { let promise; + this.addResizerScript(); + if (this.canDownload && (this.state == CoreConstants.DOWNLOADED || this.state == CoreConstants.OUTDATED)) { // Package is downloaded, use the local URL. promise = this.h5pProvider.getContentIndexFileUrl(this.urlParams.url).catch((error) => { @@ -154,6 +156,17 @@ export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy { }); } + /** + * Add the resizer script if it hasn't been added already. + */ + protected addResizerScript(): void { + const script = document.createElement('script'); + script.id = 'core-h5p-resizer-script'; + script.type = 'text/javascript'; + script.src = this.h5pProvider.getResizerScriptUrl(); + document.head.appendChild(script); + } + /** * Check if the package can be downloaded. */ diff --git a/src/core/h5p/providers/h5p.ts b/src/core/h5p/providers/h5p.ts index 57d674897..d92f398a7 100644 --- a/src/core/h5p/providers/h5p.ts +++ b/src/core/h5p/providers/h5p.ts @@ -522,20 +522,22 @@ export class CoreH5PProvider { let html = '' + content.title + '' + ''; - // @todo: Load the embed.js to allow communication with the parent window. - // $PAGE->requires->js(new moodle_url('/h5p/js/embed.js')); - // Include the required CSS. result.cssRequires.forEach((cssUrl) => { html += ''; }); // Add the settings. - html += ''; + html += ''; html += ''; // Include the required JS at the beginning of the body, like Moodle web does. + // Load the embed.js to allow communication with the parent window. + html += ''; + result.jsRequires.forEach((jsUrl) => { html += ''; }); @@ -1664,8 +1666,16 @@ export class CoreH5PProvider { * @return The HTML code with the resize script. */ protected getResizeCode(): string { - // @todo return ''; - return ''; + return ''; + } + + /** + * Get the URL to the resizer script. + * + * @return URL. + */ + getResizerScriptUrl(): string { + return this.textUtils.concatenatePaths(this.getCoreH5PPath(), 'js/h5p-resizer.js'); } /** diff --git a/src/providers/utils/dom.ts b/src/providers/utils/dom.ts index 37bb85907..18dec5369 100644 --- a/src/providers/utils/dom.ts +++ b/src/providers/utils/dom.ts @@ -385,7 +385,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; } From d5e12fb1367bfc88c32985d19fc8e57f7e89f7c4 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 25 Nov 2019 15:05:49 +0100 Subject: [PATCH 10/19] MOBILE-2235 h5p: Delete index files when updating libs --- .../h5p/components/h5p-player/h5p-player.ts | 19 +++- src/core/h5p/providers/h5p.ts | 87 ++++++++++++++++--- src/core/h5p/providers/pluginfile-handler.ts | 1 + src/providers/plugin-file-delegate.ts | 1 + 4 files changed, 92 insertions(+), 16 deletions(-) diff --git a/src/core/h5p/components/h5p-player/h5p-player.ts b/src/core/h5p/components/h5p-player/h5p-player.ts index ccc5b5325..b4058b4dd 100644 --- a/src/core/h5p/components/h5p-player/h5p-player.ts +++ b/src/core/h5p/components/h5p-player/h5p-player.ts @@ -15,6 +15,7 @@ import { Component, Input, ElementRef, OnInit, OnDestroy, OnChanges, SimpleChange } from '@angular/core'; import { CoreAppProvider } from '@providers/app'; import { CoreEventsProvider } from '@providers/events'; +import { CoreFileProvider } from '@providers/file'; import { CoreFilepoolProvider } from '@providers/filepool'; import { CoreLoggerProvider } from '@providers/logger'; import { CoreSitesProvider } from '@providers/sites'; @@ -62,7 +63,8 @@ export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy { protected eventsProvider: CoreEventsProvider, protected appProvider: CoreAppProvider, protected domUtils: CoreDomUtilsProvider, - protected pluginFileDelegate: CorePluginFileDelegate) { + protected pluginFileDelegate: CorePluginFileDelegate, + protected fileProvider: CoreFileProvider) { this.logger = loggerProvider.getInstance('CoreH5PPlayerComponent'); this.siteId = sitesProvider.getCurrentSiteId(); @@ -103,8 +105,19 @@ export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy { if (this.canDownload && (this.state == CoreConstants.DOWNLOADED || this.state == CoreConstants.OUTDATED)) { // Package is downloaded, use the local URL. - promise = this.h5pProvider.getContentIndexFileUrl(this.urlParams.url).catch((error) => { - // It seems there was something wrong when creating the index file. Delete the package? + promise = this.h5pProvider.getContentIndexFileUrl(this.urlParams.url, this.siteId).catch(() => { + + // Index file doesn't exist, probably deleted because a lib was updated. Try to create it again. + return this.filepoolProvider.getInternalUrlByUrl(this.siteId, this.urlParams.url).then((path) => { + return this.fileProvider.getFile(path); + }).then((file) => { + return this.h5pProvider.extractH5PFile(this.urlParams.url, file, this.siteId); + }).then(() => { + // File treated. Try to get the index file URL again. + return this.h5pProvider.getContentIndexFileUrl(this.urlParams.url, this.siteId); + }); + }).catch((error) => { + // Still failing. Delete the H5P package? this.logger.error('Error loading downloaded index:', error, this.src); }); } else { diff --git a/src/core/h5p/providers/h5p.ts b/src/core/h5p/providers/h5p.ts index d92f398a7..dde10e3d3 100644 --- a/src/core/h5p/providers/h5p.ts +++ b/src/core/h5p/providers/h5p.ts @@ -25,6 +25,7 @@ import { CoreMimetypeUtilsProvider } from '@providers/utils/mimetype'; import { CoreUrlUtilsProvider } from '@providers/utils/url'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreH5PUtilsProvider } from './utils'; +import { FileEntry } from '@ionic-native/file'; /** * Service to provide H5P functionalities. @@ -279,6 +280,11 @@ export class CoreH5PProvider { name: 'hash', type: 'TEXT', notNull: true + }, + { + name: 'foldername', + type: 'TEXT', + notNull: true } ] } @@ -566,7 +572,8 @@ export class CoreH5PProvider { db.deleteRecords(this.CONTENT_TABLE), db.deleteRecords(this.LIBRARIES_TABLE), db.deleteRecords(this.LIBRARY_DEPENDENCIES_TABLE), - db.deleteRecords(this.CONTENTS_LIBRARIES_TABLE) + db.deleteRecords(this.CONTENTS_LIBRARIES_TABLE), + db.deleteRecords(this.LIBRARIES_CACHEDASSETS_TABLE) ]); }); } @@ -575,15 +582,13 @@ export class CoreH5PProvider { * Delete cached assets from DB and filesystem. * * @param libraryId Library identifier. - * @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 deleteCachedAssets(libraryId: number, folderName: string, siteId?: string): Promise { + protected deleteCachedAssets(libraryId: number, siteId?: string): Promise { return this.sitesProvider.getSite(siteId).then((site) => { - const db = site.getDb(), - cachedAssetsFolder = this.getCachedAssetsFolderPath(folderName, site.getId()); + const db = site.getDb(); // Get all the hashes that use this library. return db.getRecords(this.LIBRARIES_CACHEDASSETS_TABLE, {libraryid: libraryId}).then((entries) => { @@ -594,6 +599,8 @@ export class CoreH5PProvider { 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); @@ -603,11 +610,6 @@ export class CoreH5PProvider { }); }); - // Also, delete the index.html file. - promises.push(this.fileProvider.removeFile(this.getContentIndexPath(folderName, site.getId())).catch(() => { - // Ignore errors. - })); - return Promise.all(promises).then(() => { return db.deleteRecordsList(this.LIBRARIES_CACHEDASSETS_TABLE, 'hash', hashes); }); @@ -636,6 +638,45 @@ export class CoreH5PProvider { return Promise.all(promises); } + /** + * 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. * @@ -1796,6 +1837,27 @@ export class CoreH5PProvider { }); } + /** + * 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}. * @@ -2198,9 +2260,8 @@ export class CoreH5PProvider { }); }); }).then(() => { - // Remove cached assets that use this library. - if (this.aggregateAssets && typeof libraryData.libraryId != 'undefined') { - return this.deleteCachedAssets(libraryData.libraryId, folderName, siteId); + if (typeof libraryData.libraryId != 'undefined') { + return this.libraryInstalled(libraryData.libraryId, siteId); } }); })); diff --git a/src/core/h5p/providers/pluginfile-handler.ts b/src/core/h5p/providers/pluginfile-handler.ts index a7787a3f0..fed35e88e 100644 --- a/src/core/h5p/providers/pluginfile-handler.ts +++ b/src/core/h5p/providers/pluginfile-handler.ts @@ -21,6 +21,7 @@ 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. diff --git a/src/providers/plugin-file-delegate.ts b/src/providers/plugin-file-delegate.ts index b1c33e1f0..6ff96e5c3 100644 --- a/src/providers/plugin-file-delegate.ts +++ b/src/providers/plugin-file-delegate.ts @@ -15,6 +15,7 @@ import { Injectable } from '@angular/core'; import { CoreLoggerProvider } from './logger'; import { CoreWSExternalFile } from '@providers/ws'; +import { FileEntry } from '@ionic-native/file'; /** * Interface that all plugin file handlers must implement. From ad716ce07ef6845831bd3d85dc71edad3020b668 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 25 Nov 2019 16:08:58 +0100 Subject: [PATCH 11/19] MOBILE-2235 h5p: Delete content data if original file is deleted --- src/core/h5p/providers/h5p.ts | 36 ++++++++++++++++++++ src/core/h5p/providers/pluginfile-handler.ts | 13 +++++++ src/providers/filepool.ts | 21 +++++++++++- src/providers/plugin-file-delegate.ts | 30 +++++++++++++++- 4 files changed, 98 insertions(+), 2 deletions(-) diff --git a/src/core/h5p/providers/h5p.ts b/src/core/h5p/providers/h5p.ts index dde10e3d3..6dc9f3814 100644 --- a/src/core/h5p/providers/h5p.ts +++ b/src/core/h5p/providers/h5p.ts @@ -617,6 +617,27 @@ export class CoreH5PProvider { }); } + /** + * 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. * @@ -638,6 +659,17 @@ export class CoreH5PProvider { 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. * @@ -1160,9 +1192,13 @@ export class CoreH5PProvider { 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}); }); }); } diff --git a/src/core/h5p/providers/pluginfile-handler.ts b/src/core/h5p/providers/pluginfile-handler.ts index fed35e88e..71b82053d 100644 --- a/src/core/h5p/providers/pluginfile-handler.ts +++ b/src/core/h5p/providers/pluginfile-handler.ts @@ -48,6 +48,19 @@ export class CoreH5PPluginFileHandler implements CorePluginFileHandler { return this.h5pProvider.getTrustedH5PFile(file.fileurl, {}, false, siteId); } + /** + * 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); + } + /** * Given an HTML element, get the URLs of the files that should be downloaded and weren't treated by * CoreDomUtilsProvider.extractDownloadableFilesFromHtml. diff --git a/src/providers/filepool.ts b/src/providers/filepool.ts index ddea2ddbb..5ad03bead 100644 --- a/src/providers/filepool.ts +++ b/src/providers/filepool.ts @@ -2646,7 +2646,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. @@ -2668,6 +2683,10 @@ export class CoreFilepoolProvider { return Promise.all(promises).then(() => { this.notifyFileDeleted(siteId, fileId); + + return this.pluginFileDelegate.fileDeleted(fileUrl, path, siteId).catch((error) => { + // Ignore errors. + }); }); }); }); diff --git a/src/providers/plugin-file-delegate.ts b/src/providers/plugin-file-delegate.ts index 6ff96e5c3..01aea603e 100644 --- a/src/providers/plugin-file-delegate.ts +++ b/src/providers/plugin-file-delegate.ts @@ -57,6 +57,16 @@ export interface CorePluginFileHandler { */ canDownloadFile?(file: CoreWSExternalFile, siteId?: string): Promise; + /** + * 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; + /** * Given an HTML element, get the URLs of the files that should be downloaded and weren't treated by * CoreDomUtilsProvider.extractDownloadableFilesFromHtml. @@ -139,6 +149,24 @@ export class CorePluginFileDelegate { return Promise.resolve(file); } + /** + * 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 { + const handler = this.getHandlerForFile({fileurl: fileUrl}); + + if (handler && handler.fileDeleted) { + return handler.fileDeleted(fileUrl, path, siteId); + } + + return Promise.resolve(); + } + /** * Get the handler for a certain pluginfile url. * @@ -310,7 +338,7 @@ export class CorePluginFileDelegate { treatDownloadedFile(fileUrl: string, file: FileEntry, siteId?: string): Promise { const handler = this.getHandlerForFile({fileurl: fileUrl}); - if (handler && handler.getFileSize) { + if (handler && handler.treatDownloadedFile) { return handler.treatDownloadedFile(fileUrl, file, siteId); } From 5903975e8cdb6eb63dbaecf48c1cebfeeff7fc0e Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 26 Nov 2019 08:49:59 +0100 Subject: [PATCH 12/19] MOBILE-2235 h5p: Handle display options --- .../h5p/assets/moodle/js/displayoptions.js | 35 ++++++ .../h5p/components/h5p-player/h5p-player.ts | 4 +- src/core/h5p/providers/h5p.ts | 107 +++++++++++++----- src/providers/utils/url.ts | 10 +- 4 files changed, 124 insertions(+), 32 deletions(-) create mode 100644 src/core/h5p/assets/moodle/js/displayoptions.js diff --git a/src/core/h5p/assets/moodle/js/displayoptions.js b/src/core/h5p/assets/moodle/js/displayoptions.js new file mode 100644 index 000000000..59088886d --- /dev/null +++ b/src/core/h5p/assets/moodle/js/displayoptions.js @@ -0,0 +1,35 @@ +// (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. + +/** + * Handle display options included in the URL and put them in the H5PIntegration object if it exists. + */ + +if (window.H5PIntegration && window.H5PIntegration.contents && location.search) { + var contentData = window.H5PIntegration.contents[Object.keys(window.H5PIntegration.contents)[0]]; + + if (contentData) { + contentData.displayOptions = contentData.displayOptions || {}; + + var search = location.search.replace(/^\?/, ''), + split = search.split('&'); + + split.forEach(function(param) { + var nameAndValue = param.split('='); + if (nameAndValue.length == 2) { + contentData.displayOptions[nameAndValue[0]] = nameAndValue[1] === '1' || nameAndValue[1] === 'true'; + } + }); + } +} diff --git a/src/core/h5p/components/h5p-player/h5p-player.ts b/src/core/h5p/components/h5p-player/h5p-player.ts index b4058b4dd..1a1cc7fb1 100644 --- a/src/core/h5p/components/h5p-player/h5p-player.ts +++ b/src/core/h5p/components/h5p-player/h5p-player.ts @@ -105,7 +105,7 @@ export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy { if (this.canDownload && (this.state == CoreConstants.DOWNLOADED || this.state == CoreConstants.OUTDATED)) { // Package is downloaded, use the local URL. - promise = this.h5pProvider.getContentIndexFileUrl(this.urlParams.url, this.siteId).catch(() => { + promise = this.h5pProvider.getContentIndexFileUrl(this.urlParams.url, this.urlParams, this.siteId).catch(() => { // Index file doesn't exist, probably deleted because a lib was updated. Try to create it again. return this.filepoolProvider.getInternalUrlByUrl(this.siteId, this.urlParams.url).then((path) => { @@ -114,7 +114,7 @@ export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy { return this.h5pProvider.extractH5PFile(this.urlParams.url, file, this.siteId); }).then(() => { // File treated. Try to get the index file URL again. - return this.h5pProvider.getContentIndexFileUrl(this.urlParams.url, this.siteId); + return this.h5pProvider.getContentIndexFileUrl(this.urlParams.url, this.urlParams, this.siteId); }); }).catch((error) => { // Still failing. Delete the H5P package? diff --git a/src/core/h5p/providers/h5p.ts b/src/core/h5p/providers/h5p.ts index 6dc9f3814..e6c9e76e9 100644 --- a/src/core/h5p/providers/h5p.ts +++ b/src/core/h5p/providers/h5p.ts @@ -100,7 +100,7 @@ export class CoreH5PProvider { notNull: true }, { - name: 'displayoptions', + name: 'displayoptions', // Not used right now, but we keep the field to be consistent with Moodle web. type: 'INTEGER' }, { @@ -494,10 +494,7 @@ export class CoreH5PProvider { return this.sitesProvider.getSite(siteId).then((site) => { - const disable = typeof content.disable != 'undefined' && content.disable != null ? - content.disable : CoreH5PProvider.DISABLE_NONE, - displayOptions = this.getDisplayOptionsForView(disable, id), - contentId = this.getContentId(id), + const contentId = this.getContentId(id), basePath = this.fileProvider.getBasePathInstant(), contentUrl = this.textUtils.concatenatePaths(basePath, this.getContentFolderPath(content.folderName, site.getId())); @@ -506,10 +503,10 @@ export class CoreH5PProvider { library: this.libraryToString(content.library), fullScreen: content.library.fullscreen, exportUrl: '', // We'll never display the download button, so we don't need the exportUrl. - embedCode: this.getEmbedCode(site.getURL(), h5pUrl, displayOptions[CoreH5PProvider.DISPLAY_OPTION_EMBED]), + embedCode: this.getEmbedCode(site.getURL(), h5pUrl, true), resizeCode: this.getResizeCode(), title: content.slug, - displayOptions: displayOptions, + displayOptions: {}, url: this.getEmbedUrl(site.getURL(), h5pUrl), contentUrl: contentUrl, metadata: content.metadata, @@ -537,6 +534,10 @@ export class CoreH5PProvider { html += ''; + // Add our own script to handle the display options. + html += ''; + html += ''; // Include the required JS at the beginning of the body, like Moodle web does. @@ -1218,17 +1219,26 @@ export class CoreH5PProvider { * 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, siteId?: string): Promise { + 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.validateDisplayOptions(this.getDisplayOptionsFromUrlParams(urlParams), data.id); + + return this.urlUtils.addParamsToUrl(url, options, undefined, true); + }); }); + } /** @@ -1486,25 +1496,31 @@ export class CoreH5PProvider { * @return Display options as object. */ getDisplayOptionsForView(disable: number, id: number): CoreH5PDisplayOptions { - const displayOptions = this.getDisplayOptionsAsObject(disable); + return this.validateDisplayOptions(this.getDisplayOptionsAsObject(disable), id); + } - if (this.getOption(CoreH5PProvider.DISPLAY_OPTION_FRAME, true) == false) { - displayOptions[CoreH5PProvider.DISPLAY_OPTION_FRAME] = false; - } else { - displayOptions[CoreH5PProvider.DISPLAY_OPTION_DOWNLOAD] = this.setDisplayOptionOverrides( - CoreH5PProvider.DISPLAY_OPTION_DOWNLOAD, CoreH5PPermission.DOWNLOAD_H5P, id, - displayOptions[CoreH5PProvider.DISPLAY_OPTION_DOWNLOAD]); + /** + * 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 = {}; - 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; - } + if (!params) { + return displayOptions; } - displayOptions[CoreH5PProvider.DISPLAY_OPTION_COPY] = this.hasPermission(CoreH5PPermission.COPY_H5P, id); + 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; } @@ -1734,7 +1750,7 @@ export class CoreH5PProvider { */ getOption(name: string, defaultValue: any = false): any { // For now, all them are disabled by default, so only will be rendered when defined in the displayoptions DB field. - return 2; // CONTROLLED_BY_AUTHOR_DEFAULT_OFF. + return CoreH5PDisplayOptionBehaviour.CONTROLLED_BY_AUTHOR_DEFAULT_OFF; // CONTROLLED_BY_AUTHOR_DEFAULT_OFF. } /** @@ -1845,7 +1861,8 @@ export class CoreH5PProvider { * @return Whether the user has permission to execute an action. */ hasPermission(permission: number, id: number): boolean { - return true; + // H5P capabilities have not been introduced. + return null; } /** @@ -1967,7 +1984,7 @@ export class CoreH5PProvider { params: contentData.jsoncontent, // The embedtype will be always set to 'iframe' to prevent conflicts with JS and CSS. embedType: 'iframe', - disable: contentData.displayoptions, + disable: null, folderName: contentData.foldername, title: libData.title, slug: this.h5pUtils.slugify(libData.title) + '-' + contentData.id, @@ -2193,7 +2210,7 @@ export class CoreH5PProvider { const data: any = { jsoncontent: content.params, - displayoptions: content.disable, + displayoptions: null, mainlibraryid: content.library.libraryId, timemodified: Date.now(), filtered: null, @@ -2563,6 +2580,40 @@ export class CoreH5PProvider { return db.updateRecords(this.CONTENT_TABLE, data, {id: id}); }); } + + /** + * Validate display options, updating them if needed. + * + * @param displayOptions The display options to validate. + * @param id Package ID. + */ + validateDisplayOptions(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) == false) { + 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; + } } /** @@ -2633,7 +2684,7 @@ 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. - displayoptions: number; // H5P Button display options. + displayoptions: number; // H5P Button display options. Not used right now. 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. 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') { From 3da7c99fd83be54ed1ca5b7be271963653368e5c Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 26 Nov 2019 09:42:28 +0100 Subject: [PATCH 13/19] MOBILE-2235 h5p: Download in background after play --- .../h5p/components/h5p-player/h5p-player.ts | 29 +++++++++++++++++++ src/providers/filepool.ts | 12 +++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/core/h5p/components/h5p-player/h5p-player.ts b/src/core/h5p/components/h5p-player/h5p-player.ts index 1a1cc7fb1..11a5901fc 100644 --- a/src/core/h5p/components/h5p-player/h5p-player.ts +++ b/src/core/h5p/components/h5p-player/h5p-player.ts @@ -138,6 +138,13 @@ export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy { }).finally(() => { this.loading = false; this.showPackage = true; + + if (this.canDownload && (this.state == CoreConstants.OUTDATED || this.state == CoreConstants.NOT_DOWNLOADED)) { + // Download the package in background if the size is low. + this.downloadInBg().catch((error) => { + this.logger.error('Error downloading H5P in background', error); + }); + } }); } @@ -169,6 +176,28 @@ export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy { }); } + /** + * Download the H5P in background if the size is low. + * + * @return Promise resolved when done. + */ + protected downloadInBg(): Promise { + if (this.urlParams && this.src && this.siteCanDownload && this.h5pProvider.canGetTrustedH5PFileInSite() && + this.appProvider.isOnline()) { + + // Get the file size. + return this.pluginFileDelegate.getFileSize({fileurl: this.urlParams.url}, this.siteId).then((size) => { + + if (this.filepoolProvider.shouldDownload(size)) { + // Download the file in background. + this.filepoolProvider.addToQueueByUrl(this.siteId, this.urlParams.url, this.component, this.componentId); + } + }); + } + + return Promise.resolve(); + } + /** * Add the resizer script if it hasn't been added already. */ diff --git a/src/providers/filepool.ts b/src/providers/filepool.ts index 5ad03bead..dca0d1e89 100644 --- a/src/providers/filepool.ts +++ b/src/providers/filepool.ts @@ -772,7 +772,7 @@ export class CoreFilepoolProvider { 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)) { + } else if (this.shouldDownload(size)) { return this.addToQueueByUrl(siteId, fileUrl, component, componentId, timemodified, undefined, undefined, 0, options, revision, true); } @@ -2779,6 +2779,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. * From 56faa66adcff8d42976ec40b3cea4c417ac9c4ce Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 28 Nov 2019 12:26:20 +0100 Subject: [PATCH 14/19] MOBILE-2235 h5p: Implement and use content validator --- scripts/langindex.json | 88 ++ src/assets/lang/en.json | 22 + src/core/h5p/classes/content-validator.ts | 1324 +++++++++++++++++ .../h5p/components/h5p-player/h5p-player.ts | 6 +- src/core/h5p/lang/en.json | 24 +- src/core/h5p/providers/h5p.ts | 291 ++-- src/core/h5p/providers/utils.ts | 21 + src/providers/utils/utils.ts | 8 +- 8 files changed, 1642 insertions(+), 142 deletions(-) create mode 100644 src/core/h5p/classes/content-validator.ts diff --git a/scripts/langindex.json b/scripts/langindex.json index f6be29269..70f4eb012 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1552,6 +1552,94 @@ "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.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/assets/lang/en.json b/src/assets/lang/en.json index 0b7468465..5a3093332 100644 --- a/src/assets/lang/en.json +++ b/src/assets/lang/en.json @@ -1550,7 +1550,12 @@ "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)", @@ -1559,7 +1564,11 @@ "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", @@ -1570,18 +1579,24 @@ "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", @@ -1591,14 +1606,18 @@ "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:", @@ -1617,6 +1636,9 @@ "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/core/h5p/classes/content-validator.ts b/src/core/h5p/classes/content-validator.ts new file mode 100644 index 000000000..18ae59dc3 --- /dev/null +++ b/src/core/h5p/classes/content-validator.ts @@ -0,0 +1,1324 @@ +// (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 { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreH5PProvider, CoreH5PLibraryData, CoreH5PLibraryAddonData, CoreH5PContentDepsTreeDependency } from '../providers/h5p'; +import { CoreH5PUtilsProvider } from '../providers/utils'; +import { TranslateService } from '@ngx-translate/core'; + +/** + * Equivalent to Moodle's H5PContentValidator, but without some of the validations. + * It's also used to build the dependency list. + */ +export class CoreH5PContentValidator { + protected static ALLOWED_STYLEABLE_TAGS = ['span', 'p', 'div', 'h1', 'h2', 'h3', 'td']; + + protected typeMap = { + text: 'validateText', + number: 'validateNumber', + boolean: 'validateBoolean', + list: 'validateList', + group: 'validateGroup', + file: 'validateFile', + image: 'validateImage', + video: 'validateVideo', + audio: 'validateAudio', + select: 'validateSelect', + library: 'validateLibrary', + }; + + protected nextWeight = 1; + protected libraries: {[libString: string]: CoreH5PLibraryData} = {}; + protected dependencies: {[key: string]: CoreH5PContentDepsTreeDependency} = {}; + protected relativePathRegExp = /^((\.\.\/){1,2})(.*content\/)?(\d+|editor)\/(.+)$/; + protected allowedHtml: {[tag: string]: any} = {}; + protected allowedStyles: RegExp[]; + protected metadataSemantics: any[]; + protected copyrightSemantics: any; + + constructor(protected h5pProvider: CoreH5PProvider, + protected h5pUtils: CoreH5PUtilsProvider, + protected textUtils: CoreTextUtilsProvider, + protected utils: CoreUtilsProvider, + protected translate: TranslateService, + protected siteId: string) { } + + /** + * Add Addon library. + * + * @param library The addon library to add. + * @return Promise resolved when done. + */ + addon(library: CoreH5PLibraryAddonData): Promise { + const depKey = 'preloaded-' + library.machineName; + + this.dependencies[depKey] = { + library: library, + type: 'preloaded' + }; + + return this.h5pProvider.findLibraryDependencies(this.dependencies, library, this.nextWeight).then((weight) => { + this.nextWeight = weight; + this.dependencies[depKey].weight = this.nextWeight++; + }); + } + + /** + * Get the flat dependency tree. + * + * @return array + */ + getDependencies(): {[key: string]: CoreH5PContentDepsTreeDependency} { + return this.dependencies; + } + + /** + * Validate metadata + * + * @param metadata Metadata. + * @return Promise resolved with metadata validated & filtered. + */ + validateMetadata(metadata: any): Promise { + const semantics = this.getMetadataSemantics(); + const group = this.utils.clone(metadata || {}); + + // Stop complaining about "invalid selected option in select" for old content without license chosen. + if (typeof group.license == 'undefined') { + group.license = 'U'; + } + + return this.validateGroup(group, {type: 'group', fields: semantics}, false); + } + + /** + * Validate given text value against text semantics. + * + * @param text Text to validate. + * @param semantics Semantics. + * @return Validated text. + */ + validateText(text: string, semantics: any): string { + if (typeof text != 'string') { + text = ''; + } + + if (semantics.tags) { + // Not testing for empty array allows us to use the 4 defaults without specifying them in semantics. + let tags = ['div', 'span', 'p', 'br'].concat(semantics.tags); + + // Add related tags for table etc. + if (tags.indexOf('table') != -1) { + tags = tags.concat(['tr', 'td', 'th', 'colgroup', 'thead', 'tbody', 'tfoot']); + } + if (tags.indexOf('b') != -1) { + tags.push('strong'); + } + if (tags.indexOf('i') != -1) { + tags.push('em'); + } + if (tags.indexOf('ul') != -1 || tags.indexOf('ol') != -1) { + tags.push('li'); + } + if (tags.indexOf('del') != -1 || tags.indexOf('strike') != -1) { + tags.push('s'); + } + + tags = this.utils.uniqueArray(tags); + + // Determine allowed style tags + const stylePatterns: RegExp[] = []; + // All styles must be start to end patterns (^...$) + if (semantics.font) { + if (semantics.font.size) { + stylePatterns.push(/^font-size: *[0-9.]+(em|px|%) *;?$/i); + } + if (semantics.font.family) { + stylePatterns.push(/^font-family: *[-a-z0-9," ]+;?$/i); + } + if (semantics.font.color) { + stylePatterns.push(/^color: *(#[a-f0-9]{3}[a-f0-9]{3}?|rgba?\([0-9, ]+\)) *;?$/i); + } + if (semantics.font.background) { + stylePatterns.push(/^background-color: *(#[a-f0-9]{3}[a-f0-9]{3}?|rgba?\([0-9, ]+\)) *;?$/i); + } + if (semantics.font.spacing) { + stylePatterns.push(/^letter-spacing: *[0-9.]+(em|px|%) *;?$/i); + } + if (semantics.font.height) { + stylePatterns.push(/^line-height: *[0-9.]+(em|px|%|) *;?$/i); + } + } + + // Alignment is allowed for all wysiwyg texts + stylePatterns.push(/^text-align: *(center|left|right);?$/i); + + // Strip invalid HTML tags. + text = this.filterXss(text, tags, stylePatterns); + } else { + // Filter text to plain text. + text = this.textUtils.escapeHTML(text); + } + + // Check if string is within allowed length. + if (typeof semantics.maxLength != 'undefined') { + text = text.substr(0, semantics.maxLength); + } + + return text; + } + + /** + * Validates content files + * + * @param contentPath The path containing content files to validate. + * @param isLibrary Whether it's a library. + * @return True if all files are valid. + */ + validateContentFiles(contentPath: string, isLibrary: boolean = false): boolean { + // Nothing to do, already checked by Moodle. + return true; + } + + /** + * Validate given value against number semantics. + * + * @param num Number to validate. + * @param semantics Semantics. + * @return Validated number. + */ + validateNumber(num: any, semantics: any): number { + // Validate that num is indeed a number. + num = Number(num); + if (isNaN(num)) { + num = 0; + } + // Check if number is within valid bounds. Move within bounds if not. + if (typeof semantics.min != 'undefined' && num < semantics.min) { + num = semantics.min; + } + if (typeof semantics.max != 'undefined' && num > semantics.max) { + num = semantics.max; + } + // Check if number is within allowed bounds even if step value is set. + if (typeof semantics.step != 'undefined') { + const testNumber = num - (typeof semantics.min != 'undefined' ? semantics.min : 0), + rest = testNumber % semantics.step; + if (rest !== 0) { + num -= rest; + } + } + // Check if number has proper number of decimals. + if (typeof semantics.decimals != 'undefined') { + num = num.toFixed(semantics.decimals); + } + + return num; + } + + /** + * Validate given value against boolean semantics. + * + * @param bool Boolean to check. + * @return Validated bool. + */ + validateBoolean(bool: boolean): boolean { + return !!bool; + } + + /** + * Validate select values. + * + * @param select Values to validate. + * @param semantics Semantics. + * @return Validated select. + */ + validateSelect(select: any, semantics: any): any { + const optional = semantics.optional, + options = {}; + let strict = false; + + if (semantics.options && semantics.options.length) { + // We have a strict set of options to choose from. + strict = true; + + semantics.options.forEach((option) => { + // Support optgroup - just flatten options into one. + if (option.type == 'optgroup') { + option.options.forEach((subOption) => { + options[subOption.value] = true; + }); + } else if (option.value) { + options[option.value] = true; + } + }); + } + + if (semantics.multiple) { + // Multi-choice generates array of values. Test each one against valid options, if we are strict. + for (const key in select) { + const value = select[key]; + + if (strict && !optional && !options[value]) { + delete select[key]; + } else { + select[key] = this.textUtils.escapeHTML(value); + } + } + } else { + // Single mode. If we get an array in here, we chop off the first element and use that instead. + if (Array.isArray(select)) { + select = select[0]; + } + + if (strict && !optional && !options[select]) { + select = semantics.options[0].value; + } + select = this.textUtils.escapeHTML(select); + } + + return select; + } + + /** + * Validate given list value against list semantics. + * Will recurse into validating each item in the list according to the type. + * + * @param list List to validate. + * @param semantics Semantics. + * @return Validated list. + */ + validateList(list: any, semantics: any): Promise { + const field = semantics.field, + fn = this[this.typeMap[field.type]].bind(this); + let promise = Promise.resolve(), // Use a chain of promises so the order is kept. + keys = Object.keys(list); + + // Check that list is not longer than allowed length. + if (typeof semantics.max != 'undefined') { + keys = keys.slice(0, semantics.max); + } + + // Validate each element in list. + keys.forEach((key) => { + if (isNaN(parseInt(key, 10))) { + // It's an object and the key isn't an integer. Delete it. + delete list[key]; + } else { + promise = promise.then(() => { + return Promise.resolve(fn(list[key], field)).then((val) => { + if (val === null) { + list.splice(key, 1); + } else { + list[key] = val; + } + }); + }); + } + }); + + return promise.then(() => { + + if (!Array.isArray(list)) { + list = this.utils.objectToArray(list); + } + + if (!list.length) { + return null; + } + + return list; + }); + } + + /** + * Validate a file like object, such as video, image, audio and file. + * + * @param file File to validate. + * @param semantics Semantics. + * @param typeValidKeys List of valid keys. + * @return Promise resolved with the validated file. + */ + protected validateFilelike(file: any, semantics: any, typeValidKeys: string[] = []): Promise { + // Do not allow to use files from other content folders. + const matches = file.path.match(this.relativePathRegExp); + if (matches && matches.length) { + file.path = matches[5]; + } + + // Remove temporary files suffix. + if (file.path.substr(-4, 4) === '#tmp') { + file.path = file.path.substr(0, file.path.length - 4); + } + + // Make sure path and mime does not have any special chars + file.path = this.textUtils.escapeHTML(file.path); + if (file.mime) { + file.mime = this.textUtils.escapeHTML(file.mime); + } + + // Remove attributes that should not exist, they may contain JSON escape code. + let validKeys = ['path', 'mime', 'copyright'].concat(typeValidKeys); + if (semantics.extraAttributes) { + validKeys = validKeys.concat(semantics.extraAttributes); + } + validKeys = this.utils.uniqueArray(validKeys); + + this.filterParams(file, validKeys); + + if (typeof file.width != 'undefined') { + file.width = parseInt(file.width, 10); + } + + if (typeof file.height != 'undefined') { + file.height = parseInt(file.height, 10); + } + + if (file.codecs) { + file.codecs = this.textUtils.escapeHTML(file.codecs); + } + + if (typeof file.bitrate != 'undefined') { + file.bitrate = parseInt(file.bitrate, 10); + } + + if (typeof file.quality != 'undefined') { + if (file.quality === null || typeof file.quality.level == 'undefined' || typeof file.quality.label == 'undefined') { + delete file.quality; + } else { + this.filterParams(file.quality, ['level', 'label']); + file.quality.level = parseInt(file.quality.level); + file.quality.label = this.textUtils.escapeHTML(file.quality.label); + } + } + + if (typeof file.copyright != 'undefined') { + return this.validateGroup(file.copyright, this.getCopyrightSemantics()).then(() => { + return file; + }); + } + + return Promise.resolve(file); + } + + /** + * Validate given file data. + * + * @param file File. + * @param semantics Semantics. + * @return Promise resolved with the validated file. + */ + validateFile(file: any, semantics: any): Promise { + return this.validateFilelike(file, semantics); + } + + /** + * Validate given image data. + * + * @param image Image. + * @param semantics Semantics. + * @return Promise resolved with the validated file. + */ + validateImage(image: any, semantics: any): Promise { + return this.validateFilelike(image, semantics, ['width', 'height', 'originalImage']); + } + + /** + * Validate given video data. + * + * @param video Video. + * @param semantics Semantics. + * @return Promise resolved with the validated file. + */ + validateVideo(video: any, semantics: any): Promise { + let promise = Promise.resolve(); // Use a chain of promises so the order is kept. + + for (const key in video) { + promise = promise.then(() => { + return this.validateFilelike(video[key], semantics, ['width', 'height', 'codecs', 'quality', 'bitrate']); + }); + } + + return promise.then(() => { + return video; + }); + } + + /** + * Validate given audio data. + * + * @param audio Audio. + * @param semantics Semantics. + * @return Promise resolved with the validated file. + */ + validateAudio(audio: any, semantics: any): Promise { + let promise = Promise.resolve(); // Use a chain of promises so the order is kept. + + for (const key in audio) { + promise = promise.then(() => { + return this.validateFilelike(audio[key], semantics); + }); + } + + return promise.then(() => { + return audio; + }); + } + + /** + * Validate given group value against group semantics. + * Will recurse into validating each group member. + * + * @param group Group. + * @param semantics Semantics. + * @param flatten Whether to flatten. + */ + validateGroup(group: any, semantics: any, flatten: boolean = true): Promise { + // Groups with just one field are compressed in the editor to only output he child content. + + const isSubContent = semantics.isSubContent === true; + + if (semantics.fields.length == 1 && flatten && !isSubContent) { + const field = semantics.fields[0], + fn = this[this.typeMap[field.type]].bind(this); + + return Promise.resolve(fn(group, field)); + } else { + let promise = Promise.resolve(); // Use a chain of promises so the order is kept. + + for (const key in group) { + // If subContentId is set, keep value + if (isSubContent && key == 'subContentId') { + continue; + } + + // Find semantics for name=$key + let found = false, + fn = null, + field = null; + + for (let i = 0; i < semantics.fields.length; i++) { + field = semantics.fields[i]; + + if (field.name == key) { + if (semantics.optional) { + field.optional = true; + } + fn = this[this.typeMap[field.type]].bind(this); + found = true; + break; + } + } + + if (found && fn) { + promise = promise.then(() => { + return Promise.resolve(fn(group[key], field)).then((val) => { + group[key] = val; + if (val === null) { + delete group[key]; + } + }); + }); + } else { + // Something exists in content that does not have a corresponding semantics field. Remove it. + delete group.key; + } + } + + return promise.then(() => { + return group; + }); + } + } + + /** + * Validate given library value against library semantics. + * Check if provided library is within allowed options. + * Will recurse into validating the library's semantics too. + * + * @param value Value. + * @param semantics Semantics. + * @return Promise resolved when done. + */ + validateLibrary(value: any, semantics: any): Promise { + if (!value.library) { + return Promise.resolve(); + } + + let promise; + + if (!this.libraries[value.library]) { + const libSpec = this.h5pUtils.libraryFromString(value.library); + + promise = this.h5pProvider.loadLibrary(libSpec.machineName, libSpec.majorVersion, libSpec.minorVersion, this.siteId) + .then((library) => { + this.libraries[value.library] = library; + + return library; + }); + } else { + promise = Promise.resolve(this.libraries[value.library]); + } + + return promise.then((library) => { + // Validate parameters. + return this.validateGroup(value.params, {type: 'group', fields: library.semantics}, false).then((validated) => { + + value.params = validated; + + // Validate subcontent's metadata + if (value.metadata) { + return this.validateMetadata(value.metadata).then((res) => { + value.metadata = res; + }); + } + }).then(() => { + + let validKeys = ['library', 'params', 'subContentId', 'metadata']; + if (semantics.extraAttributes) { + validKeys = this.utils.uniqueArray(validKeys.concat(semantics.extraAttributes)); + } + + this.filterParams(value, validKeys); + + if (value.subContentId && + !value.subContentId.match(/^\{?[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\}?$/)) { + delete value.subContentId; + } + + // Find all dependencies for this library. + const depKey = 'preloaded-' + library.machineName; + if (!this.dependencies[depKey]) { + this.dependencies[depKey] = { + library: library, + type: 'preloaded' + }; + + return this.h5pProvider.findLibraryDependencies(this.dependencies, library, this.nextWeight).then((weight) => { + this.nextWeight = weight; + this.dependencies[depKey].weight = this.nextWeight++; + + return value; + }); + } else { + return value; + } + }); + }); + } + + /** + * Check params for a whitelist of allowed properties. + * + * @param params Object to filter. + * @param whitelist List of keys to keep. + */ + filterParams(params: any, whitelist: string[]): void { + for (const key in params) { + if (whitelist.indexOf(key) == -1) { + delete params[key]; + } + } + } + + /** + * Filters HTML to prevent cross-site-scripting (XSS) vulnerabilities. + * Based on kses by Ulf Harnhammar, see http://sourceforge.net/projects/kses. + * + * @param str The string with raw HTML in it. + * @param allowedTags An array of allowed tags. + * @param allowedStyles Allowed styles. + * @return An XSS safe version of the string. + */ + protected filterXss(str: string, allowedTags?: string[], allowedStyles?: RegExp[]): string { + if (!str || typeof str != 'string') { + return str; + } + + allowedTags = allowedTags || ['a', 'em', 'strong', 'cite', 'blockquote', 'code', 'ul', 'ol', 'li', 'dl', 'dt', 'dd']; + + this.allowedStyles = allowedStyles; + + // Store the text format. + this.filterXssSplit(allowedTags, true); + + // Remove Netscape 4 JS entities. + str = str.replace(/&\s*\{[^}]*(\}\s*;?|$)/g, ''); + + // Defuse all HTML entities. + str = str.replace(/&/g, '&'); + + // Change back only well-formed entities in our whitelist: + // Decimal numeric entities. + str = str.replace(/&#([0-9]+;)/g, '&#$1'); + // Hexadecimal numeric entities. + str = str.replace(/&#[Xx]0*((?:[0-9A-Fa-f]{2})+;)/g, '&#x$1'); + // Named entities. + str = str.replace(/&([A-Za-z][A-Za-z0-9]*;)/g, '&$1'); + + const matches = str.match(/(<(?=[^a-zA-Z!\/])||<[^>]*(>|$)|>)/g); + if (matches && matches.length) { + matches.forEach((match) => { + str = str.replace(match, this.filterXssSplit([match])); + }); + } + + return str; + } + + /** + * Processes an HTML tag. + * + * @param m An array with various meaning depending on the value of store. + * If store is TRUE then the array contains the allowed tags. + * If store is FALSE then the array has one element, the HTML tag to process. + * @param store Whether to store m. + * @return string If the element isn't allowed, an empty string. Otherwise, the cleaned up version of the HTML element. + */ + protected filterXssSplit(m: string[], store: boolean = false): string { + + if (store) { + this.allowedHtml = this.utils.arrayToObject(m); + + return ''; + } + + const str = m[0]; + + if (str.substr(0, 1) != '<') { + // We matched a lone ">" character. + return '>'; + } else if (str.length == 1) { + // We matched a lone "<" character. + return '<'; + } + + const matches = str.match(/^<\s*(\/\s*)?([a-zA-Z0-9\-]+)([^>]*)>?|()$/); + if (!matches) { + // Seriously malformed. + return ''; + } + + const slash = matches[1] ? matches[1].trim() : '', + attrList = matches[3] || '', + comment = matches[4] || ''; + let elem = matches[2] || ''; + + if (comment) { + elem = '!--'; + } + + if (!this.allowedHtml[elem.toLowerCase()]) { + // Disallowed HTML element. + return ''; + } + + if (comment) { + return comment; + } + + if (slash != '') { + return ''; + } + + // Is there a closing XHTML slash at the end of the attributes? + const newAttrList = attrList.replace(/(\s?)\/\s*$/g, '$1'), + xhtmlSlash = attrList != newAttrList ? ' /' : ''; + + // Clean up attributes. + let attr2 = this.filterXssAttributes(newAttrList, + (CoreH5PContentValidator.ALLOWED_STYLEABLE_TAGS.indexOf(elem) != -1 ? this.allowedStyles : null)).join(' '); + attr2 = attr2.replace(/[<>]/g, ''); + attr2 = attr2.length ? ' ' + attr2 : ''; + + return '<' + elem + attr2 + xhtmlSlash + '>'; + } + + /** + * Processes a string of HTML attributes. + * + * @param attr HTML attributes. + * @param allowedStyles Allowed styles. + * @return Cleaned up version of the HTML attributes. + */ + protected filterXssAttributes(attr: string, allowedStyles?: RegExp[]): string[] { + const attrArr = []; + let mode = 0, + attrName = '', + skip = false; + + while (attr.length != 0) { + // Was the last operation successful? + let working = 0, + matches, + thisVal; + + switch (mode) { + case 0: + // Attribute name, href for instance. + matches = attr.match(/^([-a-zA-Z]+)/); + if (matches && matches.length > 1) { + attrName = matches[1].toLowerCase(); + skip = (attrName == 'style' || attrName.substr(0, 2) == 'on'); + working = mode = 1; + attr = attr.replace(/^[-a-zA-Z]+/, ''); + } + break; + + case 1: + // Equals sign or valueless ("selected"). + if (attr.match(/^\s*=\s*/)) { + working = 1; + mode = 2; + attr = attr.replace(/^\s*=\s*/, ''); + break; + } + + if (attr.match(/^\s+/)) { + working = 1; + mode = 0; + if (!skip) { + attrArr.push(attrName); + } + attr = attr.replace(/^\s+/, ''); + } + break; + + case 2: + // Attribute value, a URL after href= for instance. + matches = attr.match(/^"([^"]*)"(\s+|$)/); + if (matches && matches.length > 1) { + if (allowedStyles && attrName === 'style') { + // Allow certain styles. + for (let i = 0; i < allowedStyles.length; i++) { + const pattern = allowedStyles[i]; + if (matches[1].match(pattern)) { + // All patterns are start to end patterns, and CKEditor adds one span per style. + attrArr.push('style="' + matches[1] + '"'); + break; + } + } + break; + } + + thisVal = this.filterXssBadProtocol(matches[1]); + + if (!skip) { + attrArr.push(attrName + '="' + thisVal + '"'); + } + working = 1; + mode = 0; + attr = attr.replace(/^"[^"]*"(\s+|$)/, ''); + break; + } + + matches = attr.match(/^'([^']*)'(\s+|$)/); + if (matches && matches.length > 1) { + thisVal = this.filterXssBadProtocol(matches[1]); + + if (!skip) { + attrArr.push(attrName + '="' + thisVal + '"'); + } + working = 1; + mode = 0; + attr = attr.replace(/^'[^']*'(\s+|$)/, ''); + break; + } + + matches = attr.match(/^([^\s\"']+)(\s+|$)/); + if (matches && matches.length > 1) { + thisVal = this.filterXssBadProtocol(matches[1]); + + if (!skip) { + attrArr.push(attrName + '="' + thisVal + '"'); + } + working = 1; + mode = 0; + attr = attr.replace(/^([^\s\"']+)(\s+|$)/, ''); + } + break; + + default: + } + + if (working == 0) { + // Not well formed; remove and try again. + attr = attr.replace(/^("[^"]*("|$)|\'[^\']*(\'|$)||\S)*\s*/, ''); + mode = 0; + } + } + + // The attribute list ends with a valueless attribute like "selected". + if (mode == 1 && !skip) { + attrArr.push(attrName); + } + + return attrArr; + } + + /** + * Processes an HTML attribute value and strips dangerous protocols from URLs. + * + * @param str The string with the attribute value. + * @param decode Whether to decode entities in the $string. + * @return Cleaned up and HTML-escaped version of $string. + */ + filterXssBadProtocol(str: string, decode: boolean = true): string { + // Get the plain text representation of the attribute value (i.e. its meaning). + if (decode) { + str = this.textUtils.decodeHTMLEntities(str); + } + + return this.textUtils.escapeHTML(this.stripDangerousProtocols(str)); + } + + /** + * Strips dangerous protocols (e.g. 'javascript:') from a URI. + * + * @param uri A plain-text URI that might contain dangerous protocols. + * @return A plain-text URI stripped of dangerous protocols. + */ + protected stripDangerousProtocols(uri: string): string { + + const allowedProtocols = { + ftp: true, + http: true, + https: true, + mailto: true + }; + let before; + + // Iteratively remove any invalid protocol found. + do { + before = uri; + const colonPos = uri.indexOf(':'); + + if (colonPos > 0) { + // We found a colon, possibly a protocol. Verify. + const protocol = uri.substr(0, colonPos); + // If a colon is preceded by a slash, question mark or hash, it cannot possibly be part of the URL scheme. + // This must be a relative URL, which inherits the (safe) protocol of the base document. + if (protocol.match(/[/?#]/)) { + break; + } + // Check if this is a disallowed protocol. + if (!allowedProtocols[protocol.toLowerCase()]) { + uri = uri.substr(colonPos + 1); + } + } + } while (before != uri); + + return uri; + } + + /** + * Get metadata semantics. + * + * @return Semantics. + */ + getMetadataSemantics(): any[] { + + if (this.metadataSemantics) { + return this.metadataSemantics; + } + + const ccVersions = this.getCCVersions(); + + this.metadataSemantics = [ + { + name: 'title', + type: 'text', + label: this.translate.instant('core.h5p.title'), + placeholder: 'La Gioconda' + }, + { + name: 'license', + type: 'select', + label: this.translate.instant('core.h5p.license'), + default: 'U', + options: [ + { + value: 'U', + label: this.translate.instant('core.h5p.undisclosed') + }, + { + type: 'optgroup', + label: this.translate.instant('core.h5p.creativecommons'), + options: [ + { + value: 'CC BY', + label: this.translate.instant('core.h5p.ccattribution'), + versions: ccVersions + }, + { + value: 'CC BY-SA', + label: this.translate.instant('core.h5p.ccattributionsa'), + versions: ccVersions + }, + { + value: 'CC BY-ND', + label: this.translate.instant('core.h5p.ccattributionnd'), + versions: ccVersions + }, + { + value: 'CC BY-NC', + label: this.translate.instant('core.h5p.ccattributionnc'), + versions: ccVersions + }, + { + value: 'CC BY-NC-SA', + label: this.translate.instant('core.h5p.ccattributionncsa'), + versions: ccVersions + }, + { + value: 'CC BY-NC-ND', + label: this.translate.instant('core.h5p.ccattributionncnd'), + versions: ccVersions + }, + { + value: 'CC0 1.0', + label: this.translate.instant('core.h5p.ccpdd') + }, + { + value: 'CC PDM', + label: this.translate.instant('core.h5p.pdm') + }, + ] + }, + { + value: 'GNU GPL', + label: this.translate.instant('core.h5p.gpl') + }, + { + value: 'PD', + label: this.translate.instant('core.h5p.pd') + }, + { + value: 'ODC PDDL', + label: this.translate.instant('core.h5p.pddl') + }, + { + value: 'C', + label: this.translate.instant('core.h5p.copyrightstring') + } + ] + }, + { + name: 'licenseVersion', + type: 'select', + label: this.translate.instant('core.h5p.licenseversion'), + options: ccVersions, + optional: true + }, + { + name: 'yearFrom', + type: 'number', + label: this.translate.instant('core.h5p.yearsfrom'), + placeholder: '1991', + min: '-9999', + max: '9999', + optional: true + }, + { + name: 'yearTo', + type: 'number', + label: this.translate.instant('core.h5p.yearsto'), + placeholder: '1992', + min: '-9999', + max: '9999', + optional: true + }, + { + name: 'source', + type: 'text', + label: this.translate.instant('core.h5p.source'), + placeholder: 'https://', + optional: true + }, + { + name: 'authors', + type: 'list', + field: { + name: 'author', + type: 'group', + fields: [ + { + label: this.translate.instant('core.h5p.authorname'), + name: 'name', + optional: true, + type: 'text' + }, + { + name: 'role', + type: 'select', + label: this.translate.instant('core.h5p.authorrole'), + default: 'Author', + options: [ + { + value: 'Author', + label: this.translate.instant('core.h5p.author') + }, + { + value: 'Editor', + label: this.translate.instant('core.h5p.editor') + }, + { + value: 'Licensee', + label: this.translate.instant('core.h5p.licensee') + }, + { + value: 'Originator', + label: this.translate.instant('core.h5p.originator') + } + ] + } + ] + } + }, + { + name: 'licenseExtras', + type: 'text', + widget: 'textarea', + label: this.translate.instant('core.h5p.licenseextras'), + optional: true, + description: this.translate.instant('core.h5p.additionallicenseinfo') + }, + { + name: 'changes', + type: 'list', + field: { + name: 'change', + type: 'group', + label: this.translate.instant('core.h5p.changelog'), + fields: [ + { + name: 'date', + type: 'text', + label: this.translate.instant('core.h5p.date'), + optional: true + }, + { + name: 'author', + type: 'text', + label: this.translate.instant('core.h5p.changedby'), + optional: true + }, + { + name: 'log', + type: 'text', + widget: 'textarea', + label: this.translate.instant('core.h5p.changedescription'), + placeholder: this.translate.instant('core.h5p.changeplaceholder'), + optional: true + } + ] + } + }, + { + name: 'authorComments', + type: 'text', + widget: 'textarea', + label: this.translate.instant('core.h5p.authorcomments'), + description: this.translate.instant('core.h5p.authorcommentsdescription'), + optional: true + }, + { + name: 'contentType', + type: 'text', + widget: 'none' + }, + { + name: 'defaultLanguage', + type: 'text', + widget: 'none' + } + ]; + + return this.metadataSemantics; + } + + /** + * Get copyright semantics. + * + * @return Semantics. + */ + getCopyrightSemantics(): any { + + if (this.copyrightSemantics) { + return this.copyrightSemantics; + } + + const ccVersions = this.getCCVersions(); + + this.copyrightSemantics = { + name: 'copyright', + type: 'group', + label: this.translate.instant('core.h5p.copyrightinfo'), + fields: [ + { + name: 'title', + type: 'text', + label: this.translate.instant('core.h5p.title'), + placeholder: 'La Gioconda', + optional: true + }, + { + name: 'author', + type: 'text', + label: this.translate.instant('core.h5p.author'), + placeholder: 'Leonardo da Vinci', + optional: true + }, + { + name: 'year', + type: 'text', + label: this.translate.instant('core.h5p.years'), + placeholder: '1503 - 1517', + optional: true + }, + { + name: 'source', + type: 'text', + label: this.translate.instant('core.h5p.source'), + placeholder: 'http://en.wikipedia.org/wiki/Mona_Lisa', + optional: true, + regexp: { + pattern: '^http[s]?://.+', + modifiers: 'i' + } + }, + { + name: 'license', + type: 'select', + label: this.translate.instant('core.h5p.license'), + default: 'U', + options: [ + { + value: 'U', + label: this.translate.instant('core.h5p.undisclosed') + }, + { + value: 'CC BY', + label: this.translate.instant('core.h5p.ccattribution'), + versions: ccVersions + }, + { + value: 'CC BY-SA', + label: this.translate.instant('core.h5p.ccattributionsa'), + versions: ccVersions + }, + { + value: 'CC BY-ND', + label: this.translate.instant('core.h5p.ccattributionnd'), + versions: ccVersions + }, + { + value: 'CC BY-NC', + label: this.translate.instant('core.h5p.ccattributionnc'), + versions: ccVersions + }, + { + value: 'CC BY-NC-SA', + label: this.translate.instant('core.h5p.ccattributionncsa'), + versions: ccVersions + }, + { + value: 'CC BY-NC-ND', + label: this.translate.instant('core.h5p.ccattributionncnd'), + versions: ccVersions + }, + { + value: 'GNU GPL', + label: this.translate.instant('core.h5p.licenseGPL'), + versions: [ + { + value: 'v3', + label: this.translate.instant('core.h5p.licenseV3') + }, + { + value: 'v2', + label: this.translate.instant('core.h5p.licenseV2') + }, + { + value: 'v1', + label: this.translate.instant('core.h5p.licenseV1') + } + ] + }, + { + value: 'PD', + label: this.translate.instant('core.h5p.pd'), + versions: [ + { + value: '-', + label: '-' + }, + { + value: 'CC0 1.0', + label: this.translate.instant('core.h5p.licenseCC010U') + }, + { + value: 'CC PDM', + label: this.translate.instant('core.h5p.pdm') + } + ] + }, + { + value: 'C', + label: this.translate.instant('core.h5p.copyrightstring') + } + ] + }, + { + name: 'version', + type: 'select', + label: this.translate.instant('core.h5p.licenseversion'), + options: [] + } + ] + }; + + return this.copyrightSemantics; + } + + /** + * Get CC versions for semantics. + * + * @return CC versions. + */ + protected getCCVersions(): any[] { + return [ + { + value: '4.0', + label: this.translate.instant('core.h5p.licenseCC40') + }, + { + value: '3.0', + label: this.translate.instant('core.h5p.licenseCC30') + }, + { + value: '2.5', + label: this.translate.instant('core.h5p.licenseCC25') + }, + { + value: '2.0', + label: this.translate.instant('core.h5p.licenseCC20') + }, + { + value: '1.0', + label: this.translate.instant('core.h5p.licenseCC10') + } + ]; + } +} diff --git a/src/core/h5p/components/h5p-player/h5p-player.ts b/src/core/h5p/components/h5p-player/h5p-player.ts index 11a5901fc..f13b33073 100644 --- a/src/core/h5p/components/h5p-player/h5p-player.ts +++ b/src/core/h5p/components/h5p-player/h5p-player.ts @@ -129,8 +129,12 @@ export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy { // Local package. this.playerSrc = url; } else { + // Never allow downloading in the app. This will only work if the user is allowed to change the params. + const src = this.src && this.src.replace(CoreH5PProvider.DISPLAY_OPTION_DOWNLOAD + '=1', + CoreH5PProvider.DISPLAY_OPTION_DOWNLOAD + '=0'); + // Get auto-login URL so the user is automatically authenticated. - return this.sitesProvider.getCurrentSite().getAutoLoginUrl(this.src, false).then((url) => { + return this.sitesProvider.getCurrentSite().getAutoLoginUrl(src, false).then((url) => { // Add the preventredirect param so the user can authenticate. this.playerSrc = this.urlUtils.addParamsToUrl(url, {preventredirect: false}); }); diff --git a/src/core/h5p/lang/en.json b/src/core/h5p/lang/en.json index 0cffba19a..a85304502 100644 --- a/src/core/h5p/lang/en.json +++ b/src/core/h5p/lang/en.json @@ -1,5 +1,10 @@ { + "additionallicenseinfo": "Any additional information about the license", "author": "Author", + "authorcomments": "Author comments", + "authorcommentsdescription": "Comments for the editor of the content. (This text will not be published as a part of the copyright info.)", + "authorname": "Author's name", + "authorrole": "Author's role", "by": "by", "cancellabel": "Cancel", "ccattribution": "Attribution (CC BY)", @@ -8,7 +13,11 @@ "ccattributionncsa": "Attribution-NonCommercial-ShareAlike (CC BY-NC-SA)", "ccattributionnd": "Attribution-NoDerivs (CC BY-ND)", "ccattributionsa": "Attribution-ShareAlike (CC BY-SA)", + "ccpdd": "Public Domain Dedication (CC0)", + "changedby": "Changed by", + "changedescription": "Description of change", "changelog": "Changelog", + "changeplaceholder": "Photo cropped, text changed, etc.", "close": "Close", "confirmdialogbody": "Please confirm that you wish to proceed. This action is not reversible.", "confirmdialogheader": "Confirm action", @@ -19,18 +28,24 @@ "contentchanged": "This content has changed since you last used it.", "contenttype": "Content Type", "copyright": "Rights of use", + "copyrightinfo": "Copyright information", "copyrightstring": "Copyright", "copyrighttitle": "View copyright information for this content.", + "creativecommons": "Creative Commons", + "date": "Date", "disablefullscreen": "Disable fullscreen", "download": "Download", "downloadtitle": "Download this content as a H5P file.", + "editor": "Editor", "embed": "Embed", "embedtitle": "View the embed code for this content.", "fullscreen": "Fullscreen", + "gpl": "General Public License v3", "h5ptitle": "Visit H5P.org to check out more cool content.", "hideadvanced": "Hide advanced", "license": "License", "licenseCC010": "CC0 1.0 Universal (CC0 1.0) Public Domain Dedication", + "licenseCC010U": "CC0 1.0 Universal", "licenseCC10": "1.0 Generic", "licenseCC20": "2.0 Generic", "licenseCC25": "2.5 Generic", @@ -40,14 +55,18 @@ "licenseV1": "Version 1", "licenseV2": "Version 2", "licenseV3": "Version 3", + "licensee": "Licensee", "licenseextras": "License Extras", + "licenseversion": "License version", "nocopyright": "No copyright information available for this content.", "offlineDialogBody": "We were unable to send information about your completion of this task. Please check your internet connection.", "offlineDialogHeader": "Your connection to the server was lost", "offlineDialogRetryButtonLabel": "Retry now", "offlineDialogRetryMessage": "Retrying in :num....", "offlineSuccessfulSubmit": "Successfully submitted results.", + "originator": "Originator", "pd": "Public Domain", + "pddl": "Public Domain Dedication and Licence", "pdm": "Public Domain Mark (PDM)", "play": "Play H5P", "resizescript": "Include this script on your website if you want dynamic sizing of the embedded content:", @@ -65,5 +84,8 @@ "thumbnail": "Thumbnail", "title": "Title", "undisclosed": "Undisclosed", - "year": "Year" + "year": "Year", + "years": "Year(s)", + "yearsfrom": "Years (from)", + "yearsto": "Years (to)" } diff --git a/src/core/h5p/providers/h5p.ts b/src/core/h5p/providers/h5p.ts index e6c9e76e9..adb3f4fbd 100644 --- a/src/core/h5p/providers/h5p.ts +++ b/src/core/h5p/providers/h5p.ts @@ -25,6 +25,8 @@ import { CoreMimetypeUtilsProvider } from '@providers/utils/mimetype'; import { CoreUrlUtilsProvider } from '@providers/utils/url'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreH5PUtilsProvider } from './utils'; +import { CoreH5PContentValidator } from '../classes/content-validator'; +import { TranslateService } from '@ngx-translate/core'; import { FileEntry } from '@ionic-native/file'; /** @@ -304,7 +306,8 @@ export class CoreH5PProvider { private h5pUtils: CoreH5PUtilsProvider, private filepoolProvider: CoreFilepoolProvider, private utils: CoreUtilsProvider, - private urlUtils: CoreUrlUtilsProvider) { + private urlUtils: CoreUrlUtilsProvider, + private translate: TranslateService) { this.logger = logger.getInstance('CoreH5PProvider'); @@ -415,6 +418,7 @@ export class CoreH5PProvider { * @return Promise resolved with all of the files content in one string. */ protected concatenateFiles(assets: CoreH5PDependencyAsset[], type: string): Promise { + const basePath = this.fileProvider.getBasePathInstant(); let content = '', promise = Promise.resolve(); // Use a chain of promises so the order is kept. @@ -433,39 +437,42 @@ export class CoreH5PProvider { if (matches && matches.length) { matches.forEach((match) => { - let url = match.replace(/(url\([\'"]?|[\'"]?\)$)/i, ''); + let url = match.replace(/(url\(['"]?|['"]?\)$)/ig, ''); if (treated[url] || url.match(/^(data:|([a-z0-9]+:)?\/)/i)) { return; // Not relative or already treated, skip. } + const pathSplit = assetPath.split('/'); treated[url] = url; /* Find "../" in the URL. If it exists, we have to remove "../" and switch the last folder in the - filepath for the first folder in the url. - For instance: - Path: /H5P.Question-1.4/styles/ - Url: ../images/plus-one.svg - We want this: H5P.Question-1.4/images/ITEMID/minus-one.svg. */ + filepath for the first folder in the url. */ if (url.match(/^\.\.\//)) { - const pathSplit = assetPath.split('/'), - urlSplit = url.split('/').filter((i) => { + const urlSplit = url.split('/').filter((i) => { return i; // Remove empty values. }); - // Remove the first element: ../. - urlSplit.unshift(); + // Remove the file name from the asset path. + pathSplit.pop(); + + // Remove the first element from the file URL: ../ . + urlSplit.shift(); // Put the url's first folder into the asset path. pathSplit[pathSplit.length - 1] = urlSplit[0]; urlSplit.shift(); // Create the new URL and replace it in the file contents. - url = '/' + pathSplit.join('/') + '/' + urlSplit.join('/'); + url = pathSplit.join('/') + '/' + urlSplit.join('/'); - fileContent = fileContent.replace(new RegExp(this.textUtils.escapeForRegex(match), 'g'), - 'url("' + url + '")'); + } else { + pathSplit[pathSplit.length - 1] = url; // Put the whole path to the end of the asset path. + url = pathSplit.join('/'); } + + fileContent = fileContent.replace(new RegExp(this.textUtils.escapeForRegex(match), 'g'), + 'url("' + this.textUtils.concatenatePaths(basePath, url) + '")'); }); } @@ -510,11 +517,11 @@ export class CoreH5PProvider { url: this.getEmbedUrl(site.getURL(), h5pUrl), contentUrl: contentUrl, metadata: content.metadata, - contentUserData: { - 0: { + contentUserData: [ + { state: '{}' } - } + ] }; // Get the core H5P assets, needed by the H5P classes to render the H5P content. @@ -859,8 +866,7 @@ export class CoreH5PProvider { return Promise.resolve(null); } - const dependencies = {}, // In web, dependencies are built by the validator. - params = { + const params = { library: this.libraryToString(content.library), params: this.textUtils.parseJSON(content.params, false) }; @@ -869,90 +875,65 @@ export class CoreH5PProvider { return null; } - // Get the main library data. - return this.loadLibrary(content.library.name, content.library.majorVersion, content.library.minorVersion, siteId) - .then((library) => { + const validator = new CoreH5PContentValidator(this, this.h5pUtils, this.textUtils, this.utils, this.translate, siteId); - library.semantics = this.textUtils.parseJSON(library.semantics, ''); + // Validate the main library and its dependencies. + return validator.validateLibrary(params, {options: [params.library]}).then(() => { - const depKey = 'preloaded-' + library.machineName; - let nextWeight; + // Handle addons. + return this.loadAddons(siteId); + }).then((addons) => { + // Validate addons. Use a chain of promises to calculate the weight properly. + let promise = Promise.resolve(); - if (!dependencies[depKey]) { - dependencies[depKey] = { - library: library, - type: 'preloaded' - }; - } + addons.forEach((addon) => { + const addTo = addon.addTo; - // Get the whole library dependency tree. - return this.findLibraryDependencies(dependencies, library, 1, false, siteId).then((weight) => { - nextWeight = weight; - dependencies[depKey].weight = nextWeight++; + 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]; - // Handle addons. - return this.loadAddons(siteId); - }).then((addons) => { - // Get the dependencies of all the addons. Use a chain of promises to calculate the weight properly. - let promise = Promise.resolve(); + if (type && type.text && type.text.regex && + this.h5pUtils.textAddonMatches(params.params, type.text.regex)) { - addons.forEach((addon) => { - const addTo = this.textUtils.parseJSON(addon.addTo, null); + promise = promise.then(() => { + return validator.addon(addon); + }); - 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)) { - - const addonDepKey = 'preloaded-' + addon.machineName; - dependencies[addonDepKey] = { - library: addon, - type: 'preloaded' - }; - - promise = promise.then(() => { - return this.findLibraryDependencies(dependencies, addon, nextWeight).then((weight) => { - nextWeight = weight; - dependencies[addonDepKey].weight = nextWeight++; - }); - }); - - break; - } + // An addon shall only be added once. + break; } } - }); - - return promise; - }).then(() => { - // Update content dependencies. - content.dependencies = dependencies; - - 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; }); + + 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; }); @@ -990,12 +971,13 @@ export class CoreH5PProvider { } library[property].forEach((dependency: CoreH5PLibraryBasicData) => { - const dependencyKey = type + '-' + dependency.machineName; - if (dependencies[dependencyKey]) { - return; // Skip, already have this. - } 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) => { @@ -1424,7 +1406,7 @@ export class CoreH5PProvider { // 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, siteId); + return this.saveCachedAssets(cachedAssetsHash, dependencies, folderName, siteId); }).then(() => { return files; }); @@ -1652,12 +1634,12 @@ export class CoreH5PProvider { } return db.getRecords(this.LIBRARIES_TABLE, conditions); - }).then((libraries) => { + }).then((libraries): any => { if (!libraries.length) { return Promise.reject(null); } - return libraries[0]; + return this.parseLibDBData(libraries[0]); }); } @@ -1681,7 +1663,9 @@ export class CoreH5PProvider { */ protected getLibraryById(id: number, siteId?: string): Promise { return this.sitesProvider.getSiteDb(siteId).then((db) => { - return db.getRecord(this.LIBRARIES_TABLE, {id: id}); + return db.getRecord(this.LIBRARIES_TABLE, {id: id}).then((library) => { + return this.parseLibDBData(library); + }); }); } @@ -1946,7 +1930,7 @@ export class CoreH5PProvider { const addons = []; for (let i = 0; i < result.rows.length; i++) { - addons.push(result.rows.item(i)); + addons.push(this.parseLibAddonData(result.rows.item(i))); } return addons; @@ -1963,6 +1947,8 @@ export class CoreH5PProvider { * @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) { @@ -1978,31 +1964,35 @@ export class CoreH5PProvider { // Load the main library data. return this.getLibraryById(contentData.mainlibraryid, siteId).then((libData) => { - // 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, - // The embedtype will be always set to 'iframe' to prevent conflicts with JS and CSS. - embedType: '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: { - license: 'U' // Stop "invalid selected option in select" for old content without license chosen. - }, - library: { - id: libData.id, - name: libData.machinename, - majorVersion: libData.majorversion, - minorVersion: libData.minorversion, - embedTypes: libData.embedtypes, - fullscreen: libData.fullscreen - } - }; + // 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 + } + }; + }); }); }); } @@ -2113,6 +2103,31 @@ export class CoreH5PProvider { }); } + /** + * 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. @@ -2172,12 +2187,13 @@ export class CoreH5PProvider { * 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 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}, - siteId?: string): Promise { + folderName: string, siteId?: string): Promise { return this.sitesProvider.getSiteDb(siteId).then((db) => { const promises = []; @@ -2185,7 +2201,8 @@ export class CoreH5PProvider { for (const key in dependencies) { const data = { hash: key, - libraryid: dependencies[key].libraryId + libraryid: dependencies[key].libraryId, + foldername: folderName }; promises.push(db.insertRecord(this.LIBRARIES_CACHEDASSETS_TABLE, data)); @@ -2279,7 +2296,7 @@ export class CoreH5PProvider { for (const libString in librariesJsonData) { const libraryData = librariesJsonData[libString]; - // Find local library identifier + // Find local library identifier. promises.push(this.getLibraryByData(libraryData).catch(() => { // Not found. }).then((dbData) => { @@ -2422,7 +2439,7 @@ export class CoreH5PProvider { preloadedjs: preloadedJS, preloadedcss: preloadedCSS, droplibrarycss: dropLibraryCSS, - semantics: libraryData.semantics, + semantics: typeof libraryData.semantics != 'undefined' ? JSON.stringify(libraryData.semantics) : null, addto: typeof libraryData.addTo != 'undefined' ? JSON.stringify(libraryData.addTo) : null, }; @@ -2497,8 +2514,8 @@ export class CoreH5PProvider { for (const key in librariesInUse) { const dependency = librariesInUse[key]; - if (dependency.library.dropLibraryCss) { - const split = dependency.library.dropLibraryCss.split(', '); + if (( dependency.library).dropLibraryCss) { + const split = ( dependency.library).dropLibraryCss.split(', '); split.forEach((css) => { dropLibraryCssList[css] = css; @@ -2739,7 +2756,7 @@ export type CoreH5PContentDependencyData = { * Data for each content dependency in the dependency tree. */ export type CoreH5PContentDepsTreeDependency = { - library: CoreH5PLibraryData; // Library data. + library: CoreH5PLibraryData | CoreH5PLibraryAddonData; // Library data. type: string; // Dependency type. weight?: number; // An integer determining the order of the libraries when they are loaded. }; @@ -2786,7 +2803,7 @@ export type CoreH5PLibraryAddonData = { patchVersion: number; // Patch version. preloadedJs?: string; // Comma separated list of scripts to load. preloadedCss?: string; // Comma separated list of stylesheets to load. - addTo?: string; // Plugin configuration data. + addTo?: any; // Plugin configuration data. }; /** @@ -2805,8 +2822,8 @@ export type CoreH5PLibraryDBData = { 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?: string; // The semantics definition in json format. - addto?: string; // Plugin configuration data. + semantics?: any; // The semantics definition. + addto?: any; // Plugin configuration data. }; /** diff --git a/src/core/h5p/providers/utils.ts b/src/core/h5p/providers/utils.ts index 9c568acac..026b2b9b7 100644 --- a/src/core/h5p/providers/utils.ts +++ b/src/core/h5p/providers/utils.ts @@ -313,6 +313,27 @@ export class CoreH5PUtilsProvider { }; } + /** + * 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. * diff --git a/src/providers/utils/utils.ts b/src/providers/utils/utils.ts index c4f051645..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; From 69e4fdd036f3f8a2c4117fcf04171a1af2bd616e Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Thu, 28 Nov 2019 17:51:22 +0100 Subject: [PATCH 15/19] MOBILE-2235 iframe: Fix clicks in deep iframe links --- src/providers/utils/iframe.ts | 106 +++++++++++++++++++++------------- 1 file changed, 67 insertions(+), 39 deletions(-) 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. +}; From 1a2ea9485f5b55c31cf44cbb19d2eb861237a924 Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 4 Dec 2019 13:36:43 +0100 Subject: [PATCH 16/19] MOBILE-2235 h5p: Fix check download for external h5p packages --- src/core/h5p/components/h5p-player/h5p-player.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/core/h5p/components/h5p-player/h5p-player.ts b/src/core/h5p/components/h5p-player/h5p-player.ts index f13b33073..daffc9570 100644 --- a/src/core/h5p/components/h5p-player/h5p-player.ts +++ b/src/core/h5p/components/h5p-player/h5p-player.ts @@ -26,6 +26,7 @@ import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreH5PProvider } from '@core/h5p/providers/h5p'; import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; import { CoreConstants } from '@core/constants'; +import { CoreSite } from '@classes/site'; /** * Component to render an H5P package. @@ -46,6 +47,7 @@ export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy { canDownload: boolean; calculating = true; + protected site: CoreSite; protected siteId: string; protected siteCanDownload: boolean; protected observer; @@ -67,7 +69,8 @@ export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy { protected fileProvider: CoreFileProvider) { this.logger = loggerProvider.getInstance('CoreH5PPlayerComponent'); - this.siteId = sitesProvider.getCurrentSiteId(); + this.site = sitesProvider.getCurrentSite(); + this.siteId = this.site.getId(); this.siteCanDownload = this.sitesProvider.getCurrentSite().canDownloadFiles(); } @@ -82,7 +85,7 @@ export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy { * Detect changes on input properties. */ ngOnChanges(changes: {[name: string]: SimpleChange}): void { - // If it's already playing and the src changes, don't change the player src, the user could lose data. + // If it's already playing there's no need to check if it can be downloaded. if (changes.src && !this.showPackage) { this.checkCanDownload(); } @@ -220,7 +223,7 @@ export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy { this.observer && this.observer.off(); this.urlParams = this.urlUtils.extractUrlParams(this.src); - if (this.src && this.siteCanDownload && this.h5pProvider.canGetTrustedH5PFileInSite()) { + if (this.src && this.siteCanDownload && this.h5pProvider.canGetTrustedH5PFileInSite() && this.site.containsUrl(this.src)) { this.calculating = true; From 5dae06ff8106615657f35fcefa5f8ff7b51bdadb Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 4 Dec 2019 13:37:00 +0100 Subject: [PATCH 17/19] MOBILE-2235 h5p: Fix issues identified during PeerReview --- scripts/langindex.json | 1 + .../mod/scorm/providers/prefetch-handler.ts | 5 - .../h5p/components/h5p-player/h5p-player.ts | 16 +- src/core/h5p/providers/h5p.ts | 137 +++++++----------- src/core/h5p/providers/pluginfile-handler.ts | 24 +-- src/providers/file.ts | 23 ++- src/providers/filepool.ts | 2 +- src/providers/plugin-file-delegate.ts | 96 ++++++------ src/providers/utils/dom.ts | 2 +- 9 files changed, 141 insertions(+), 165 deletions(-) diff --git a/scripts/langindex.json b/scripts/langindex.json index 70f4eb012..2b696380e 100644 --- a/scripts/langindex.json +++ b/scripts/langindex.json @@ -1620,6 +1620,7 @@ "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", diff --git a/src/addon/mod/scorm/providers/prefetch-handler.ts b/src/addon/mod/scorm/providers/prefetch-handler.ts index 7c6dcccde..0d3756312 100644 --- a/src/addon/mod/scorm/providers/prefetch-handler.ts +++ b/src/addon/mod/scorm/providers/prefetch-handler.ts @@ -172,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/core/h5p/components/h5p-player/h5p-player.ts b/src/core/h5p/components/h5p-player/h5p-player.ts index daffc9570..f4c0f70d7 100644 --- a/src/core/h5p/components/h5p-player/h5p-player.ts +++ b/src/core/h5p/components/h5p-player/h5p-player.ts @@ -148,7 +148,7 @@ export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy { if (this.canDownload && (this.state == CoreConstants.OUTDATED || this.state == CoreConstants.NOT_DOWNLOADED)) { // Download the package in background if the size is low. - this.downloadInBg().catch((error) => { + this.attemptDownloadInBg().catch((error) => { this.logger.error('Error downloading H5P in background', error); }); } @@ -188,7 +188,7 @@ export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy { * * @return Promise resolved when done. */ - protected downloadInBg(): Promise { + protected attemptDownloadInBg(): Promise { if (this.urlParams && this.src && this.siteCanDownload && this.h5pProvider.canGetTrustedH5PFileInSite() && this.appProvider.isOnline()) { @@ -225,8 +225,6 @@ export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy { if (this.src && this.siteCanDownload && this.h5pProvider.canGetTrustedH5PFileInSite() && this.site.containsUrl(this.src)) { - this.calculating = true; - this.calculateState(); // Listen for changes in the state. @@ -238,19 +236,21 @@ export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy { // An error probably means the file cannot be downloaded or we cannot check it (offline). }); - return; + } else { + this.calculating = false; + this.canDownload = false; } - this.calculating = false; - this.canDownload = false; } /** - * Calcuñate state of the file. + * Calculate state of the file. * * @param fileUrl The H5P file URL. */ protected calculateState(): void { + this.calculating = true; + // Get the status of the file. this.filepoolProvider.getFileStateByUrl(this.siteId, this.urlParams.url).then((state) => { this.canDownload = true; diff --git a/src/core/h5p/providers/h5p.ts b/src/core/h5p/providers/h5p.ts index adb3f4fbd..6750e226c 100644 --- a/src/core/h5p/providers/h5p.ts +++ b/src/core/h5p/providers/h5p.ts @@ -13,7 +13,6 @@ // limitations under the License. import { Injectable } from '@angular/core'; -import { CoreEventsProvider } from '@providers/events'; import { CoreFileProvider } from '@providers/file'; import { CoreFilepoolProvider } from '@providers/filepool'; import { CoreLoggerProvider } from '@providers/logger'; @@ -81,6 +80,10 @@ export class CoreH5PProvider { protected siteSchema: CoreSiteSchema = { name: 'CoreH5PProvider', version: 1, + canBeCleared: [ + this.CONTENT_TABLE, this.LIBRARIES_TABLE, this.LIBRARY_DEPENDENCIES_TABLE, this.CONTENTS_LIBRARIES_TABLE, + this.LIBRARIES_CACHEDASSETS_TABLE + ], tables: [ { name: this.CONTENT_TABLE, @@ -101,10 +104,6 @@ export class CoreH5PProvider { type: 'INTEGER', notNull: true }, - { - name: 'displayoptions', // Not used right now, but we keep the field to be consistent with Moodle web. - type: 'INTEGER' - }, { name: 'foldername', type: 'TEXT', @@ -293,12 +292,11 @@ export class CoreH5PProvider { ] }; - protected ROOT_CACHE_KEY = 'mmH5P:'; + protected ROOT_CACHE_KEY = 'CoreH5P:'; protected logger; constructor(logger: CoreLoggerProvider, - eventsProvider: CoreEventsProvider, private sitesProvider: CoreSitesProvider, private textUtils: CoreTextUtilsProvider, private fileProvider: CoreFileProvider, @@ -312,12 +310,6 @@ export class CoreH5PProvider { this.logger = logger.getInstance('CoreH5PProvider'); this.sitesProvider.registerSiteSchema(this.siteSchema); - - eventsProvider.on(CoreEventsProvider.SITE_STORAGE_DELETED, (data) => { - this.deleteAllData(data.siteId).catch((error) => { - this.logger.error('Error deleting all H5P data from site.', error); - }); - }); } /** @@ -568,24 +560,6 @@ export class CoreH5PProvider { }); } - /** - * Delete all the H5P data from the DB of a certain site. - * - * @param siteId Site ID. - * @return Promise resolved when done. - */ - protected deleteAllData(siteId: string): Promise { - return this.sitesProvider.getSiteDb(siteId).then((db) => { - return Promise.all([ - db.deleteRecords(this.CONTENT_TABLE), - db.deleteRecords(this.LIBRARIES_TABLE), - db.deleteRecords(this.LIBRARY_DEPENDENCIES_TABLE), - db.deleteRecords(this.CONTENTS_LIBRARIES_TABLE), - db.deleteRecords(this.LIBRARIES_CACHEDASSETS_TABLE) - ]); - }); - } - /** * Delete cached assets from DB and filesystem. * @@ -784,14 +758,8 @@ export class CoreH5PProvider { const folderName = this.mimeUtils.removeExtension(file.name), destFolder = this.textUtils.concatenatePaths(CoreFileProvider.TMPFOLDER, 'h5p/' + folderName); - // Make sure the dest dir doesn't exist already. - return this.fileProvider.removeDir(destFolder).catch(() => { - // Ignore errors. - }).then(() => { - return this.fileProvider.createDir(destFolder); - }).then(() => { - return this.fileProvider.unzipFile(file.toURL(), destFolder); - }).then(() => { + // 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) => { @@ -830,11 +798,6 @@ export class CoreH5PProvider { return Promise.reject(error); }); }); - }).then(() => { - // Remove tmp folder. - return this.fileProvider.removeDir(destFolder).catch(() => { - // Ignore errors, it will be deleted eventually. - }); }).then(() => { // Create the content player. @@ -843,6 +806,11 @@ export class CoreH5PProvider { 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. + }); }); }); }); @@ -1002,6 +970,40 @@ export class CoreH5PProvider { }); } + /** + * 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. * @@ -1215,7 +1217,7 @@ export class CoreH5PProvider { }).then((url) => { // Add display options to the URL. return this.getContentDataByUrl(fileUrl, siteId).then((data) => { - const options = this.validateDisplayOptions(this.getDisplayOptionsFromUrlParams(urlParams), data.id); + const options = this.fixDisplayOptions(this.getDisplayOptionsFromUrlParams(urlParams), data.id); return this.urlUtils.addParamsToUrl(url, options, undefined, true); }); @@ -1478,7 +1480,7 @@ export class CoreH5PProvider { * @return Display options as object. */ getDisplayOptionsForView(disable: number, id: number): CoreH5PDisplayOptions { - return this.validateDisplayOptions(this.getDisplayOptionsAsObject(disable), id); + return this.fixDisplayOptions(this.getDisplayOptionsAsObject(disable), id); } /** @@ -1733,8 +1735,8 @@ export class CoreH5PProvider { * @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 displayoptions DB field. - return CoreH5PDisplayOptionBehaviour.CONTROLLED_BY_AUTHOR_DEFAULT_OFF; // CONTROLLED_BY_AUTHOR_DEFAULT_OFF. + // 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; } /** @@ -1776,7 +1778,7 @@ export class CoreH5PProvider { * * @param url The file URL. * @param options Options. - * @param ignoreCache Whether to ignore cache.. + * @param ignoreCache Whether to ignore cache. * @param siteId Site ID. If not defined, current site. * @return Promise resolved with the file data. */ @@ -2135,7 +2137,6 @@ export class CoreH5PProvider { * * @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. */ protected processH5PFiles(destFolder: string, entries: (DirectoryEntry | FileEntry)[]) @@ -2227,7 +2228,6 @@ export class CoreH5PProvider { const data: any = { jsoncontent: content.params, - displayoptions: null, mainlibraryid: content.library.libraryId, timemodified: Date.now(), filtered: null, @@ -2597,40 +2597,6 @@ export class CoreH5PProvider { return db.updateRecords(this.CONTENT_TABLE, data, {id: id}); }); } - - /** - * Validate display options, updating them if needed. - * - * @param displayOptions The display options to validate. - * @param id Package ID. - */ - validateDisplayOptions(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) == false) { - 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; - } } /** @@ -2701,7 +2667,6 @@ 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. - displayoptions: number; // H5P Button display options. Not used right now. 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. diff --git a/src/core/h5p/providers/pluginfile-handler.ts b/src/core/h5p/providers/pluginfile-handler.ts index 71b82053d..a152945c0 100644 --- a/src/core/h5p/providers/pluginfile-handler.ts +++ b/src/core/h5p/providers/pluginfile-handler.ts @@ -37,17 +37,6 @@ export class CoreH5PPluginFileHandler implements CorePluginFileHandler { protected fileProvider: CoreFileProvider, protected h5pProvider: CoreH5PProvider) { } - /** - * 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. - */ - canDownloadFile(file: CoreWSExternalFile, siteId?: string): Promise { - return this.h5pProvider.getTrustedH5PFile(file.fileurl, {}, false, siteId); - } - /** * React to a file being deleted. * @@ -61,6 +50,17 @@ export class CoreH5PPluginFileHandler implements CorePluginFileHandler { 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 * CoreDomUtilsProvider.extractDownloadableFilesFromHtml. @@ -68,7 +68,7 @@ export class CoreH5PPluginFileHandler implements CorePluginFileHandler { * @param container Container where to get the URLs from. * @return {string[]} List of URLs. */ - getDownloadableFiles(container: HTMLElement): string[] { + getDownloadableFilesFromHTML(container: HTMLElement): string[] { const iframes = Array.from(container.querySelectorAll('iframe.h5p-iframe')); const urls = []; diff --git a/src/providers/file.ts b/src/providers/file.ts index 915ac5a36..9dedaedbf 100644 --- a/src/providers/file.ts +++ b/src/providers/file.ts @@ -762,7 +762,7 @@ export class CoreFileProvider { * @return Promise resolved when the entry is moved. */ moveDir(originalPath: string, newPath: string, destDirExists?: boolean): Promise { - return this.moveFileOrDir(originalPath, newPath, true); + return this.moveFileOrDir(originalPath, newPath, true, destDirExists); } /** @@ -775,7 +775,7 @@ export class CoreFileProvider { * @return Promise resolved when the entry is moved. */ moveFile(originalPath: string, newPath: string, destDirExists?: boolean): Promise { - return this.moveFileOrDir(originalPath, newPath, false); + return this.moveFileOrDir(originalPath, newPath, false, destDirExists); } /** @@ -991,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)); diff --git a/src/providers/filepool.ts b/src/providers/filepool.ts index dca0d1e89..07058ee71 100644 --- a/src/providers/filepool.ts +++ b/src/providers/filepool.ts @@ -1339,7 +1339,7 @@ export class CoreFilepoolProvider { */ protected fixPluginfileURL(siteId: string, fileUrl: string, timemodified: number = 0): Promise { - return this.pluginFileDelegate.canDownloadFile({fileurl: fileUrl, timemodified: timemodified}).then((file) => { + return this.pluginFileDelegate.getDownloadableFile({fileurl: fileUrl, timemodified: timemodified}).then((file) => { return this.sitesProvider.getSite(siteId).then((site) => { return site.checkAndFixPluginfileURL(file.fileurl); diff --git a/src/providers/plugin-file-delegate.ts b/src/providers/plugin-file-delegate.ts index 01aea603e..f5582a28a 100644 --- a/src/providers/plugin-file-delegate.ts +++ b/src/providers/plugin-file-delegate.ts @@ -48,15 +48,6 @@ export interface CorePluginFileHandler { */ getComponentRevisionReplace?(args: string[]): string; - /** - * 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. - */ - canDownloadFile?(file: CoreWSExternalFile, siteId?: string): Promise; - /** * React to a file being deleted. * @@ -67,6 +58,15 @@ export interface CorePluginFileHandler { */ 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 * CoreDomUtilsProvider.extractDownloadableFilesFromHtml. @@ -74,7 +74,7 @@ export interface CorePluginFileHandler { * @param container Container where to get the URLs from. * @return {string[]} List of URLs. */ - getDownloadableFiles?(container: HTMLElement): string[]; + getDownloadableFilesFromHTML?(container: HTMLElement): string[]; /** * Get a file size. @@ -116,39 +116,6 @@ export class CorePluginFileDelegate { this.logger = logger.getInstance('CorePluginFileDelegate'); } - /** - * 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. - */ - canDownloadFile(file: CoreWSExternalFile, siteId?: string): Promise { - const handler = this.getHandlerForFile(file); - - return this.canHandlerDownloadFile(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 canHandlerDownloadFile(file: CoreWSExternalFile, handler: CorePluginFileHandler, siteId?: string) - : Promise { - - if (handler && handler.canDownloadFile) { - return handler.canDownloadFile(file, siteId).then((newFile) => { - return newFile || file; - }); - } - - return Promise.resolve(file); - } - /** * React to a file being deleted. * @@ -167,6 +134,39 @@ export class CorePluginFileDelegate { 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); + } + /** * Get the handler for a certain pluginfile url. * @@ -201,14 +201,14 @@ export class CorePluginFileDelegate { * @param container Container where to get the URLs from. * @return List of URLs. */ - getDownloadableFiles(container: HTMLElement): string[] { + getDownloadableFilesFromHTML(container: HTMLElement): string[] { let files = []; for (const component in this.handlers) { const handler = this.handlers[component]; - if (handler && handler.getDownloadableFiles) { - files = files.concat(handler.getDownloadableFiles(container)); + if (handler && handler.getDownloadableFilesFromHTML) { + files = files.concat(handler.getDownloadableFilesFromHTML(container)); } } @@ -256,8 +256,8 @@ export class CorePluginFileDelegate { const handler = this.getHandlerForFile(file); // First of all check if file can be downloaded. - return this.canHandlerDownloadFile(file, handler, siteId).then((canDownload) => { - if (!canDownload) { + return this.getHandlerDownloadableFile(file, handler, siteId).then((file) => { + if (!file) { return 0; } diff --git a/src/providers/utils/dom.ts b/src/providers/utils/dom.ts index 18dec5369..564b889c2 100644 --- a/src/providers/utils/dom.ts +++ b/src/providers/utils/dom.ts @@ -286,7 +286,7 @@ export class CoreDomUtilsProvider { } // Now get other files from plugin file handlers. - urls = urls.concat(this.pluginFileDelegate.getDownloadableFiles(element)); + urls = urls.concat(this.pluginFileDelegate.getDownloadableFilesFromHTML(element)); return urls; } From 40fc0e2b0080169462bb85d820d05a060c2eb4ce Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Wed, 4 Dec 2019 13:27:41 +0100 Subject: [PATCH 18/19] MOBILE-2235 core: Use isStateDownloaded where it should --- src/components/file/file.ts | 2 +- src/core/course/providers/helper.ts | 2 +- .../providers/module-prefetch-delegate.ts | 18 ++++++++++++------ .../h5p/components/h5p-player/h5p-player.ts | 6 ++++-- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/components/file/file.ts b/src/components/file/file.ts index b8e7a7965..57c509107 100644 --- a/src/components/file/file.ts +++ b/src/components/file/file.ts @@ -169,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; diff --git a/src/core/course/providers/helper.ts b/src/core/course/providers/helper.ts index 82b9d03d6..9cfb9385c 100644 --- a/src/core/course/providers/helper.ts +++ b/src/core/course/providers/helper.ts @@ -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) { 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/h5p/components/h5p-player/h5p-player.ts b/src/core/h5p/components/h5p-player/h5p-player.ts index f4c0f70d7..1166c0136 100644 --- a/src/core/h5p/components/h5p-player/h5p-player.ts +++ b/src/core/h5p/components/h5p-player/h5p-player.ts @@ -25,6 +25,7 @@ import { CoreUrlUtilsProvider } from '@providers/utils/url'; import { CoreUtilsProvider } from '@providers/utils/utils'; import { CoreH5PProvider } from '@core/h5p/providers/h5p'; import { CorePluginFileDelegate } from '@providers/plugin-file-delegate'; +import { CoreFileHelperProvider } from '@providers/file-helper'; import { CoreConstants } from '@core/constants'; import { CoreSite } from '@classes/site'; @@ -66,7 +67,8 @@ export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy { protected appProvider: CoreAppProvider, protected domUtils: CoreDomUtilsProvider, protected pluginFileDelegate: CorePluginFileDelegate, - protected fileProvider: CoreFileProvider) { + protected fileProvider: CoreFileProvider, + protected fileHelper: CoreFileHelperProvider) { this.logger = loggerProvider.getInstance('CoreH5PPlayerComponent'); this.site = sitesProvider.getCurrentSite(); @@ -106,7 +108,7 @@ export class CoreH5PPlayerComponent implements OnInit, OnChanges, OnDestroy { this.addResizerScript(); - if (this.canDownload && (this.state == CoreConstants.DOWNLOADED || this.state == CoreConstants.OUTDATED)) { + if (this.canDownload && this.fileHelper.isStateDownloaded(this.state)) { // Package is downloaded, use the local URL. promise = this.h5pProvider.getContentIndexFileUrl(this.urlParams.url, this.urlParams, this.siteId).catch(() => { From e0849668e3818e3018349baba2170c8535c95dad Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Mon, 9 Dec 2019 15:34:08 +0100 Subject: [PATCH 19/19] MOBILE-2235 h5p: Support and use isEnabled function in file delegate --- .../folder/providers/pluginfile-handler.ts | 9 +++ .../mod/forum/providers/prefetch-handler.ts | 2 +- .../glossary/providers/prefetch-handler.ts | 2 +- .../mod/imscp/providers/pluginfile-handler.ts | 9 +++ .../mod/lesson/providers/prefetch-handler.ts | 3 +- .../mod/page/providers/pluginfile-handler.ts | 9 +++ .../mod/quiz/providers/prefetch-handler.ts | 2 +- .../resource/providers/pluginfile-handler.ts | 9 +++ .../mod/scorm/providers/pluginfile-handler.ts | 9 +++ src/classes/delegate.ts | 12 ++-- .../course/classes/module-prefetch-handler.ts | 4 +- src/core/course/providers/helper.ts | 6 +- src/core/h5p/providers/pluginfile-handler.ts | 11 ++- src/core/question/providers/helper.ts | 2 +- src/providers/filepool.ts | 53 +++++++++++++++ src/providers/plugin-file-delegate.ts | 67 +++++-------------- src/providers/utils/dom.ts | 17 +++-- upgrade.txt | 4 ++ 18 files changed, 160 insertions(+), 70 deletions(-) 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/forum/providers/prefetch-handler.ts b/src/addon/mod/forum/providers/prefetch-handler.ts index 2f1e05b5d..743d99b97 100644 --- a/src/addon/mod/forum/providers/prefetch-handler.ts +++ b/src/addon/mod/forum/providers/prefetch-handler.ts @@ -96,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 34a94b142..590294ad7 100644 --- a/src/addon/mod/glossary/providers/prefetch-handler.ts +++ b/src/addon/mod/glossary/providers/prefetch-handler.ts @@ -93,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/lesson/providers/prefetch-handler.ts b/src/addon/mod/lesson/providers/prefetch-handler.ts index f0732535e..46ef36db2 100644 --- a/src/addon/mod/lesson/providers/prefetch-handler.ts +++ b/src/addon/mod/lesson/providers/prefetch-handler.ts @@ -419,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/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/quiz/providers/prefetch-handler.ts b/src/addon/mod/quiz/providers/prefetch-handler.ts index fdd66c7e5..0040dd8e8 100644 --- a/src/addon/mod/quiz/providers/prefetch-handler.ts +++ b/src/addon/mod/quiz/providers/prefetch-handler.ts @@ -126,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/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/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/core/course/classes/module-prefetch-handler.ts b/src/core/course/classes/module-prefetch-handler.ts index 0448dcd33..815cbf5c4 100644 --- a/src/core/course/classes/module-prefetch-handler.ts +++ b/src/core/course/classes/module-prefetch-handler.ts @@ -195,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 9cfb9385c..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; } } @@ -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/h5p/providers/pluginfile-handler.ts b/src/core/h5p/providers/pluginfile-handler.ts index a152945c0..13657fc2d 100644 --- a/src/core/h5p/providers/pluginfile-handler.ts +++ b/src/core/h5p/providers/pluginfile-handler.ts @@ -63,7 +63,7 @@ export class CoreH5PPluginFileHandler implements CorePluginFileHandler { /** * Given an HTML element, get the URLs of the files that should be downloaded and weren't treated by - * CoreDomUtilsProvider.extractDownloadableFilesFromHtml. + * CoreFilepoolProvider.extractDownloadableFilesFromHtml. * * @param container Container where to get the URLs from. * @return {string[]} List of URLs. @@ -103,6 +103,15 @@ export class CoreH5PPluginFileHandler implements CorePluginFileHandler { }); } + /** + * 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. * 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/providers/filepool.ts b/src/providers/filepool.ts index 07058ee71..9e8fa5790 100644 --- a/src/providers/filepool.ts +++ b/src/providers/filepool.ts @@ -1239,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. diff --git a/src/providers/plugin-file-delegate.ts b/src/providers/plugin-file-delegate.ts index f5582a28a..17354d4b5 100644 --- a/src/providers/plugin-file-delegate.ts +++ b/src/providers/plugin-file-delegate.ts @@ -13,18 +13,17 @@ // 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. @@ -69,7 +68,7 @@ export interface CorePluginFileHandler { /** * Given an HTML element, get the URLs of the files that should be downloaded and weren't treated by - * CoreDomUtilsProvider.extractDownloadableFilesFromHtml. + * CoreFilepoolProvider.extractDownloadableFilesFromHtml. * * @param container Container where to get the URLs from. * @return {string[]} List of URLs. @@ -108,12 +107,13 @@ export interface CorePluginFileHandler { * 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); } /** @@ -167,18 +167,6 @@ export class CorePluginFileDelegate { return Promise.resolve(file); } - /** - * Get the handler for a certain pluginfile url. - * - * @param component Component of the plugin. - * @return Handler. Undefined if no handler found for the plugin. - */ - protected getPluginHandler(component: string): CorePluginFileHandler { - if (typeof this.handlers[component] != 'undefined') { - return this.handlers[component]; - } - } - /** * Get the RegExp of the component and filearea described in the URL. * @@ -187,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); @@ -196,7 +184,7 @@ export class CorePluginFileDelegate { /** * Given an HTML element, get the URLs of the files that should be downloaded and weren't treated by - * CoreDomUtilsProvider.extractDownloadableFilesFromHtml. + * CoreFilepoolProvider.extractDownloadableFilesFromHtml. * * @param container Container where to get the URLs from. * @return List of URLs. @@ -204,8 +192,8 @@ export class CorePluginFileDelegate { getDownloadableFilesFromHTML(container: HTMLElement): string[] { let files = []; - for (const component in this.handlers) { - const handler = this.handlers[component]; + for (const component in this.enabledHandlers) { + const handler = this.enabledHandlers[component]; if (handler && handler.getDownloadableFilesFromHTML) { files = files.concat(handler.getDownloadableFilesFromHTML(container)); @@ -278,8 +266,8 @@ export class CorePluginFileDelegate { * @return Handler. */ protected getHandlerForFile(file: CoreWSExternalFile): CorePluginFileHandler { - for (const component in this.handlers) { - const handler = this.handlers[component]; + for (const component in this.enabledHandlers) { + const handler = this.enabledHandlers[component]; if (handler && handler.shouldHandleFile && handler.shouldHandleFile(file)) { return handler; @@ -287,25 +275,6 @@ export class CorePluginFileDelegate { } } - /** - * Register a handler. - * - * @param handler The handler to register. - * @return True if registered successfully, false otherwise. - */ - registerHandler(handler: CorePluginFileHandler): boolean { - if (typeof this.handlers[handler.component || handler.name] !== 'undefined') { - this.logger.log(`Handler '${handler.component}' already registered`); - - return false; - } - - this.logger.log(`Registered handler '${handler.component}'`); - this.handlers[handler.component || handler.name] = handler; - - return true; - } - /** * Removes the revision number from a file URL. * @@ -315,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); diff --git a/src/providers/utils/dom.ts b/src/providers/utils/dom.ts index 564b889c2..6bc976dd6 100644 --- a/src/providers/utils/dom.ts +++ b/src/providers/utils/dom.ts @@ -22,7 +22,7 @@ import { TranslateService } from '@ngx-translate/core'; import { CoreTextUtilsProvider } from './text'; import { CoreAppProvider } from '../app'; import { CoreConfigProvider } from '../config'; -import { CorePluginFileDelegate } from '../plugin-file-delegate'; +import { CoreLoggerProvider } from '../logger'; import { CoreUrlUtilsProvider } from './url'; import { CoreFileProvider } from '@providers/file'; import { CoreConstants } from '@core/constants'; @@ -62,6 +62,7 @@ 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, @@ -76,7 +77,9 @@ export class CoreDomUtilsProvider { private sanitizer: DomSanitizer, private popoverCtrl: PopoverController, private fileProvider: CoreFileProvider, - private pluginFileDelegate: CorePluginFileDelegate) { + loggerProvider: CoreLoggerProvider) { + + this.logger = loggerProvider.getInstance('CoreDomUtilsProvider'); // Check if debug messages should be displayed. configProvider.get(CoreConstants.SETTINGS_DEBUG_DISPLAY, false).then((debugDisplay) => { @@ -260,9 +263,13 @@ export class CoreDomUtilsProvider { * * @param html HTML code. * @return List of file urls. + * @deprecated since 3.8. Use CoreFilepoolProvider.extractDownloadableFilesFromHtml instead. */ extractDownloadableFilesFromHtml(html: string): string[] { - let urls = []; + this.logger.error('The function extractDownloadableFilesFromHtml has been moved to CoreFilepoolProvider.' + + ' Please use that function instead of this one.'); + + const urls = []; let elements; const element = this.convertToElement(html); @@ -285,9 +292,6 @@ export class CoreDomUtilsProvider { } } - // Now get other files from plugin file handlers. - urls = urls.concat(this.pluginFileDelegate.getDownloadableFilesFromHTML(element)); - return urls; } @@ -296,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); 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.